diff --git a/mpf/core/light_controller.py b/mpf/core/light_controller.py index 51a9bbd67..131275339 100644 --- a/mpf/core/light_controller.py +++ b/mpf/core/light_controller.py @@ -17,9 +17,8 @@ def __init__(self, machine): # Generate and add color correction profiles to the machine self.light_color_correction_profiles = dict() - self.lights_to_update = set() + # will only get initialised if there are lights self._initialised = False - self._updater_task = None def initialise_light_subsystem(self): """Initialise the light subsystem.""" @@ -53,30 +52,6 @@ def initialise_light_subsystem(self): linear_cutoff=profile_parameters['linear_cutoff']) self.light_color_correction_profiles[profile_name] = profile - # schedule the single machine-wide update to write the current light of - # each light to the hardware - self._updater_task = self.machine.clock.schedule_interval( - self._update_lights, 1 / self.machine.config['mpf']['default_light_hw_update_hz']) - + # add setting for brightness self.machine.settings.add_setting(SettingEntry("brightness", "Brightness", 100, "brightness", 1.0, {0.25: "25%", 0.5: "50%", 0.75: "75%", 1.0: "100% (default)"})) - - def _update_lights(self, dt): - """Write lights to hardware platform. - - Called periodically (default at the end of every frame) to write the - new light colors to the hardware for the lights that changed during that - frame. - - Args: - dt: time since last call - """ - del dt - - new_lights_to_update = set() - if self.lights_to_update: - for light in self.lights_to_update: - light.write_color_to_hw_driver() - if light.fade_in_progress: - new_lights_to_update.add(light) - self.lights_to_update = new_lights_to_update diff --git a/mpf/core/platform.py b/mpf/core/platform.py index 2fe705ab8..811bb6339 100644 --- a/mpf/core/platform.py +++ b/mpf/core/platform.py @@ -1,7 +1,7 @@ """Contains the parent class for all platforms.""" import abc -from typing import Optional +from typing import Optional, Callable, Tuple from mpf.devices.switch import Switch from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface diff --git a/mpf/devices/light.py b/mpf/devices/light.py index 795a2ec78..d62adeed7 100644 --- a/mpf/devices/light.py +++ b/mpf/devices/light.py @@ -1,22 +1,25 @@ """Contains the Light class.""" +from functools import partial from operator import itemgetter +from typing import Tuple from mpf.core.device_monitor import DeviceMonitor from mpf.core.rgb_color import RGBColor from mpf.core.system_wide_device import SystemWideDevice from mpf.devices.driver import ReconfiguredDriver -from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface +from mpf.platforms.interfaces.light_platform_interface import LightPlatformSoftwareFade -class DriverLight(LightPlatformInterface): +class DriverLight(LightPlatformSoftwareFade): """A coil which is used to drive a light.""" - def __init__(self, driver): + def __init__(self, driver, loop, software_fade_ms): """Initialise coil as light.""" + super().__init__(loop, software_fade_ms) self.driver = driver - def set_brightness(self, brightness: float, fade_ms: int): + def set_brightness(self, brightness: float): """Set pwm to coil.""" # TODO: fix driver interface if brightness <= 0: @@ -43,9 +46,7 @@ def __init__(self, machine, name): super().__init__(machine, name) self.machine.light_controller.initialise_light_subsystem() - self.fade_in_progress = False self.default_fade_ms = None - self._max_fade_ms = 0 self._color_correction_profile = None @@ -152,18 +153,15 @@ def _load_hw_drivers(self): if not channels: raise AssertionError("Light {} has no channels.".format(self.name)) - max_fade_ms = [] - for num, channel in channels.items(): + for color, channel in channels.items(): channel = self.machine.config_validator.validate_config("light_channels", channel) - self.hw_drivers[num] = self._load_hw_driver(channel) - max_fade_ms.append(self.hw_drivers[num].get_max_fade_ms()) + self.hw_drivers[color] = self._load_hw_driver(channel, color) - self._max_fade_ms = min(max_fade_ms) - - def _load_hw_driver(self, channel): + def _load_hw_driver(self, channel, color): """Load one channel.""" if channel['platform'] == "drivers": - return DriverLight(self.machine.coils[channel['number'].strip()]) + return DriverLight(self.machine.coils[channel['number'].strip()], self.machine.clock.loop, + int(1 / self.machine.config['mpf']['default_light_hw_update_hz'] * 1000)) else: platform = self.machine.get_platform_sections('lights', channel['platform']) return platform.configure_light(channel['number'], channel['subtype'], channel['platform_settings']) @@ -277,7 +275,7 @@ def off(self, fade_ms=None, priority=0, key=None, **kwargs): def _add_to_stack(self, color, fade_ms, priority, key): curr_color = self.get_color() - self.remove_from_stack_by_key(key) + self._remove_from_stack_by_key(key) if fade_ms: new_color = curr_color @@ -318,15 +316,16 @@ def remove_from_stack_by_key(self, key): were removed, the light will be updated with whatever's below it. If no settings remain after these are removed, the light will turn off. """ - self.debug_log("Removing key '%s' from stack", key) + self._remove_from_stack_by_key(key) + self._schedule_update() + def _remove_from_stack_by_key(self, key): + self.debug_log("Removing key '%s' from stack", key) self.stack[:] = [x for x in self.stack if x['key'] != key] - self._schedule_update() - def _schedule_update(self): - self.fade_in_progress = self.stack and self.stack[0]['dest_time'] - self.machine.light_controller.lights_to_update.add(self) + for color, hw_driver in self.hw_drivers.items(): + hw_driver.set_fade(partial(self._get_brightness_and_fade, color=color)) def clear_stack(self): """Remove all entries from the stack and resets this light to 'off'.""" @@ -381,39 +380,64 @@ def color_correct(self, color): return self._color_correction_profile.apply(color) - def get_color(self): - """Return an RGBColor() instance of the 'color' setting of the highest color setting in the stack. - - This is usually the same color as the physical light, but not always (since physical lights are updated once per - frame, this value could vary. - - Also note the color returned is the "raw" color that does has not had the color correction profile applied. - """ + def _get_color_and_fade(self, max_fade_ms: int) -> Tuple[RGBColor, int]: try: color_settings = self.stack[0] except IndexError: # no stack - return RGBColor('off') + return RGBColor('off'), -1 # no fade if not color_settings['dest_time']: - return color_settings['dest_color'] + return color_settings['dest_color'], -1 + + current_time = self.machine.clock.get_time() + + # fade is done + if current_time >= color_settings['dest_time']: + return color_settings['dest_color'], -1 + + target_time = current_time + (max_fade_ms / 1000.0) + # check if fade will be done before max_fade_ms + if target_time > color_settings['dest_time']: + return color_settings['dest_time'], int(color_settings['dest_time'] - current_time) / 1000 # figure out the ratio of how far along we are try: - ratio = ((self.machine.clock.get_time() - - color_settings['start_time']) / - (color_settings['dest_time'] - - color_settings['start_time'])) + ratio = ((target_time - color_settings['start_time']) / + (color_settings['dest_time'] - color_settings['start_time'])) except ZeroDivisionError: ratio = 1.0 - self.debug_log("Fade, ratio: %s", ratio) + return RGBColor.blend(color_settings['start_color'], color_settings['dest_color'], ratio), max_fade_ms + + def _get_brightness_and_fade(self, max_fade_ms: int, color: str) -> Tuple[float, int]: + uncorrected_color, fade_ms = self._get_color_and_fade(max_fade_ms) + corrected_color = self.gamma_correct(uncorrected_color) + corrected_color = self.color_correct(corrected_color) - if ratio >= 1.0: # fade is done - return color_settings['dest_color'] + if color in ["red", "blue", "green"]: + brightness = getattr(corrected_color, color) / 255.0 + elif color == "white": + brightness = min(corrected_color.red, corrected_color.green, corrected_color.blue) / 255.0 else: - return RGBColor.blend(color_settings['start_color'], color_settings['dest_color'], ratio) + raise AssertionError("Invalid color {}".format(color)) + return brightness, fade_ms + + def get_color(self): + """Return an RGBColor() instance of the 'color' setting of the highest color setting in the stack. + + This is usually the same color as the physical light, but not always (since physical lights are updated once per + frame, this value could vary. + + Also note the color returned is the "raw" color that does has not had the color correction profile applied. + """ + return self._get_color_and_fade(0)[0] + + @property + def fade_in_progress(self) -> bool: + """Return true if a fade is in progress.""" + return bool(self.stack and self.stack[0]['dest_time'] > self.machine.clock.get_time()) def write_color_to_hw_driver(self): """Set color to hardware platform. diff --git a/mpf/platforms/fast/fast.py b/mpf/platforms/fast/fast.py index 9a4abdef6..7f474ecab 100644 --- a/mpf/platforms/fast/fast.py +++ b/mpf/platforms/fast/fast.py @@ -188,9 +188,11 @@ def update_leds(self, dt): dt: time since last call """ del dt - msg = 'RS:' + ','.join(["%s%s" % (led.number, led.current_color) - for led in self.fast_leds.values()]) - self.rgb_connection.send(msg) + dirty_leds = [led for led in self.fast_leds.values() if led.dirty] + + if dirty_leds: + msg = 'RS:' + ','.join(["%s%s" % (led.number, led.current_color) for led in dirty_leds]) + self.rgb_connection.send(msg) def get_hw_switch_states(self): """Return hardware states.""" @@ -477,9 +479,11 @@ def configure_light(self, number, subtype, platform_settings) -> LightPlatformIn 'but no connection to a NET processor is ' 'available') if subtype == "gi": - return FASTGIString(number, self.net_connection.send) + return FASTGIString(number, self.net_connection.send, self.machine, + int(1 / self.machine.config['mpf']['default_light_hw_update_hz'] * 1000)) elif subtype == "matrix": - return FASTMatrixLight(number, self.net_connection.send) + return FASTMatrixLight(number, self.net_connection.send, self.machine, + int(1 / self.machine.config['mpf']['default_light_hw_update_hz'] * 1000)) elif not subtype or subtype == "led": if not self.flag_led_tick_registered: # Update leds every frame @@ -489,7 +493,8 @@ def configure_light(self, number, subtype, platform_settings) -> LightPlatformIn number_str, channel = number.split("-") if number_str not in self.fast_leds: - self.fast_leds[number_str] = FASTDirectLED(number_str) + self.fast_leds[number_str] = FASTDirectLED( + number_str, int(self.config['hardware_led_fade_time'])) fast_led_channel = FASTDirectLEDChannel(self.fast_leds[number_str], channel) return fast_led_channel diff --git a/mpf/platforms/fast/fast_gi.py b/mpf/platforms/fast/fast_gi.py index 8168c34dd..2a317fcd4 100644 --- a/mpf/platforms/fast/fast_gi.py +++ b/mpf/platforms/fast/fast_gi.py @@ -2,26 +2,22 @@ import logging from mpf.core.utility_functions import Util -from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface +from mpf.platforms.interfaces.light_platform_interface import LightPlatformSoftwareFade -class FASTGIString(LightPlatformInterface): +class FASTGIString(LightPlatformSoftwareFade): """A FAST GI string in a WPC machine.""" - def __init__(self, number, sender): - """Initialise GI string. - - TODO: Need to implement the enable_relay and control which strings are - dimmable. - """ + def __init__(self, number, sender, machine, software_fade_ms: int): + """Initialise GI string.""" + super().__init__(machine.clock.loop, software_fade_ms) self.log = logging.getLogger('FASTGIString.0x' + str(number)) self.number = number self.send = sender - def set_brightness(self, brightness: float, fade_ms: int): - """Turn on GI string.""" - del fade_ms + def set_brightness(self, brightness: float): + """Set GI string to a certain brightness.""" brightness = int(brightness * 255) if brightness >= 255: brightness = 255 diff --git a/mpf/platforms/fast/fast_led.py b/mpf/platforms/fast/fast_led.py index 2510494a3..56d2870b5 100644 --- a/mpf/platforms/fast/fast_led.py +++ b/mpf/platforms/fast/fast_led.py @@ -1,6 +1,8 @@ """WS2812 LED on the FAST controller.""" import logging +from typing import Callable, Tuple + from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface @@ -8,9 +10,11 @@ class FASTDirectLED: """FAST RGB LED.""" - def __init__(self, number): + def __init__(self, number: str, hardware_fade_ms: int): """Initialise FAST LED.""" self.number = number + self.dirty = True + self.hardware_fade_ms = hardware_fade_ms self.colors = [0, 0, 0] self.log = logging.getLogger('FASTLED') # All FAST LEDs are 3 element RGB and are set using hex strings @@ -19,9 +23,18 @@ def __init__(self, number): @property def current_color(self): """Return current color.""" - return "{0}{1}{2}".format(hex(int(self.colors[0]))[2:].zfill(2), - hex(int(self.colors[1]))[2:].zfill(2), - hex(int(self.colors[2]))[2:].zfill(2)) + result = "" + self.dirty = False + for color in self.colors: + if callable(color): + brightness, fade_ms = color(self.hardware_fade_ms) + result += hex(int(brightness * 255))[2:].zfill(2) + if fade_ms >= self.hardware_fade_ms: + self.dirty = True + else: + result += "00" + + return result class FASTDirectLEDChannel(LightPlatformInterface): @@ -33,8 +46,7 @@ def __init__(self, led: FASTDirectLED, channel): self.led = led self.channel = int(channel) - def set_brightness(self, brightness: float, fade_ms: int): - """Instantly set this LED channel to the brightness passed.""" - # FAST does not support fade per light/channel - del fade_ms - self.led.colors[self.channel] = int(brightness * 255) + def set_fade(self, color_and_fade_callback: Callable[[int], Tuple[float, int]]): + """Set brightness via callback.""" + self.led.dirty = True + self.led.colors[self.channel] = color_and_fade_callback diff --git a/mpf/platforms/fast/fast_light.py b/mpf/platforms/fast/fast_light.py index 752f4f41a..ed232d4e9 100644 --- a/mpf/platforms/fast/fast_light.py +++ b/mpf/platforms/fast/fast_light.py @@ -2,21 +2,20 @@ import logging from mpf.core.utility_functions import Util -from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface +from mpf.platforms.interfaces.light_platform_interface import LightPlatformSoftwareFade -class FASTMatrixLight(LightPlatformInterface): +class FASTMatrixLight(LightPlatformSoftwareFade): """A direct light on a fast controller.""" - def __init__(self, number, sender): + def __init__(self, number, sender, machine, fade_interval_ms: int): """Initialise light.""" + super().__init__(machine.clock.loop, fade_interval_ms) self.log = logging.getLogger('FASTMatrixLight') self.number = number self.send = sender - def set_brightness(self, brightness: float, fade_ms: int): - """Enable (turn on) this driver.""" - # FAST gi does not support fades - del fade_ms + def set_brightness(self, brightness: float): + """Set matrix light brightness.""" self.send('L1:{},{}'.format(self.number, Util.int_to_hex_string(int(brightness * 255)))) diff --git a/mpf/platforms/interfaces/light_platform_interface.py b/mpf/platforms/interfaces/light_platform_interface.py index 5b8849a5a..87664c22b 100644 --- a/mpf/platforms/interfaces/light_platform_interface.py +++ b/mpf/platforms/interfaces/light_platform_interface.py @@ -1,27 +1,101 @@ """Interface for a light hardware devices.""" import abc +import asyncio +from asyncio import AbstractEventLoop + +from typing import Callable, Tuple class LightPlatformInterface(metaclass=abc.ABCMeta): """Interface for a light in hardware platforms.""" - def get_max_fade_ms(self): - """Return maximum fade_ms for this light.""" - return 0 + def set_fade(self, color_and_fade_callback: Callable[[int], Tuple[float, int]]): + pass + + +class LightPlatformDirectFade(LightPlatformInterface, metaclass=abc.ABCMeta): + + """Implement a light which can set fade and brightness directly.""" + + def __init__(self, loop: AbstractEventLoop): + """Initialise light.""" + self.loop = loop + self.task = None + + @abc.abstractmethod + def get_max_fade_ms(self) -> int: + """Return max fade time.""" + raise NotImplementedError() + + def get_fade_interval_ms(self) -> int: + """Return max fade time.""" + return self.get_max_fade_ms() + + def set_fade(self, color_and_fade_callback: Callable[[int], Tuple[float, int]]): + """Perform a fade with either a asyncio task or with a single command.""" + max_fade_ms = self.get_max_fade_ms() + + brightness, fade_ms = color_and_fade_callback(max_fade_ms) + self.set_brightness_and_fade(brightness, max(fade_ms, 0)) + if fade_ms >= max_fade_ms: + # we have to continue the fade later + if self.task: + self.task.cancel() + self.task = self.loop.create_task(self._fade(color_and_fade_callback)) - def mark_dirty(self): - """Mark light dirty.""" - return + @asyncio.coroutine + def _fade(self, color_and_fade_callback): + while True: + yield from asyncio.sleep(self.get_fade_interval_ms() / 1000, loop=self.loop) + max_fade_ms = self.get_max_fade_ms() + brightness, fade_ms = color_and_fade_callback(max_fade_ms) + self.set_brightness_and_fade(brightness, max(fade_ms, 0)) + if fade_ms < max_fade_ms: + return @abc.abstractmethod - def set_brightness(self, brightness: float, fade_ms: int): + def set_brightness_and_fade(self, brightness: float, fade_ms: int): """Set the light to the specified brightness. Args: brightness: float of the brightness fade_ms: ms to fade the light + Returns: + None + """ + raise NotImplementedError('set_brightness_and_fade method must be defined to use this base class') + + +class LightPlatformSoftwareFade(LightPlatformDirectFade, metaclass=abc.ABCMeta): + + """Implement a light which cannot fade on its own.""" + + def __init__(self, loop: AbstractEventLoop, software_fade_ms: int): + """Initialise light with software fade.""" + super().__init__(loop) + self.software_fade_ms = software_fade_ms + + def get_max_fade_ms(self) -> int: + """Return max fade time.""" + return 0 + + def get_fade_interval_ms(self) -> int: + """Return software fade interval.""" + return self.software_fade_ms + + def set_brightness_and_fade(self, brightness: float, fade_ms: int): + """Set brightness and ensure that fade is 0.""" + assert fade_ms == 0 + self.set_brightness(brightness) + + def set_brightness(self, brightness: float): + """Set the light to the specified brightness. + + Args: + brightness: float of the brightness + Returns: None """ diff --git a/mpf/platforms/openpixel.py b/mpf/platforms/openpixel.py index be8a29caa..ba4d0b94b 100644 --- a/mpf/platforms/openpixel.py +++ b/mpf/platforms/openpixel.py @@ -6,6 +6,9 @@ import logging +from typing import Callable +from typing import Tuple + from mpf.core.platform import LightsPlatform from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface @@ -93,10 +96,14 @@ def __init__(self, opc_client, channel, channel_number, debug): self.channel_number = int(channel_number) self.opc_client.add_pixel(self.opc_channel, self.channel_number) + def set_fade(self, color_and_fade_callback: Callable[[int], Tuple[float, int]]): + self.opc_client.set_pixel_color(self.opc_channel, self.channel_number, color_and_fade_callback) + def set_brightness(self, brightness: float, fade_ms: int): """Set brightness of the led.""" # fadecandy does not support fade per led del fade_ms + return if self.debug: self.log.debug("Setting brightness: %s", brightness) self.opc_client.set_pixel_color(self.opc_channel, self.channel_number, int(brightness * 255)) @@ -152,15 +159,15 @@ def add_pixel(self, channel, led): self.channels[channel] += [0 for _ in range(leds_to_add)] - def set_pixel_color(self, channel, pixel, brightness): + def set_pixel_color(self, channel, pixel, callback: Callable[[int], Tuple[float, int]]): """Set an invidual pixel color. Args: channel: Int of the OPC channel for this pixel. pixel: Int of the number for this pixel on that channel. - brightness: brightness value as an integer between 0-255. + callback: callback to get brightness """ - self.channels[channel][pixel] = brightness + self.channels[channel][pixel] = callback self.dirty = True def tick(self, dt): @@ -197,11 +204,14 @@ def update_pixels(self, pixels, channel=0): """ # Build the OPC message msg = bytearray() + max_fade_ms = int(1 / self.machine.config['mpf']['default_light_hw_update_hz']) len_hi_byte = int(len(pixels) / 256) len_lo_byte = (len(pixels)) % 256 header = bytes([channel, 0, len_hi_byte, len_lo_byte]) msg.extend(header) for brightness in pixels: + if callable(brightness): + brightness = brightness(max_fade_ms)[0] * 255 brightness = min(255, max(0, int(brightness))) msg.append(brightness) self.send(bytes(msg)) diff --git a/mpf/platforms/opp/opp.py b/mpf/platforms/opp/opp.py index aa1cb0760..6c5bbd6b6 100644 --- a/mpf/platforms/opp/opp.py +++ b/mpf/platforms/opp/opp.py @@ -268,7 +268,7 @@ def _parse_gen2_board(self, chain_serial, msg, read_input_msg): has_neo = True wing_index += 1 if incand_mask != 0: - self.opp_incands.append(OPPIncandCard(chain_serial, msg[0], incand_mask, self.incandDict)) + self.opp_incands.append(OPPIncandCard(chain_serial, msg[0], incand_mask, self.incandDict, self.machine)) if sol_mask != 0: self.opp_solenoid.append( OPPSolenoidCard(chain_serial, msg[0], sol_mask, self.solDict, self)) diff --git a/mpf/platforms/opp/opp_incand.py b/mpf/platforms/opp/opp_incand.py index 1abf0637d..73f72dff6 100644 --- a/mpf/platforms/opp/opp_incand.py +++ b/mpf/platforms/opp/opp_incand.py @@ -1,7 +1,7 @@ """Support for incandescent wings in OPP.""" import logging -from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface +from mpf.platforms.interfaces.light_platform_interface import LightPlatformSoftwareFade from mpf.platforms.opp.opp_rs232_intf import OppRs232Intf @@ -10,7 +10,7 @@ class OPPIncandCard(object): """An incandescent wing card.""" - def __init__(self, chain_serial, addr, mask, incand_dict): + def __init__(self, chain_serial, addr, mask, incand_dict, machine): """Initialise OPP incandescent card.""" self.log = logging.getLogger('OPPIncand') self.addr = addr @@ -18,6 +18,7 @@ def __init__(self, chain_serial, addr, mask, incand_dict): self.oldState = 0 self.newState = 0 self.mask = mask + hardware_fade_ms = int(1 / machine.config['mpf']['default_light_hw_update_hz'] * 1000) self.log.debug("Creating OPP Incand at hardware address: 0x%02x", addr) @@ -25,25 +26,25 @@ def __init__(self, chain_serial, addr, mask, incand_dict): for index in range(0, 32): if ((1 << index) & mask) != 0: number = card + '-' + str(index) - incand_dict[chain_serial + '-' + number] = OPPIncand(self, number) + incand_dict[chain_serial + '-' + number] = OPPIncand(self, number, hardware_fade_ms, machine.clock.loop) -class OPPIncand(LightPlatformInterface): +class OPPIncand(LightPlatformSoftwareFade): """A driver of an incandescent wing card.""" - def __init__(self, incand_card, number): + def __init__(self, incand_card, number, hardware_fade_ms, loop): """Initialise Incandescent wing card driver.""" + super().__init__(loop, hardware_fade_ms) self.incandCard = incand_card self.number = number - def set_brightness(self, brightness: float, fade_ms: int): + def set_brightness(self, brightness: float): """Enable (turns on) this driver. Args: brightness: brightness 0 (off) to 255 (on) for this incandescent light. OPP only supports on (>0) or off. """ - del fade_ms _, incand = self.number.split("-") curr_bit = (1 << int(incand)) if brightness == 0: diff --git a/mpf/platforms/opp/opp_neopixel.py b/mpf/platforms/opp/opp_neopixel.py index 07fae8a7a..0a98835a2 100644 --- a/mpf/platforms/opp/opp_neopixel.py +++ b/mpf/platforms/opp/opp_neopixel.py @@ -1,7 +1,7 @@ """OPP WS2812 wing.""" import logging -from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface +from mpf.platforms.interfaces.light_platform_interface import LightPlatformSoftwareFade from mpf.platforms.opp.opp_rs232_intf import OppRs232Intf @@ -26,10 +26,12 @@ def __init__(self, chain_serial, addr, neo_card_dict, platform): def add_channel(self, pixel_number, neo_dict, index): """Add a channel.""" + hardware_fade_ms = int(1 / self.platform.machine.config['mpf']['default_light_hw_update_hz'] * 1000) if self.card + '-' + str(pixel_number) not in neo_dict: self.add_neopixel(pixel_number, neo_dict) - return OPPLightChannel(neo_dict[self.card + '-' + str(pixel_number)], int(index)) + return OPPLightChannel(neo_dict[self.card + '-' + str(pixel_number)], int(index), hardware_fade_ms, + self.platform.machine.clock.loop) def add_neopixel(self, number, neo_dict): """Add a LED channel.""" @@ -41,16 +43,17 @@ def add_neopixel(self, number, neo_dict): return pixel -class OPPLightChannel(LightPlatformInterface): +class OPPLightChannel(LightPlatformSoftwareFade): """A channel of a WS2812 LED.""" - def __init__(self, led, index): + def __init__(self, led, index, hardware_fade_ms, loop): """Initialise led channel.""" + super().__init__(loop, hardware_fade_ms) self.led = led self.index = index - def set_brightness(self, brightness: float, fade_ms: int): + def set_brightness(self, brightness: float): """Set brightness.""" self.led.set_channel(self.index, int(brightness * 255)) diff --git a/mpf/platforms/p_roc_common.py b/mpf/platforms/p_roc_common.py index 6db2bf308..bc32a4cef 100644 --- a/mpf/platforms/p_roc_common.py +++ b/mpf/platforms/p_roc_common.py @@ -4,7 +4,7 @@ import platform import sys import time -from typing import Union, List +from typing import Union, List, Callable, Tuple from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface from mpf.platforms.p_roc_devices import PROCSwitch, PROCMatrixLight @@ -296,7 +296,7 @@ def configure_light(self, number, subtype, platform_settings) -> LightPlatformIn else: proc_num = self.pinproc.decode(self.machine_type, str(number)) - return PROCMatrixLight(proc_num, self.proc) + return PROCMatrixLight(proc_num, self.proc, self.machine) elif subtype == "led": board, index = number.split("-") polarity = platform_settings and platform_settings.get("polarity", False) @@ -839,13 +839,21 @@ def _normalise_color(self, value): else: return value - def set_brightness(self, brightness: float, fade_ms: int): - """Instantly set this LED to the color passed. + def set_fade(self, color_and_fade_callback: Callable[[int], Tuple[float, int]]): + """Set or fade this LED to the color passed. + + Can fade for up to 100 days so do not bother about too long fades. Args: brightness: brightness of this channel """ - self.proc.led_color(self.board, self.address, self._normalise_color(int(brightness * 255))) + brightness, fade_ms = color_and_fade_callback(int(pow(2, 31) * 4)) + if fade_ms <= 0: + # just set color + self.proc.led_color(self.board, self.address, self._normalise_color(int(brightness * 255))) + else: + # fade to color + self.proc.led_fade(self.board, self.address, self._normalise_color(int(brightness * 255)), int(fade_ms / 4)) def is_pdb_address(addr): diff --git a/mpf/platforms/p_roc_devices.py b/mpf/platforms/p_roc_devices.py index 36c69cb4a..91d9af053 100644 --- a/mpf/platforms/p_roc_devices.py +++ b/mpf/platforms/p_roc_devices.py @@ -1,7 +1,7 @@ """P-Roc hardware platform devices.""" import logging -from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface +from mpf.platforms.interfaces.light_platform_interface import LightPlatformSoftwareFade from mpf.platforms.interfaces.switch_platform_interface import SwitchPlatformInterface from mpf.platforms.interfaces.driver_platform_interface import DriverPlatformInterface from mpf.core.utility_functions import Util @@ -132,20 +132,21 @@ def state(self): return self.proc.driver_get_state(self.number) -class PROCMatrixLight(LightPlatformInterface): +class PROCMatrixLight(LightPlatformSoftwareFade): """A P-ROC matrix light device.""" - def __init__(self, number, proc_driver): + def __init__(self, number, proc_driver, machine): """Initialise matrix light device.""" + super().__init__(machine.clock.loop, int(1 / machine.config['mpf']['default_light_hw_update_hz'] * 1000)) self.log = logging.getLogger('PROCMatrixLight') self.number = number self.proc = proc_driver - def set_brightness(self, brightness: float, fade_ms: int): + def set_brightness(self, brightness: float): """Enable (turns on) this driver.""" - if brightness >= 1.0: - self.proc.driver_schedule(number=self.number, schedule=0xffffffff, - cycle_seconds=0, now=True) + if brightness > 0: + pwm_on_ms, pwm_off_ms = (Util.pwm8_to_on_off(int(brightness * 8))) + self.proc.driver_patter(self.number, pwm_on_ms, pwm_off_ms, 0, True) else: self.proc.driver_disable(self.number) diff --git a/mpf/platforms/spike/spike.py b/mpf/platforms/spike/spike.py index 641f6d3d7..008d8f71f 100644 --- a/mpf/platforms/spike/spike.py +++ b/mpf/platforms/spike/spike.py @@ -3,7 +3,7 @@ import logging -from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface +from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface, LightPlatformDirectFade from mpf.platforms.interfaces.driver_platform_interface import DriverPlatformInterface @@ -25,17 +25,22 @@ def __init__(self, config, platform): self.platform = platform -class SpikeLight(LightPlatformInterface): +class SpikeLight(LightPlatformDirectFade): """A light on a Stern Spike node board.""" def __init__(self, node, number, platform): """Initialise light.""" + super().__init__(platform.machine.clock.loop) self.node = node self.number = number self.platform = platform - def set_brightness(self, brightness: float, fade_ms: int): + def get_max_fade_ms(self): + """Return max fade ms.""" + return 199 # int(199 * 1.28) = 255 + + def set_brightness_and_fade(self, brightness: float, fade_ms: int): """Set brightness of channel.""" fade_time = int(fade_ms * 1.28) brightness = int(brightness * 255) diff --git a/mpf/platforms/virtual.py b/mpf/platforms/virtual.py index 76d35cf8e..5fd8f9313 100644 --- a/mpf/platforms/virtual.py +++ b/mpf/platforms/virtual.py @@ -1,6 +1,7 @@ """Contains code for a virtual hardware platform.""" import logging +from typing import Callable, Tuple from mpf.platforms.interfaces.dmd_platform import DmdPlatformInterface from mpf.platforms.interfaces.light_platform_interface import LightPlatformInterface @@ -275,11 +276,23 @@ def __init__(self, number, settings): """Initialise LED.""" self.settings = settings self.number = number - self.current_brightness = 0 + self.color_and_fade_callback = None + + @property + def current_brightness(self, max_fade=0) -> float: + if self.color_and_fade_callback: + return self.color_and_fade_callback(max_fade)[0] + else: + return 0 + + def set_fade(self, color_and_fade_callback: Callable[[int], Tuple[float, int]]): + """Store CB function.""" + self.color_and_fade_callback = color_and_fade_callback def set_brightness(self, brightness: float, fade_ms: int): """Set brightness.""" - self.current_brightness = brightness + pass + #self.current_brightness = brightness class VirtualServo(ServoPlatformInterface): diff --git a/mpf/tests/MpfTestCase.py b/mpf/tests/MpfTestCase.py index c2035a23e..7c58f2735 100644 --- a/mpf/tests/MpfTestCase.py +++ b/mpf/tests/MpfTestCase.py @@ -312,21 +312,15 @@ def assertLightColor(self, light_name, color): if isinstance(color, str) and color.lower() == 'on': color = self.machine.lights[light_name].config['default_on_color'] - self.assertAlmostEqual(RGBColor(color).red / 255.0, - self.machine.lights[light_name].hw_drivers["red"].current_brightness) - self.assertAlmostEqual(RGBColor(color).green / 255.0, - self.machine.lights[light_name].hw_drivers["green"].current_brightness) - self.assertAlmostEqual(RGBColor(color).blue / 255.0, - self.machine.lights[light_name].hw_drivers["blue"].current_brightness) + self.assertEqual(RGBColor(color), self.machine.lights[light_name].get_color(), + "{} != {}".format(RGBColor(color).name, self.machine.lights[light_name].get_color().name)) def assertNotLightColor(self, light_name, color): if isinstance(color, str) and color.lower() == 'on': color = self.machine.lights[light_name].config['default_on_color'] - self.assertFalse( - RGBColor(color).red / 255.0 == self.machine.lights[light_name].hw_drivers["red"].current_brightness and - RGBColor(color).green / 255.0 == self.machine.lights[light_name].hw_drivers["green"].current_brightness and - RGBColor(color).blue / 255.0 == self.machine.lights[light_name].hw_drivers["blue"].current_brightness) + self.assertNotEqual(RGBColor(color), self.machine.lights[light_name].get_color(), + "{} == {}".format(RGBColor(color).name, self.machine.lights[light_name].get_color().name)) def assertLightColors(self, light_name, color_list, secs=1, check_delta=.1): colors = list() @@ -340,10 +334,7 @@ def assertLightColors(self, light_name, color_list, secs=1, check_delta=.1): break for x in range(int(secs / check_delta)): - color = RGBColor() - color.red = int(self.machine.lights[light_name].hw_drivers["red"].current_brightness * 255) - color.green = int(self.machine.lights[light_name].hw_drivers["green"].current_brightness * 255) - color.blue = int(self.machine.lights[light_name].hw_drivers["blue"].current_brightness * 255) + color = self.machine.lights[light_name].get_color() colors.append(color) self.advance_time_and_run(check_delta) diff --git a/mpf/tests/test_DeviceLED.py b/mpf/tests/test_DeviceLED.py index a1fabfa8c..5e5fc7464 100644 --- a/mpf/tests/test_DeviceLED.py +++ b/mpf/tests/test_DeviceLED.py @@ -11,17 +11,9 @@ def getConfigFile(self): def getMachinePath(self): return 'tests/machine_files/led/' - def _synchronise_led_update(self): - ts = self.machine.light_controller._updater_task.get_next_call_time() - self.assertTrue(ts) - self.advance_time_and_run(ts - self.machine.clock.get_time()) - self.advance_time_and_run(.01) - def test_color_and_stack(self): led1 = self.machine.lights.led1 - self._synchronise_led_update() - # set led1 to red and check the color and stack led1.color('red') @@ -130,7 +122,6 @@ def test_color_and_stack(self): def test_fades(self): led1 = self.machine.lights.led1 - self._synchronise_led_update() led1.color('red', fade_ms=2000) self.machine_run() @@ -155,7 +146,7 @@ def test_fades(self): self.assertEqual(color_setting['dest_color'], RGBColor('red')) self.assertEqual(led1.get_color(), RGBColor((127, 0, 0))) self.assertIsNone(color_setting['key']) - self.assertLightColor("led1", [126, 0, 0]) + self.assertLightColor("led1", [127, 0, 0]) # advance to after the fade is done self.advance_time_and_run(2) @@ -170,24 +161,22 @@ def test_fades(self): led = self.machine.lights.led4 self.assertEqual(1000, led.default_fade_ms) - self._synchronise_led_update() led.color('white') self.advance_time_and_run(.02) self.advance_time_and_run(.5) - self.assertLightColor("led4", [130, 130, 130]) + self.assertLightColor("led4", [132, 132, 132]) self.advance_time_and_run(.5) self.assertLightColor("led4", [255, 255, 255]) def test_restore_to_fade_in_progress(self): led1 = self.machine.lights.led1 - self._synchronise_led_update() led1.color('red', fade_ms=4000) self.advance_time_and_run(0.02) self.advance_time_and_run(1) # fade is 25% complete - self.assertLightColor("led1", [64, 0, 0]) + self.assertLightColor("led1", [65, 0, 0]) # higher priority color which goes on top of fade (higher priority # becuase it was added after the first, even though the priorities are @@ -200,7 +189,7 @@ def test_restore_to_fade_in_progress(self): led1.remove_from_stack_by_key('test') # should go back to the fade in progress, which is now 75% complete self.advance_time_and_run(1) - self.assertLightColor("led1", [191, 0, 0]) + self.assertLightColor("led1", [192, 0, 0]) self.assertTrue(led1.fade_in_progress) # go to 1 sec after fade and make sure it finished @@ -214,13 +203,12 @@ def test_multiple_concurrent_fades(self): led1 = self.machine.lights.led1 - self._synchronise_led_update() led1.color('red', fade_ms=4000) self.advance_time_and_run(0.02) self.advance_time_and_run(1) # fade is 25% complete - self.assertLightColor("led1", [64, 0, 0]) + self.assertLightColor("led1", [65, 0, 0]) # start a blue 2s fade led1.color('blue', key='test', fade_ms=2000) @@ -232,7 +220,7 @@ def test_multiple_concurrent_fades(self): # future self.advance_time_and_run(1) - self.assertLightColor("led1", [33, 0, 126]) + self.assertLightColor("led1", [33, 0, 127]) # advance past the end self.advance_time_and_run(2) @@ -243,15 +231,30 @@ def test_color_correction(self): led = self.machine.lights.led_corrected led.color(RGBColor("white")) self.advance_time_and_run() - self.assertLightColor("led_corrected", [210, 184, 159]) + # color is uncorrected + self.assertLightColor("led_corrected", RGBColor("white")) + # corrected color + self.assertEqual(RGBColor([210, 184, 159]), led.color_correct(led.get_color())) + # check hardware + self.assertEqual(210 / 255.0, led.hw_drivers["red"].current_brightness) + self.assertEqual(184 / 255.0, led.hw_drivers["green"].current_brightness) + self.assertEqual(159 / 255.0, led.hw_drivers["blue"].current_brightness) led.color(RGBColor([128, 128, 128])) self.advance_time_and_run() - self.assertLightColor("led_corrected", [96, 83, 70]) + self.assertLightColor("led_corrected", [128, 128, 128]) + self.assertEqual(RGBColor([96, 83, 70]), led.color_correct(led.get_color())) + self.assertEqual(96 / 255.0, led.hw_drivers["red"].current_brightness) + self.assertEqual(83 / 255.0, led.hw_drivers["green"].current_brightness) + self.assertEqual(70 / 255.0, led.hw_drivers["blue"].current_brightness) led.color(RGBColor("black")) self.advance_time_and_run() self.assertLightColor("led_corrected", [0, 0, 0]) + self.assertEqual(RGBColor([0, 0, 0]), led.color_correct(led.get_color())) + self.assertEqual(0 / 255.0, led.hw_drivers["red"].current_brightness) + self.assertEqual(0 / 255.0, led.hw_drivers["green"].current_brightness) + self.assertEqual(0 / 255.0, led.hw_drivers["blue"].current_brightness) def test_non_rgb_leds(self): # test bgr @@ -291,9 +294,15 @@ def test_brightness_correction(self): led.color(RGBColor((100, 100, 100))) self.advance_time_and_run(1) self.assertLightColor("led1", [100, 100, 100]) + self.assertEqual(100 / 255.0, led.hw_drivers["red"].current_brightness) + self.assertEqual(100 / 255.0, led.hw_drivers["green"].current_brightness) + self.assertEqual(100 / 255.0, led.hw_drivers["blue"].current_brightness) self.machine.create_machine_var("brightness", 0.8) led.color(RGBColor((100, 100, 100))) self.advance_time_and_run(1) - self.assertLightColor("led1", [80, 80, 80]) + self.assertLightColor("led1", [100, 100, 100]) + self.assertEqual(80 / 255.0, led.hw_drivers["red"].current_brightness) + self.assertEqual(80 / 255.0, led.hw_drivers["green"].current_brightness) + self.assertEqual(80 / 255.0, led.hw_drivers["blue"].current_brightness) diff --git a/mpf/tests/test_Fast.py b/mpf/tests/test_Fast.py index 92477509b..9cc91ffff 100644 --- a/mpf/tests/test_Fast.py +++ b/mpf/tests/test_Fast.py @@ -596,7 +596,7 @@ def test_dmd_update(self): def test_lights_and_leds(self): self._test_matrix_light() self._test_pdb_gi_light() - self._test_rdb_led() + self._test_pdb_led() def _test_matrix_light(self): # test enable of matrix light @@ -627,8 +627,50 @@ def _test_matrix_light(self): self.net_cpu.expected_commands = { "L1:23,00": "L1:P", } - self.machine.lights.test_pdb_light.on(brightness=0) - self.advance_time_and_run(.1) + self.machine.lights.test_pdb_light.on(brightness=255, fade_ms=100) + self.advance_time_and_run(.01) + self.assertFalse(self.net_cpu.expected_commands) + + # step 1 + self.net_cpu.expected_commands = { + "L1:23,32": "L1:P", + } + self.advance_time_and_run(.02) + self.assertFalse(self.net_cpu.expected_commands) + + # step 2 + self.net_cpu.expected_commands = { + "L1:23,65": "L1:P", + } + self.advance_time_and_run(.02) + self.assertFalse(self.net_cpu.expected_commands) + + # step 3 + self.net_cpu.expected_commands = { + "L1:23,98": "L1:P", + } + self.advance_time_and_run(.02) + self.assertFalse(self.net_cpu.expected_commands) + + # step 4 + self.net_cpu.expected_commands = { + "L1:23,CB": "L1:P", + } + self.advance_time_and_run(.02) + self.assertFalse(self.net_cpu.expected_commands) + + # step 5 + self.net_cpu.expected_commands = { + "L1:23,FE": "L1:P", + } + self.advance_time_and_run(.02) + self.assertFalse(self.net_cpu.expected_commands) + + # step 6 + self.net_cpu.expected_commands = { + "L1:23,FF": "L1:P", + } + self.advance_time_and_run(.02) self.assertFalse(self.net_cpu.expected_commands) def _test_pdb_gi_light(self): @@ -670,13 +712,12 @@ def _test_pdb_gi_light(self): self.advance_time_and_run(.1) self.assertFalse(self.net_cpu.expected_commands) - def _test_rdb_led(self): + def _test_pdb_led(self): self.advance_time_and_run() device = self.machine.lights.test_led device2 = self.machine.lights.test_led2 self.assertEqual("000000", self.rgb_cpu.leds['97']) self.assertEqual("000000", self.rgb_cpu.leds['99']) - self.rgb_cpu.leds = {} # test led on device.on() self.advance_time_and_run(1) diff --git a/mpf/tests/test_LedPlayer.py b/mpf/tests/test_LedPlayer.py index d4ea9bdf1..31915dcdb 100644 --- a/mpf/tests/test_LedPlayer.py +++ b/mpf/tests/test_LedPlayer.py @@ -11,12 +11,6 @@ def getConfigFile(self): def getMachinePath(self): return 'tests/machine_files/led_player/' - def _synchronise_led_update(self): - ts = self.machine.light_controller._updater_task.get_next_call_time() - self.assertTrue(ts) - self.advance_time_and_run(ts - self.machine.clock.get_time()) - self.advance_time_and_run(.01) - def test_config_player_config_processing(self): led1 = self.machine.lights.led1 led2 = self.machine.lights.led2 @@ -75,7 +69,6 @@ def test_led_player(self): # post event2, which is a tag with led1 and led2, but at priority 100 # led1 should remain unchanged since it was set at priority 200, # led2 should fade to blue since it was red before at priority 0 - self._synchronise_led_update() self.machine.events.post('event2') self.advance_time_and_run(.1) @@ -83,7 +76,7 @@ def test_led_player(self): self.assertEqual(200, self.machine.lights.led1.stack[0]['priority']) # fade is half way from red to blue - self.assertLightColor("led2", [141, 0, 114]) + self.assertLightColor("led2", [128, 0, 127]) self.assertEqual(100, self.machine.lights.led2.stack[0]['priority']) self.advance_time_and_run() @@ -107,14 +100,13 @@ def test_led_player(self): self.assertFalse(self.machine.lights.led3.stack) # test fades via express config with a few different options - self._synchronise_led_update() self.machine.events.post('event3') # fades are 500ms, so advance 250 and check self.advance_time_and_run(.26) - self.assertLightColor("led1", [0, 127, 0]) - self.assertLightColor("led2", [0, 127, 0]) - self.assertLightColor("led3", [0, 127, 0]) + self.assertLightColor("led1", [0, 132, 0]) + self.assertLightColor("led2", [0, 132, 0]) + self.assertLightColor("led3", [0, 132, 0]) # finish the fade self.advance_time_and_run() diff --git a/mpf/tests/test_P3_Roc.py b/mpf/tests/test_P3_Roc.py index 590d8210b..2e4f24054 100644 --- a/mpf/tests/test_P3_Roc.py +++ b/mpf/tests/test_P3_Roc.py @@ -706,11 +706,18 @@ def test_pdb_matrix_light(self): ) # test enable of matrix light - assert not self.machine.lights.test_pdb_light.hw_drivers["white"].proc.driver_schedule.called + assert not self.machine.lights.test_pdb_light.hw_drivers["white"].proc.driver_patter.called self.machine.lights.test_pdb_light.on() self.advance_time_and_run(.02) - self.machine.lights.test_pdb_light.hw_drivers["white"].proc.driver_schedule.assert_called_with( - cycle_seconds=0, schedule=4294967295, now=True, number=32 + self.machine.lights.test_pdb_light.hw_drivers["white"].proc.driver_patter.assert_called_with( + 32, 8, 0, 0, True + ) + + self.machine.lights.test_pdb_light.hw_drivers["white"].proc.driver_patter = MagicMock() + self.machine.lights.test_pdb_light.on(brightness=128) + self.advance_time_and_run(.02) + self.machine.lights.test_pdb_light.hw_drivers["white"].proc.driver_patter.assert_called_with( + 32, 1, 1, 0, True ) # test disable of matrix light diff --git a/mpf/tests/test_P_Roc.py b/mpf/tests/test_P_Roc.py index 316420a68..102c03862 100644 --- a/mpf/tests/test_P_Roc.py +++ b/mpf/tests/test_P_Roc.py @@ -217,11 +217,11 @@ def test_pdb_matrix_light(self): ) # test enable of matrix light - assert not self.machine.lights.test_pdb_light.hw_drivers["white"].proc.driver_schedule.called + assert not self.machine.lights.test_pdb_light.hw_drivers["white"].proc.driver_patter.called self.machine.lights.test_pdb_light.on() self.advance_time_and_run(.02) - self.machine.lights.test_pdb_light.hw_drivers["white"].proc.driver_schedule.assert_called_with( - cycle_seconds=0, schedule=4294967295, now=True, number=32 + self.machine.lights.test_pdb_light.hw_drivers["white"].proc.driver_patter.assert_called_with( + 32, 8, 0, 0, True ) # test disable of matrix light diff --git a/mpf/tests/test_Shots.py b/mpf/tests/test_Shots.py index 58123358d..83cb88827 100644 --- a/mpf/tests/test_Shots.py +++ b/mpf/tests/test_Shots.py @@ -686,13 +686,14 @@ def test_show_when_disabled(self): # disable the shot shot19.disable() + self.advance_time_and_run(.1) # color should not change - self.assertLightColor("led_19", 'antiquewhite') + self.assertLightColor("led_19", 'aliceblue') # and show should still be running - self.advance_time_and_run() - self.assertLightColor("led_19", 'aliceblue') + self.advance_time_and_run(1) + self.assertLightColor("led_19", 'antiquewhite') def test_no_show_when_disabled(self): shot20 = self.machine.shots.shot_20