class EnableCoilEjector(DefaultBallSearch, BallDeviceEjector): """Enable a coil to eject one ball.""" __slots__ = ["delay"] def __init__(self, config, ball_device, machine): """Initialise ejector.""" for option in ["eject_coil", "eject_coil_enable_time"]: if option not in config and option in ball_device.config: config[option] = ball_device.config[option] super().__init__(config, ball_device, machine) self.delay = DelayManager(self.ball_device.machine) self.config = self.machine.config_validator.validate_config( "ball_device_ejector_enable", self.config) async def eject_one_ball(self, is_jammed, eject_try, balls_in_device): """Enable eject coil.""" del is_jammed del eject_try # If multiple eject_coil_enable_time values, they correspond to the # of balls if self.ball_device.balls <= len( self.config['eject_coil_enable_time']): eject_time = self.config['eject_coil_enable_time'][balls_in_device - 1] else: eject_time = self.config['eject_coil_enable_time'][-1] # default pulse self.ball_device.debug_log( "Enabling eject coil for %sms, Current balls: %s.", eject_time, self.ball_device.balls) self.config['eject_coil'].enable( max_wait_ms=self.config['eject_coil_max_wait_ms']) self.delay.reset(name="disable", callback=self._disable_coil, ms=eject_time) async def reorder_balls(self): """Reordering balls is not supported.""" # TODO: implement self.ball_device.log.warning( "Reordering balls is not implemented in enable ejector") def _disable_coil(self): """Disable the coil.""" self.config['eject_coil'].disable() def _fire_coil_for_search(self, full_power): if not full_power: self.config['eject_coil'].pulse() else: self.config['eject_coil'].enable() self.delay.reset(name="disable", callback=self._disable_coil, ms=self.config['eject_coil_enable_time'][0]) return True
class EnableCoilEjector(BallDeviceEjector): """Enable a coil to eject one ball.""" def __init__(self, ball_device): """Initialise ejector.""" super().__init__(ball_device) self.delay = DelayManager(self.ball_device.machine.delayRegistry) def eject_one_ball(self, is_jammed, eject_try): """Enable eject coil.""" del is_jammed # default pulse self.ball_device.debug_log( "Enabling eject coil for %sms, Current balls: %s.", self.ball_device.config['eject_coil_enable_time'], self.ball_device.balls) self.ball_device.config['eject_coil'].enable() self.delay.reset(name="disable", callback=self._disable_coil, ms=self.ball_device.config['eject_coil_enable_time']) def _disable_coil(self): """Disable the coil.""" self.ball_device.config['eject_coil'].disable() def eject_all_balls(self): """Cannot eject all balls.""" raise NotImplementedError() def ball_search(self, phase, iteration): """Run ball search.""" del iteration if phase == 1: # round 1: only idle + no ball # only run ball search when the device is idle and contains no balls if self.ball_device.state == "idle" and self.ball_device.balls == 0: return self._fire_coil_for_search(True) elif phase == 2: # round 2: all devices except trough. only pulse if 'trough' not in self.ball_device.config['tags']: return self._fire_coil_for_search(False) else: # round 3: all devices except trough. release balls if 'trough' not in self.ball_device.config['tags']: return self._fire_coil_for_search(True) # no action by default return False def _fire_coil_for_search(self, only_pulse): if only_pulse and self.ball_device.config['eject_coil_jam_pulse']: self.ball_device.config['eject_coil'].pulse() else: self.ball_device.config['eject_coil'].enable() self.delay.reset( name="disable", callback=self._disable_coil, ms=self.ball_device.config['eject_coil_enable_time']) return True
class EnableCoilEjector(PulseCoilEjector): """Enable a coil to eject one ball.""" __slots__ = ["delay"] def __init__(self, config, ball_device, machine): """Initialise ejector.""" super().__init__(config, ball_device, machine) self.delay = DelayManager(self.ball_device.machine.delayRegistry) def _validate_config(self): # overwrite validation from pulse_coil_ejector pass @asyncio.coroutine def eject_one_ball(self, is_jammed, eject_try): """Enable eject coil.""" del is_jammed del eject_try # If multiple eject_coil_enable_time values, they correspond to the # of balls if self.ball_device.balls <= len( self.ball_device.config['eject_coil_enable_time']): eject_time = self.ball_device.config['eject_coil_enable_time'][ self.ball_device.balls - 1] else: eject_time = self.ball_device.config['eject_coil_enable_time'][-1] # default pulse self.ball_device.debug_log( "Enabling eject coil for %sms, Current balls: %s.", eject_time, self.ball_device.balls) self.ball_device.config['eject_coil'].enable() self.delay.reset(name="disable", callback=self._disable_coil, ms=eject_time) def _disable_coil(self): """Disable the coil.""" self.ball_device.config['eject_coil'].disable() def _fire_coil_for_search(self, full_power): if full_power and self.ball_device.config['eject_coil_jam_pulse']: self.ball_device.config['eject_coil'].pulse() else: self.ball_device.config['eject_coil'].enable() self.delay.reset( name="disable", callback=self._disable_coil, ms=self.ball_device.config['eject_coil_enable_time'][0]) return True
class Driver(SystemWideDevice): """Generic class that holds driver objects. A 'driver' is any device controlled from a driver board which is typically the high-voltage stuff like coils and flashers. This class exposes the methods you should use on these driver types of devices. Each platform module (i.e. P-ROC, FAST, etc.) subclasses this class to actually communicate with the physical hardware and perform the actions. Args: Same as the Device parent class """ config_section = 'coils' collection = 'coils' class_label = 'coil' __slots__ = ["hw_driver", "delay", "platform", "__dict__"] def __init__(self, machine: MachineController, name: str) -> None: """Initialise driver.""" self.hw_driver = None # type: DriverPlatformInterface super().__init__(machine, name) self.delay = DelayManager(self.machine) self.platform = None # type: DriverPlatform @classmethod def device_class_init(cls, machine: MachineController): """Register handler for duplicate coil number checks.""" machine.events.add_handler("init_phase_4", cls._check_duplicate_coil_numbers, machine=machine) @staticmethod def _check_duplicate_coil_numbers(machine, **kwargs): del kwargs check_set = set() for coil in machine.coils: if not hasattr(coil, "hw_driver"): # skip dual wound and other special devices continue key = (coil.platform, coil.hw_driver.number) if key in check_set: raise AssertionError( "Duplicate coil number {} for coil {}".format( coil.hw_driver.number, coil)) check_set.add(key) def validate_and_parse_config(self, config: dict, is_mode_config: bool, debug_prefix: str = None) -> dict: """Return the parsed and validated config. Args: config: Config of device is_mode_config: Whether this device is loaded in a mode or system-wide Returns: Validated config """ config = super().validate_and_parse_config(config, is_mode_config, debug_prefix) platform = self.machine.get_platform_sections( 'coils', getattr(config, "platform", None)) config['platform_settings'] = platform.validate_coil_section( self, config.get('platform_settings', None)) self._configure_device_logging(config) return config @asyncio.coroutine def _initialize(self): yield from super()._initialize() self.platform = self.machine.get_platform_sections( 'coils', self.config['platform']) config = DriverConfig( default_pulse_ms=self.get_and_verify_pulse_ms(None), default_pulse_power=self.get_and_verify_pulse_power(None), default_hold_power=self.get_and_verify_hold_power(None), default_recycle=self.config['default_recycle'], max_pulse_ms=self.config['max_pulse_ms'], max_pulse_power=self.config['max_pulse_power'], max_hold_power=self.config['max_hold_power']) platform_settings = dict( self.config['platform_settings'] ) if self.config['platform_settings'] else dict() try: self.hw_driver = self.platform.configure_driver( config, self.config['number'], platform_settings) except AssertionError as e: raise AssertionError( "Failed to configure driver {} in platform. See error above". format(self.name)) from e def get_and_verify_pulse_power(self, pulse_power: Optional[float]) -> float: """Return the pulse power to use. If pulse_power is None it will use the default_pulse_power. Additionally it will verify the limits. """ if pulse_power is None: pulse_power = self.config['default_pulse_power'] if self.config[ 'default_pulse_power'] is not None else 1.0 if pulse_power and 0 > pulse_power > 1: raise AssertionError( "Pulse power has to be between 0 and 1 but is {}".format( pulse_power)) max_pulse_power = 0 if self.config['max_pulse_power']: max_pulse_power = self.config['max_pulse_power'] elif self.config['default_pulse_power']: max_pulse_power = self.config['default_pulse_power'] if pulse_power > max_pulse_power: raise DriverLimitsError( "Driver may {} not be pulsed with pulse_power {} because max_pulse_power is {}" .format(self.name, pulse_power, max_pulse_power)) return pulse_power def get_and_verify_hold_power(self, hold_power: Optional[float]) -> float: """Return the hold power to use. If hold_power is None it will use the default_hold_power. Additionally it will verify the limits. """ if hold_power is None and self.config['default_hold_power']: hold_power = self.config['default_hold_power'] if hold_power is None and self.config['max_hold_power']: hold_power = self.config['max_hold_power'] if hold_power is None and self.config['allow_enable']: hold_power = 1.0 if hold_power is None: hold_power = 0.0 if hold_power and 0 > hold_power > 1: raise AssertionError( "Hold_power has to be between 0 and 1 but is {}".format( hold_power)) max_hold_power = 0 # type: float if self.config['max_hold_power']: max_hold_power = self.config['max_hold_power'] elif self.config['allow_enable']: max_hold_power = 1.0 elif self.config['default_hold_power']: max_hold_power = self.config['default_hold_power'] if hold_power > max_hold_power: raise DriverLimitsError( "Driver {} may not be enabled with hold_power {} because max_hold_power is {}" .format(self.name, hold_power, max_hold_power)) return hold_power def get_and_verify_pulse_ms(self, pulse_ms: Optional[int]) -> int: """Return and verify pulse_ms to use. If pulse_ms is None return the default. """ if pulse_ms is None: if self.config['default_pulse_ms'] is not None: pulse_ms = self.config['default_pulse_ms'] else: pulse_ms = self.machine.config['mpf']['default_pulse_ms'] if not isinstance(pulse_ms, int): raise AssertionError("Wrong type {}".format(pulse_ms)) if 0 > pulse_ms > self.platform.features['max_pulse']: raise AssertionError("Pulse_ms {} is not valid.".format(pulse_ms)) if self.config[ 'max_pulse_ms'] and pulse_ms > self.config['max_pulse_ms']: raise DriverLimitsError( "Driver {} may not be pulsed with pulse_ms {} because max_pulse_ms is {}" .format(self.name, pulse_ms, self.config['max_pulse_ms'])) return pulse_ms @event_handler(2) def event_enable(self, pulse_ms: int = None, pulse_power: float = None, hold_power: float = None, **kwargs): """Event handler for control enable.""" del kwargs self.enable(pulse_ms, pulse_power, hold_power) def enable(self, pulse_ms: int = None, pulse_power: float = None, hold_power: float = None): """Enable a driver by holding it 'on'. Args: pulse_ms: The number of milliseconds the driver should be enabled for. If no value is provided, the driver will be enabled for the value specified in the config dictionary. pulse_power: The pulse power. A float between 0.0 and 1.0. hold_power: The pulse power. A float between 0.0 and 1.0. If this driver is configured with a holdpatter, then this method will use that holdpatter to pwm pulse the driver. If not, then this method will just enable the driver. As a safety precaution, if you want to enable() this driver without pwm, then you have to add the following option to this driver in your machine configuration files: allow_enable: True """ pulse_ms = self.get_and_verify_pulse_ms(pulse_ms) pulse_power = self.get_and_verify_pulse_power(pulse_power) hold_power = self.get_and_verify_hold_power(hold_power) if hold_power == 0.0: raise DriverLimitsError("Cannot enable driver with hold_power 0.0") self.info_log( "Enabling Driver with power %s (pulse_ms %sms and pulse_power %s)", hold_power, pulse_ms, pulse_power) self.hw_driver.enable( PulseSettings(power=pulse_power, duration=pulse_ms), HoldSettings(power=hold_power)) # inform bcp clients self.machine.bcp.interface.send_driver_event( action="enable", name=self.name, number=self.config['number'], pulse_ms=pulse_ms, pulse_power=pulse_power, hold_power=hold_power) @event_handler(1) def event_disable(self, **kwargs): """Event handler for disable control event.""" del kwargs self.disable() def disable(self): """Disable this driver.""" self.info_log("Disabling Driver") self.machine.delay.remove(name='{}_timed_enable'.format(self.name)) self.hw_driver.disable() # inform bcp clients self.machine.bcp.interface.send_driver_event( action="disable", name=self.name, number=self.config['number']) def _get_wait_ms(self, pulse_ms: int, max_wait_ms: Optional[int]) -> int: """Determine if this pulse should be delayed.""" if max_wait_ms is None: self.config['psu'].notify_about_instant_pulse(pulse_ms=pulse_ms) return 0 else: return self.config['psu'].get_wait_time_for_pulse( pulse_ms=pulse_ms, max_wait_ms=max_wait_ms) def _pulse_now(self, pulse_ms: int, pulse_power: float) -> None: """Pulse this driver now.""" if 0 < pulse_ms <= self.platform.features['max_pulse']: self.info_log("Pulsing Driver for %sms (%s pulse_power)", pulse_ms, pulse_power) self.hw_driver.pulse( PulseSettings(power=pulse_power, duration=pulse_ms)) else: self.info_log("Enabling Driver for %sms (%s pulse_power)", pulse_ms, pulse_power) self.delay.reset(name='timed_disable', ms=pulse_ms, callback=self.disable) self.hw_driver.enable(PulseSettings(power=pulse_power, duration=0), HoldSettings(power=pulse_power)) # inform bcp clients self.machine.bcp.interface.send_driver_event( action="pulse", name=self.name, number=self.config['number'], pulse_ms=pulse_ms, pulse_power=pulse_power) @event_handler(3) def event_pulse(self, pulse_ms: int = None, pulse_power: float = None, max_wait_ms: int = None, **kwargs) -> None: """Event handler for pulse control events.""" del kwargs self.pulse(pulse_ms, pulse_power, max_wait_ms) def pulse(self, pulse_ms: int = None, pulse_power: float = None, max_wait_ms: int = None) -> int: """Pulse this driver. Args: pulse_ms: The number of milliseconds the driver should be enabled for. If no value is provided, the driver will be enabled for the value specified in the config dictionary. pulse_power: The pulse power. A float between 0.0 and 1.0. """ pulse_ms = self.get_and_verify_pulse_ms(pulse_ms) pulse_power = self.get_and_verify_pulse_power(pulse_power) wait_ms = self._get_wait_ms(pulse_ms, max_wait_ms) if wait_ms > 0: self.debug_log( "Delaying pulse by %sms pulse_ms: %sms (%s pulse_power)", wait_ms, pulse_ms, pulse_power) self.delay.add(wait_ms, self._pulse_now, pulse_ms=pulse_ms, pulse_power=pulse_power) else: self._pulse_now(pulse_ms, pulse_power) return wait_ms
class AutofireCoil(SystemWideDevice): """Autofire coils which fire based on switch hits with a hardware rule. Coils in the pinball machine which should fire automatically based on switch hits using defined hardware switch rules. Autofire coils work with rules written to the hardware pinball controller that allow them to respond "instantly" to switch hits versus waiting for the lag of USB and the host computer. Examples of Autofire Coils are pop bumpers, slingshots, and kicking targets. (Flippers use the same autofire rules under the hood, but flipper devices have their own device type in MPF. """ config_section = 'autofire_coils' collection = 'autofires' class_label = 'autofire' def __init__(self, machine: "MachineController", name: str) -> None: """Initialise autofire.""" self._enabled = False self._rule = None # type: HardwareRule super().__init__(machine, name) self.delay = DelayManager(self.machine.delayRegistry) self._ball_search_in_progress = False def _initialize(self) -> None: if self.config['ball_search_order']: self.config['playfield'].ball_search.register( self.config['ball_search_order'], self._ball_search, self.name) # pulse is handled via rule but add a handler so that we take notice anyway self.config['switch'].add_handler(self._hit) @event_handler(10) def enable(self, **kwargs): """Enable the autofire device. This causes the coil to respond to the switch hits. This is typically called when a ball starts to enable the slingshots, pops, etc. Note that there are several options for both the coil and the switch which can be incorporated into this rule, including recycle times, switch debounce, reversing the switch (fire the coil when the switch goes inactive), etc. These rules vary by hardware platform. See the user documentation for the hardware platform for details. Args: **kwargs: Not used, just included so this method can be used as an event callback. """ del kwargs if self._enabled: return self._enabled = True self.debug_log("Enabling") recycle = True if self.config['coil_overwrite'].get( 'recycle', None) in (True, None) else False debounce = False if self.config['switch_overwrite'].get( 'debounce', None) in (None, "quick") else True self._rule = self.machine.platform_controller.set_pulse_on_hit_rule( SwitchRuleSettings(switch=self.config['switch'], debounce=debounce, invert=self.config['reverse_switch']), DriverRuleSettings(driver=self.config['coil'], recycle=recycle), PulseRuleSettings( duration=self.config['coil_overwrite'].get('pulse_ms', None), power=self.config['coil_overwrite'].get('pulse_power', None))) @event_handler(1) def disable(self, **kwargs): """Disable the autofire device. This is typically called at the end of a ball and when a tilt event happens. Args: **kwargs: Not used, just included so this method can be used as an event callback. """ del kwargs if not self._enabled: return self._enabled = False self.debug_log("Disabling") self.machine.platform_controller.clear_hw_rule(self._rule) def _hit(self): """Rule was triggered.""" if not self._ball_search_in_progress: self.config['playfield'].mark_playfield_active_from_device_action() def _ball_search(self, phase, iteration): del phase del iteration self.delay.reset(ms=200, callback=self._ball_search_ignore_done, name="ball_search_ignore_done") self._ball_search_in_progress = True self.config['coil'].pulse() return True def _ball_search_ignore_done(self): """We no longer expect any fake hits.""" self._ball_search_in_progress = False
class AutofireCoil(SystemWideDevice): """Autofire coils which fire based on switch hits with a hardware rule. Coils in the pinball machine which should fire automatically based on switch hits using defined hardware switch rules. Autofire coils work with rules written to the hardware pinball controller that allow them to respond "instantly" to switch hits versus waiting for the lag of USB and the host computer. Examples of Autofire Coils are pop bumpers, slingshots, and kicking targets. (Flippers use the same autofire rules under the hood, but flipper devices have their own device type in MPF. """ config_section = 'autofire_coils' collection = 'autofires' class_label = 'autofire' __slots__ = [ "_enabled", "_rule", "delay", "_ball_search_in_progress", "_timeout_watch_time", "_timeout_max_hits", "_timeout_disable_time", "_timeout_hits" ] def __init__(self, machine: "MachineController", name: str) -> None: """Initialise autofire.""" self._enabled = False self._rule = None # type: HardwareRule super().__init__(machine, name) self.delay = DelayManager(self.machine) self._ball_search_in_progress = False self._timeout_watch_time = None self._timeout_max_hits = None self._timeout_disable_time = None self._timeout_hits = [] # type: List[float] async def _initialize(self): await super()._initialize() if self.config['ball_search_order']: self.config['playfield'].ball_search.register( self.config['ball_search_order'], self._ball_search, self.name) # pulse is handled via rule but add a handler so that we take notice anyway self.config['switch'].add_handler(self._hit) if self.config['timeout_watch_time']: self._timeout_watch_time = self.config['timeout_watch_time'] / 1000 self._timeout_max_hits = self.config['timeout_max_hits'] self._timeout_disable_time = self.config['timeout_disable_time'] if '{}_active'.format( self.config['playfield'].name) in self.config['switch'].tags: self.raise_config_error( "Autofire device '{}' uses switch '{}' which has a " "'{}_active' tag. This is handled internally by the device. Remove the " "redundant '{}_active' tag from that switch.".format( self.name, self.config['switch'].name, self.config['playfield'].name, self.config['playfield'].name), 1) @event_handler(1) def event_enable(self, **kwargs): """Handle enable control event. To prevent multiple rules at the same time we prioritize disable > enable. """ del kwargs self.enable() def enable(self): """Enable the autofire device. This causes the coil to respond to the switch hits. This is typically called when a ball starts to enable the slingshots, pops, etc. Note that there are several options for both the coil and the switch which can be incorporated into this rule, including recycle times, switch debounce, reversing the switch (fire the coil when the switch goes inactive), etc. These rules vary by hardware platform. See the user documentation for the hardware platform for details. Args: **kwargs: Not used, just included so this method can be used as an event callback. """ if self._enabled: return self._enabled = True self.debug_log("Enabling") recycle = self.config['coil_overwrite'].get('recycle', None) in (True, None) debounce = self.config['switch_overwrite'].get('debounce', None) not in (None, "quick") self._rule = self.machine.platform_controller.set_pulse_on_hit_rule( SwitchRuleSettings(switch=self.config['switch'], debounce=debounce, invert=self.config['reverse_switch']), DriverRuleSettings(driver=self.config['coil'], recycle=recycle), PulseRuleSettings( duration=self.config['coil_overwrite'].get('pulse_ms', None), power=self.config['coil_overwrite'].get('pulse_power', None))) @event_handler(10) def event_disable(self, **kwargs): """Handle disable control event. To prevent multiple rules at the same time we prioritize disable > enable. """ del kwargs self.disable() def disable(self): """Disable the autofire device. This is typically called at the end of a ball and when a tilt event happens. Args: **kwargs: Not used, just included so this method can be used as an event callback. """ self.delay.remove("_timeout_enable_delay") if not self._enabled: return self._enabled = False self.debug_log("Disabling") self.machine.platform_controller.clear_hw_rule(self._rule) def _hit(self): """Rule was triggered.""" if not self._enabled: return if not self._ball_search_in_progress: self.config['playfield'].mark_playfield_active_from_device_action() if self._timeout_watch_time: current_time = self.machine.clock.get_time() self._timeout_hits = [ t for t in self._timeout_hits if t > current_time - self._timeout_watch_time / 1000.0 ] self._timeout_hits.append(current_time) if len(self._timeout_hits) >= self._timeout_max_hits: self.disable() self.delay.add(self._timeout_disable_time, self.enable, "_timeout_enable_delay") def _ball_search(self, phase, iteration): del phase del iteration self.delay.reset(ms=200, callback=self._ball_search_ignore_done, name="ball_search_ignore_done") self._ball_search_in_progress = True self.config['coil'].pulse() return True def _ball_search_ignore_done(self): """We no longer expect any fake hits.""" self._ball_search_in_progress = False
class DigitalOutput(SystemWideDevice): """A digital output on either a light or driver platform.""" config_section = 'digital_outputs' collection = 'digital_outputs' class_label = 'digital_output' __slots__ = ["hw_driver", "platform", "type", "__dict__"] def __init__(self, machine: MachineController, name: str) -> None: """Initialise digital output.""" self.hw_driver = None # type: Union[DriverPlatformInterface, LightPlatformInterface] self.platform = None # type: Union[DriverPlatform, LightsPlatform] self.type = None # type: str super().__init__(machine, name) self.delay = DelayManager(self.machine.delayRegistry) @asyncio.coroutine def _initialize(self): """Initialise the hardware driver for this digital output.""" yield from super()._initialize() if self.config['type'] == "driver": self._initialize_driver() elif self.config['type'] == "light": self._initialize_light() else: raise AssertionError("Invalid type {}".format(self.config['type'])) def _initialize_light(self): """Configure a light as digital output.""" self.platform = self.machine.get_platform_sections( 'lights', self.config['platform']) self.type = "light" try: self.hw_driver = self.platform.configure_light( self.config['number'], self.config['light_subtype'], {}) except AssertionError as e: raise AssertionError( "Failed to configure light {} in platform. See error above". format(self.name)) from e def _initialize_driver(self): """Configure a driver as digital output.""" self.platform = self.machine.get_platform_sections( 'coils', self.config['platform']) self.type = "driver" config = DriverConfig(default_pulse_ms=255, default_pulse_power=1.0, default_hold_power=1.0, default_recycle=False, max_pulse_ms=255, max_pulse_power=1.0, max_hold_power=1.0) try: self.hw_driver = self.platform.configure_driver( config, self.config['number'], {}) except AssertionError as e: raise AssertionError( "Failed to configure driver {} in platform. See error above". format(self.name)) from e @staticmethod def _get_state(max_fade_ms: int, state: bool) -> Tuple[float, int, bool]: """Return the current state without any fade.""" del max_fade_ms if state: return 1.0, -1, True else: return 0.0, -1, True def pulse(self, pulse_ms, **kwargs): """Pulse digital output.""" del kwargs if self.type == "driver": self.hw_driver.pulse(PulseSettings(power=1.0, duration=pulse_ms)) elif self.type == "light": self.hw_driver.set_fade(partial(self._get_state, state=True)) self.platform.light_sync() self.delay.reset(name='timed_disable', ms=pulse_ms, callback=self.disable) else: raise AssertionError("Invalid type {}".format(self.type)) def enable(self, **kwargs): """Enable digital output.""" del kwargs if self.type == "driver": self.hw_driver.enable(PulseSettings(power=1.0, duration=0), HoldSettings(power=1.0)) elif self.type == "light": self.hw_driver.set_fade(partial(self._get_state, state=True)) self.platform.light_sync() self.delay.remove(name='timed_disable') else: raise AssertionError("Invalid type {}".format(self.type)) def disable(self, **kwargs): """Disable digital output.""" del kwargs if self.type == "driver": self.hw_driver.disable() elif self.type == "light": self.hw_driver.set_fade(partial(self._get_state, state=False)) self.platform.light_sync() self.delay.remove(name='timed_disable') else: raise AssertionError("Invalid type {}".format(self.type))
class EntranceSwitchCounter(PhysicalBallCounter): """Count balls using an entrance switch.""" __slots__ = ["recycle_secs", "recycle_clear_time", "_settle_delay"] def __init__(self, ball_device, config): """Initialise entrance switch counter.""" for option in ["entrance_switch", "entrance_switch_ignore_window_ms", "entrance_switch_full_timeout", "ball_capacity"]: if option not in config and option in ball_device.config: config[option] = ball_device.config[option] super().__init__(ball_device, config) self._settle_delay = DelayManager(self.machine) self.config = self.machine.config_validator.validate_config("ball_device_counter_entrance_switches", self.config) self.recycle_secs = self.config['entrance_switch_ignore_window_ms'] / 1000.0 self.recycle_clear_time = {} for switch in self.config['entrance_switch']: # Configure switch handlers for entrance switch activity self.machine.switch_controller.add_switch_handler_obj( switch=switch, state=1, ms=0, callback=self._entrance_switch_handler, callback_kwargs={"switch_name": switch.name}) self.machine.switch_controller.add_switch_handler_obj( switch=switch, state=0, ms=0, callback=self._entrance_switch_released_handler, callback_kwargs={"switch_name": switch.name}) if self.config['entrance_switch_full_timeout'] and self.config['ball_capacity']: if len(self.config['entrance_switch']) > 1: raise AssertionError("entrance_switch_full_timeout not supported with multiple entrance switches.") self.machine.switch_controller.add_switch_handler_obj( switch=self.config['entrance_switch'][0], state=1, ms=self.config['entrance_switch_full_timeout'], callback=self._entrance_switch_full_handler) # Handle initial ball count with entrance_switch. If there is a ball on the entrance_switch at boot # assume that we are at max capacity. if (self.config['ball_capacity'] and self.config['entrance_switch_full_timeout'] and self.machine.switch_controller.is_active(self.config['entrance_switch'][0], ms=self.config['entrance_switch_full_timeout'])): self._last_count = self.config['ball_capacity'] else: self._last_count = 0 self._count_stable.set() # TODO validate that we are not used with mechanical eject if not self.config['ball_capacity']: self.ball_device.raise_config_error("Need ball capacity if there are no switches.", 2) elif self.ball_device.config.get('ball_switches'): self.ball_device.raise_config_error("Cannot use capacity and ball switches.", 3) @property def capacity(self): """Return capacity under normal circumstances (i.e. without jam switches).""" return self.config['ball_capacity'] def is_jammed(self) -> bool: """Return False because this device can not know if it is jammed.""" return False def is_count_unreliable(self) -> bool: """Return False because this device can not know if it is jammed.""" return False def received_entrance_event(self): """Handle entrance event.""" self._entrance_switch_handler("event") def _recycle_passed(self, switch): self.recycle_clear_time[switch] = None def _entrance_switch_handler(self, switch_name): """Add a ball to the device since the entrance switch has been hit.""" # always invalidate count if this has been triggered by a real switch if switch_name != "event": self.invalidate_count() # If recycle is ongoing, do nothing if self.recycle_clear_time.get(switch_name, False): self.debug_log("Entrance switch hit within ignore window, taking no action") return # If a recycle time is configured, set a timeout to prevent future entrance activity if self.recycle_secs: self.recycle_clear_time[switch_name] = self.machine.clock.get_time() + self.recycle_secs self.machine.clock.loop.call_at(self.recycle_clear_time[switch_name], self._recycle_passed, switch_name) self.debug_log("Entrance switch hit") if self.config['ball_capacity'] and self.config['ball_capacity'] <= self._last_count: # do not count beyond capacity self.ball_device.log.warning("Device received balls but is already full!") elif self.config['ball_capacity'] and self.config['entrance_switch_full_timeout'] and \ self.config['ball_capacity'] == self._last_count + 1: # wait for entrance_switch_full_timeout before setting the device to full capacity self._settle_delay.remove("count_stable") else: # increase count self._settle_delay.reset(self.config['settle_time_ms'], self.mark_count_as_stable_and_trigger_activity, "count_stable") self._last_count += 1 self.record_activity(BallEntranceActivity()) def _entrance_switch_released_handler(self, switch_name): """Entrance switch has been released.""" del switch_name # count is stable once switch is inactive # (or if it stays on the switch long enough; see _entrance_switch_full_handler) self._count_stable.set() def _entrance_switch_full_handler(self): # a ball is sitting on the entrance_switch. assume the device is full self._count_stable.set() new_balls = self.config['ball_capacity'] - self._last_count self.mark_count_as_stable_and_trigger_activity() if new_balls > 0: self.debug_log("Ball is sitting on entrance_switch. Assuming " "device is full. Adding %s balls and setting balls" "to %s", new_balls, self.config['ball_capacity']) self._last_count += new_balls for _ in range(new_balls): self.record_activity(BallEntranceActivity()) def count_balls_sync(self) -> int: """Return the number of balls entered.""" assert self._last_count is not None if self.config['ball_capacity'] and self.config['ball_capacity'] == self._last_count: # we are at capacity. this is fine pass elif self.config['ball_capacity'] and self.config['entrance_switch_full_timeout'] and \ self.config['ball_capacity'] == self._last_count + 1 and \ self.machine.switch_controller.is_active(self.config['entrance_switch'][0], ms=self.config['entrance_switch_full_timeout']): # can count when entrance switch is active for at least entrance_switch_full_timeout pass elif not self.is_ready_to_receive: # cannot count when the entrance_switch is still active raise ValueError return self._last_count async def wait_for_ball_to_leave(self): """Wait for a ball to leave.""" await self.wait_for_count_stable() # wait 10ms done_future = asyncio.ensure_future(asyncio.sleep(0.01)) done_future.add_done_callback(self._ball_left) return done_future def _ball_left(self, future): del future self._last_count -= 1 self.record_activity(BallLostActivity()) self.trigger_activity() @property def is_ready_to_receive(self): """Return true if entrance switch is inactive.""" return not all(self.machine.switch_controller.is_active(switch) for switch in self.config['entrance_switch']) async def wait_for_ready_to_receive(self): """Wait until all entrance switch are inactive.""" while True: if self.is_ready_to_receive: return True await self.machine.switch_controller.wait_for_any_switch( switches=self.config['entrance_switch'], state=0, only_on_change=True)
class BallController(object): """Base class for the Ball Controller which is used to keep track of all the balls in a pinball machine. Parameters ---------- machine : :class:`MachineController` A reference to the instance of the MachineController object. """ def __init__(self, machine): """Initialise ball controller.""" self.machine = machine self.log = logging.getLogger("BallController") self.log.debug("Loading the BallController") self.delay = DelayManager(self.machine.delayRegistry) self.num_balls_known = -999 # register for events self.machine.events.add_handler('request_to_start_game', self.request_to_start_game) self.machine.events.add_handler('machine_reset_phase_2', self._initialize) self.machine.events.add_handler('init_phase_2', self._init2) def _init2(self, **kwargs): del kwargs # register a handler for all switches for device in self.machine.ball_devices: if 'ball_switches' not in device.config: continue for switch in device.config['ball_switches']: self.machine.switch_controller.add_switch_handler( switch.name, self._update_num_balls_known, ms=device.config['entrance_count_delay'], state=1) self.machine.switch_controller.add_switch_handler( switch.name, self._update_num_balls_known, ms=device.config['exit_count_delay'], state=0) self.machine.switch_controller.add_switch_handler( switch.name, self._correct_playfield_count, ms=device.config['entrance_count_delay'], state=1) self.machine.switch_controller.add_switch_handler( switch.name, self._correct_playfield_count, ms=device.config['exit_count_delay'], state=0) for playfield in self.machine.playfields: self.machine.events.add_handler( '{}_ball_count_change'.format(playfield.name), self._correct_playfield_count) # run initial count self._update_num_balls_known() def _get_loose_balls(self): return self.num_balls_known - self._count_stable_balls() def _count_stable_balls(self): self.log.debug("Counting Balls") balls = 0 for device in self.machine.ball_devices: if device.is_playfield(): continue if not device.is_ball_count_stable(): raise ValueError("devices not stable") # generally we do not count ball devices without switches if 'ball_switches' not in device.config: continue # special handling for troughs (needed for gottlieb) elif not device.config['ball_switches'] and 'trough' in device.tags: balls += device.balls else: for switch in device.config['ball_switches']: if self.machine.switch_controller.is_active( switch.name, ms=device.config['entrance_count_delay']): balls += 1 elif self.machine.switch_controller.is_inactive( switch.name, ms=device.config['exit_count_delay']): continue else: raise ValueError("switches not stable") return balls def _correct_playfield_count(self, **kwargs): del kwargs self.delay.reset(ms=1, callback=self._correct_playfield_count2, name="correct_playfield") def _correct_playfield_count2(self): try: loose_balls = self._get_loose_balls() except ValueError: self.delay.reset(ms=10000, callback=self._correct_playfield_count2, name="correct_playfield") return balls_on_pfs = 0 for playfield in self.machine.playfields: balls_on_pfs += playfield.balls jump_sources = [] jump_targets = [] # fix too much balls and prefer playfields where balls and available_balls have the same value if balls_on_pfs > loose_balls: balls_on_pfs -= self._fix_jumped_balls(balls_on_pfs - loose_balls, jump_sources) # fix too much balls and take the remaining playfields if balls_on_pfs > loose_balls: balls_on_pfs -= self._remove_balls_from_playfield_randomly( balls_on_pfs - loose_balls, jump_sources) if balls_on_pfs > loose_balls: self.log.warning( "Failed to remove enough balls from playfields. This is a bug!" ) for playfield in self.machine.playfields: if playfield.balls != playfield.available_balls: self.log.warning( "Correcting available_balls %s to %s on " "playfield %s", playfield.available_balls, playfield.balls, playfield.name) if playfield.balls > playfield.available_balls: jump_targets.append(playfield) playfield.available_balls = playfield.balls for _ in range(min(len(jump_sources), len(jump_targets))): source = jump_sources.pop() target = jump_targets.pop() self.log.warning("Suspecting that ball jumped from %s to %s", str(source), str(target)) self.machine.events.post("playfield_jump", source=source, target=target) def _fix_jumped_balls(self, balls_to_remove, jump_sources): balls_removed = 0 for dummy_i in range(balls_to_remove): for playfield in self.machine.playfields: self.log.warning( "Correcting balls on pf from %s to %s on " "playfield %s (preferred)", playfield.balls, playfield.balls - 1, playfield.name) if playfield.available_balls == playfield.balls and playfield.balls > 0: jump_sources.append(playfield) if playfield.unexpected_balls > 0: playfield.unexpected_balls -= 1 playfield.balls -= 1 playfield.available_balls -= 1 balls_removed += 1 break return balls_removed def _remove_balls_from_playfield_randomly(self, balls_to_remove, jump_sources): balls_removed = 0 for dummy_i in range(balls_to_remove): for playfield in self.machine.playfields: self.log.warning( "Correcting balls on pf from %s to %s on " "playfield %s", playfield.balls, playfield.balls - 1, playfield.name) if playfield.balls > 0: jump_sources.append(playfield) if playfield.unexpected_balls > 0: playfield.unexpected_balls -= 1 playfield.balls -= 1 playfield.available_balls -= 1 balls_removed += 1 break return balls_removed def trigger_ball_count(self): """Count the balls now if possible.""" self._update_num_balls_known() self._correct_playfield_count() def _update_num_balls_known(self): try: balls = self._count_balls() except ValueError: self.delay.reset(ms=100, callback=self._update_num_balls_known, name="update_num_balls_known") return if self.num_balls_known < 0: self.num_balls_known = 0 if balls > self.num_balls_known: self.log.debug("Found new balls. Setting known balls to %s", balls) self.delay.add(1, self._handle_new_balls, new_balls=balls - self.num_balls_known) self.num_balls_known = balls def _handle_new_balls(self, new_balls): for dummy_i in range(new_balls): for playfield in self.machine.playfields: if playfield.unexpected_balls > 0: playfield.unexpected_balls -= 1 playfield.available_balls += 1 break def _count_balls(self): self.log.debug("Counting Balls") balls = 0 for device in self.machine.ball_devices: # generally we do not count ball devices without switches if 'ball_switches' not in device.config: continue # special handling for troughs (needed for gottlieb) elif not device.config['ball_switches'] and 'trough' in device.tags: balls += device.balls else: for switch in device.config['ball_switches']: if self.machine.switch_controller.is_active( switch.name, ms=device.config['entrance_count_delay']): balls += 1 elif self.machine.switch_controller.is_inactive( switch.name, ms=device.config['exit_count_delay']): continue else: raise ValueError("switches not stable") return balls def _initialize(self, **kwargs): # If there are no ball devices, then the ball controller has no work to # do and will create errors, so we just abort. del kwargs if not hasattr(self.machine, 'ball_devices'): return for device in self.machine.ball_devices: if 'drain' in device.tags: # device is used to drain balls from pf self.machine.events.add_handler( 'balldevice_' + device.name + '_ball_enter', self._ball_drained_handler) def dump_ball_counts(self): """Dump ball count of all devices.""" for device in self.machine.ball_devices: self.log.info("%s contains %s balls. Tags %s", device.name, device.balls, device.tags) def request_to_start_game(self, **kwargs): """Method registered for the *request_to_start_game* event. Checks to make sure that the balls are in all the right places and returns. If too many balls are missing (based on the config files 'Min Balls' setting), it will return False to reject the game start request. """ del kwargs try: balls = self._count_balls() except ValueError: balls = -1 self.log.debug("Received request to start game.") if balls < self.machine.config['machine']['min_balls']: self.dump_ball_counts() self.log.warning( "BallController denies game start. Not enough " "balls. %s found. %s required", balls, self.machine.config['machine']['min_balls']) return False if self.machine.config['game']['allow_start_with_ball_in_drain']: allowed_positions = ['home', 'trough', 'drain'] else: allowed_positions = ['home', 'trough'] if self.machine.config['game']['allow_start_with_loose_balls']: return elif not self.are_balls_collected(allowed_positions): self.collect_balls('home') self.dump_ball_counts() self.log.warning("BallController denies game start. Balls are not " "in their home positions.") return False def are_balls_collected(self, target): """Check to see if all the balls are contained in devices tagged with the parameter that was passed. Note if you pass a target that's not used in any ball devices, this method will return True. (Because you're asking if all balls are nowhere, and they always are. :) Args: target: String or list of strings of the tags you'd like to collect the balls to. Default of None will be replaced with 'home' and 'trough'. """ self.log.debug( "Checking to see if all the balls are in devices tagged" " with '%s'", target) if isinstance(target, str): target = Util.string_to_list(target) count = 0 devices = set() for tag in target: for device in self.machine.ball_devices.items_tagged(tag): devices.add(device) if len(devices) == 0: # didn't find any devices matching that tag, so we return True return True for device in devices: count += device.get_status('balls') self.log.debug('Found %s ball(s) in %s. Found %s total', device.get_status('balls'), device.name, count) if count == self.machine.ball_controller.num_balls_known: self.log.debug("Yes, all balls are collected") return True else: self.log.debug( "No, all balls are not collected. Balls Counted: %s. " "Total balls known: %s", count, self.machine.ball_controller.num_balls_known) return False def collect_balls(self, target='home, trough'): """Used to ensure that all balls are in contained in ball devices with the tag or list of tags you pass. Typically this would be used after a game ends, or when the machine is reset or first starts up, to ensure that all balls are in devices tagged with 'home' and/or 'trough'. Args: target: A string of the tag name or a list of tags names of the ball devices you want all the balls to end up in. Default is ['home', 'trough']. """ tag_list = Util.string_to_list(target) self.log.debug("Collecting all balls to devices with tags '%s'", tag_list) target_devices = set() source_devices = set() balls_to_collect = False for tag in tag_list: for device in self.machine.ball_devices.items_tagged(tag): target_devices.add(device) for device in self.machine.ball_devices: if device not in target_devices: if device.available_balls: source_devices.add(device) balls_to_collect = True self.log.debug("Ejecting all balls from: %s", source_devices) if balls_to_collect: self.machine.events.post('collecting_balls') '''event: collecting_balls desc: Posted by the ball controller when it starts the collecting balls process. ''' for device in target_devices: self.machine.events.replace_handler( 'balldevice_{}_ball_enter'.format(device.name), self._collecting_balls_entered_callback, target=target) for device in source_devices: if not device.is_playfield(): if "drain" in device.tags: device.eject_all(device.find_next_trough()) else: device.eject_all() else: self.log.debug("All balls are collected") self._collecting_balls_complete() def _collecting_balls_entered_callback(self, target, new_balls, unclaimed_balls, **kwargs): del kwargs del new_balls if self.are_balls_collected(target=target): self._collecting_balls_complete() return {'unclaimed_balls': unclaimed_balls} def _collecting_balls_complete(self): self.machine.events.remove_handler(self._collecting_balls_complete) self.machine.events.post('collecting_balls_complete') '''event: collecting_balls_complete desc: Posted by the ball controller when it has finished the collecting balls process. ''' def _ball_drained_handler(self, new_balls, unclaimed_balls, device, **kwargs): del kwargs del new_balls self.machine.events.post_relay('ball_drain', callback=self._process_ball_drained, device=device, balls=unclaimed_balls) '''event: ball_drain desc: A ball (or balls) has just drained. (More specifically, ball(s) have entered a ball device tagged with "drain".) This is a relay event. args: device: The ball device object that received the ball(s) balls: The number of balls that have just drained. Any balls remaining after the relay will be processed as newly-drained balls. ''' # What happens if the ball enters the trough but the ball_add_live # event hasn't confirmed its eject? todo def _process_ball_drained(self, balls=None, ev_result=None, **kwargs): # We don't need to do anything here because other modules (ball save, # the game, etc. should jump in and do whatever they need to do when a # ball is drained. pass
class BallSearch(object): """Ball search controller.""" def __init__(self, machine, playfield): """Initialise ball search.""" self.machine = machine self.playfield = playfield self.log = logging.getLogger("BallSearch " + playfield.name) self.delay = DelayManager(self.machine.delayRegistry) self.started = False self.enabled = False self.callbacks = [] self.iteration = False self.iterator = False self.phase = False # register for events self.machine.events.add_handler('request_to_start_game', self.request_to_start_game) self.machine.events.add_handler('cancel_ball_search', self.cancel_ball_search) '''event: cancel_ball_search desc: This event will cancel all running ball searches and mark the balls as lost. This is only a handler so all you have to do is to post the event.''' def request_to_start_game(self, **kwargs): """Method registered for the *request_to_start_game* event. Returns false if the ball search is running. """ del kwargs if self.started: return False else: return def register(self, priority, callback): """Register a callback for sequential ballsearch. Callbacks are called by priority. Ball search only waits if the callback returns true. Args: priority: priority of this callback in the ball search procedure callback: callback to call. ball search will wait before the next callback, if it returns true """ self.callbacks.append((priority, callback)) # sort by priority self.callbacks = sorted(self.callbacks, key=lambda entry: entry[0]) def enable(self): """Enable but do not start ball search. Ball search is started by a timeout. Enable also resets that timer. """ if self.playfield.config['enable_ball_search'] is False or ( not self.playfield.config['enable_ball_search'] and not self.machine.config['mpf']['default_ball_search']): return if not self.callbacks: raise AssertionError("No callbacks registered") self.log.debug("Enabling Ball Search") self.enabled = True self.reset_timer() def disable(self): """Disable ball search. Will stop the ball search if it is running. """ if self.started: self.machine.events.post('ball_search_stopped') '''event: ball_search_stopped desc: The ball search process has been disabled. This event is posted any time ball search stops, regardless of whether it found a ball or gave up. (If the ball search failed to find the ball, it will also post the *ball_search_failed* event.) ''' self.started = False self.enabled = False self.delay.remove('start') self.delay.remove('run') def reset_timer(self): """Reset the start timer. Called by playfield. """ if self.enabled and not self.started: self.delay.reset(name='start', callback=self.start, ms=self.playfield.config['ball_search_timeout']) def start(self): """Actually start ball search.""" if not self.enabled or self.started or not self.callbacks: return self.started = True self.iteration = 1 self.phase = 1 self.iterator = iter(self.callbacks) self.log.debug("Starting ball search") self.machine.events.post('ball_search_started') '''event: ball_search_started desc: The ball search process has been begun. ''' self.run() def run(self): """Run one iteration of the ball search. Will schedule itself for the next run. """ timeout = self.playfield.config['ball_search_interval'] # iterate until we are done with all callbacks while True: try: element = next(self.iterator) except StopIteration: self.iteration += 1 # give up at some point if self.iteration > self.playfield.config[ 'ball_search_phase_' + str(self.phase) + '_searches']: self.phase += 1 self.iteration = 1 if self.phase > 3: self.give_up() return self.log.debug("Ball Search Phase %s Iteratio %s", self.phase, self.iteration) self.iterator = iter(self.callbacks) element = next(self.iterator) timeout = self.playfield.config[ 'ball_search_wait_after_iteration'] (dummy_priority, callback) = element # if a callback returns True we wait for the next one if callback(self.phase, self.iteration): self.delay.add(name='run', callback=self.run, ms=timeout) return def cancel_ball_search(self, **kwargs): """Cancel the current ballsearch and mark the ball as missing.""" del kwargs if self.started: self.give_up() def give_up(self): """Give up the ball search. Did not find the missing ball. Execute the failed action which either adds a replacement ball or ends the game. """ self.log.warning("Ball Search failed to find ball. Giving up!") self.disable() self.machine.events.post('ball_search_failed') '''event: ball_search_failed desc: The ball search process has failed to locate a missing or stuck ball and has given up. This event will be posted immediately after the *ball_search_stopped* event. ''' lost_balls = self.playfield.balls self.machine.ball_controller.num_balls_known -= lost_balls self.playfield.balls = 0 self.playfield.available_balls = 0 self._compensate_lost_balls(lost_balls) def _compensate_lost_balls(self, lost_balls): if not self.machine.game: return if self.playfield.config['ball_search_failed_action'] == "new_ball": if self.machine.ball_controller.num_balls_known > 0: # we have at least one ball remaining self.log.debug("Adding %s replacement ball", lost_balls) for dummy_iterator in range(lost_balls): self.playfield.add_ball() else: self.log.debug("No more balls left. Ending game!") self.machine.game.game_ending() elif self.playfield.config['ball_search_failed_action'] == "end_game": if self.machine.game: self.log.debug("Ending the game") self.machine.game.game_ending() else: self.log.warning("There is no game. Doing nothing!") else: raise AssertionError( "Unknown action " + self.playfield.config['ball_search_failed_action'])
class BallSearch(MpfController): """Implements Ball search for a playfield device. In MPF, the ball search functionality is attached to each playfield device, rather than being done at the global level. (In other words, each playfield is responsible for making sure no balls get stuck on it, and it leverages an instance of this BallSearch class to handle it.) """ def __init__(self, machine: MachineController, playfield: "Playfield") -> None: """Initialize ball search.""" self.module_name = 'BallSearch.' + playfield.name self.config_name = 'ball_search' super().__init__(machine) self.playfield = playfield """The playfield device this ball search instance is attached to.""" self.delay = DelayManager(self.machine) self.started = False """Is the ball search process started (running) now.""" self.enabled = False """Is ball search enabled.""" self.blocked = False """If True, ball search will be blocked and will not start.""" self.callbacks = [] # type: List[BallSearchCallback] self.iteration = False """Current iteration of the ball search, or ``False`` if ball search is not started.""" self.iterator = False self.phase = False """Current phase of the ball search, or ``False`` if ball search is not started.""" # register for events self.machine.events.add_handler('request_to_start_game', self.request_to_start_game) self.machine.events.add_handler('cancel_ball_search', self.cancel_ball_search) '''event: cancel_ball_search desc: This event will cancel all running ball searches and mark the balls as lost. This is only a handler so all you have to do is to post the event.''' def request_to_start_game(self, **kwargs): """Handle result of the *request_to_start_game* event. If ball search is running, this method will return *False* to prevent the game from starting while ball search is running. This method also posts the *ball_search_prevents_game_start* event if ball search is started. """ # todo we should enable ball search if a ball is missing on game start del kwargs if self.started: self.machine.events.post('ball_search_prevents_game_start') '''event: ball_search_prevents_game_start desc: A game start has been requested, but the ball search process is running and thus the game start has been blocked. This is a good event to use for a slide player to inform the player that the machine is looking for a missing ball.''' return False return True def register(self, priority, callback, name, *, restore_callback=None): """Register a callback for sequential ball search. Callbacks are called by priority. Ball search only waits if the callback returns true. Args: ---- priority: priority of this callback in the ball search procedure callback: callback to call. ball search will wait before the next callback, if it returns true name: string name which is used for debugging & the logs restore_callback: optional callback to restore state of the device after ball search ended """ self.debug_log("Registering callback: {} (priority: {})".format( name, priority)) self.callbacks.append( BallSearchCallback(priority, callback, name, restore_callback)) # sort by priority self.callbacks = sorted(self.callbacks, key=lambda entry: entry.priority) def enable(self, **kwargs): """Enable the ball search for this playfield. Note that this method does *not* start the ball search process. Rather it just resets and starts the timeout timer, as well as resetting it when playfield switches are hit. """ if self.blocked: return del kwargs if self.playfield.config['enable_ball_search'] is False or ( not self.playfield.config['enable_ball_search'] and not self.machine.config['mpf']['default_ball_search']): return if not self.callbacks: raise AssertionError("No callbacks registered") self.debug_log("Enabling Ball Search") self.enabled = True self.reset_timer() def disable(self, **kwargs): """Disable ball search. This method will also stop the ball search if it is running. """ del kwargs self.stop() self.debug_log("Disabling Ball Search") self.enabled = False self.delay.remove('start') def block(self, **kwargs): """Block ball search for this playfield. Blocking will disable ball search if it's enabled or running, and will prevent ball search from enabling if it's disabled until ``ball_search_unblock()`` is called. """ del kwargs self.debug_log("Blocking ball search") self.disable() self.blocked = True def unblock(self, **kwargs): """Unblock ball search for this playfield. This will check to see if there are balls on the playfield, and if so, enable ball search. """ del kwargs self.debug_log("Unblocking ball search") self.blocked = False if self.playfield.balls: self.enable() def reset_timer(self): """Reset the timeout timer which starts ball search. This method will also cancel an actively running (started) ball search. This is called by the playfield anytime a playfield switch is hit. """ if self.started: self.stop() if self.enabled: self.debug_log("Resetting ball search timer") self.delay.reset(name='start', callback=self.start, ms=self.playfield.config['ball_search_timeout']) def start(self): """Start ball search the ball search process.""" if not self.enabled or self.started or not self.callbacks: return self.started = True self.iteration = 1 self.phase = 1 self.iterator = iter(self.callbacks) self.info_log("Starting ball search") self.machine.events.post('ball_search_started') '''event: ball_search_started desc: The ball search process has been begun. ''' self.machine.events.post('ball_search_phase_1', iteration=1) # see description below self._run() def stop(self): """Stop an actively running ball search.""" if not self.started: return self.info_log("Stopping ball search") self.started = False self.delay.remove('run') # restore all devices for callback in self.callbacks: if callback.restore_callback: callback.restore_callback() self.machine.events.post('ball_search_stopped') '''event: ball_search_stopped desc: The ball search process has been disabled. This event is posted any time ball search stops, regardless of whether it found a ball or gave up. (If the ball search failed to find the ball, it will also post the *ball_search_failed* event.) ''' def _run(self): # Runs one iteration of the ball search. # Will schedule itself for the next run. # check if we should skip this phase if not self.playfield.config['ball_search_phase_{}_searches'.format( self.phase)]: self.phase += 1 if self.phase > 3: # give up self.give_up() else: # go to the next phase self._run() return timeout = self.playfield.config['ball_search_interval'] # iterate until we are done with all callbacks while True: try: element = next(self.iterator) except StopIteration: self.iteration += 1 self.machine.events.post('ball_search_phase_{}'.format( self.phase), iteration=self.iteration) '''event: ball_search_phase_(num) desc: The ball search phase (num) has started. args: iteration: Current iteration of phase (num) ''' # give up at some point if self.iteration > self.playfield.config[ 'ball_search_phase_{}_searches'.format(self.phase)]: self.phase += 1 self.iteration = 1 if self.phase > 3: self.give_up() return self.iterator = iter(self.callbacks) element = next(self.iterator) timeout = self.playfield.config[ 'ball_search_wait_after_iteration'] # if a callback returns True we wait for the next one self.debug_log("Ball search: {} (phase: {} iteration: {})".format( element.name, self.phase, self.iteration)) if element.callback(self.phase, self.iteration): self.delay.add(name='run', callback=self._run, ms=timeout) return def cancel_ball_search(self, **kwargs): """Cancel the current ball search and mark the ball as missing.""" del kwargs if self.started: self.give_up() def give_up(self): """Give up the ball search. This method is called when the ball search process Did not find the missing ball. It executes the failed action which depending on the specification of *ball_search_failed_action*, either adds a replacement ball, ends the game, or ends the current ball. """ self.info_log("Ball Search failed to find ball. Giving up!") self.disable() self.machine.events.post('ball_search_failed') '''event: ball_search_failed desc: The ball search process has failed to locate a missing or stuck ball and has given up. This event will be posted immediately after the *ball_search_stopped* event. ''' lost_balls = self.playfield.balls self.machine.ball_controller.num_balls_known -= lost_balls self.playfield.balls = 0 self.playfield.available_balls = 0 self._compensate_lost_balls(lost_balls) def _compensate_lost_balls(self, lost_balls): if not self.machine.game: return if self.playfield.config['ball_search_failed_action'] == "new_ball": if self.machine.ball_controller.num_balls_known > 0: # we have at least one ball remaining self.info_log("Adding %s replacement ball", lost_balls) for dummy_iterator in range(lost_balls): self.playfield.add_ball() else: self.info_log("No more balls left. Ending game!") self.machine.game.end_game() elif self.playfield.config['ball_search_failed_action'] == "end_game": if self.machine.game: self.info_log("Ending the game") self.machine.game.end_game() else: self.warning_log("There is no game. Doing nothing!") elif self.playfield.config['ball_search_failed_action'] == "end_ball": self.info_log("Ending current ball") self.machine.game.end_ball() else: raise AssertionError( "Unknown action " + self.playfield.config['ball_search_failed_action'])
class LogicBlock(SystemWideDevice, ModeDevice): """Parent class for each of the logic block classes.""" __slots__ = ["delay", "_state", "_start_enabled", "player_state_variable"] def __init__(self, machine: MachineController, name: str) -> None: """Initialize logic block.""" super().__init__(machine, name) self.delay = DelayManager(self.machine) self._state = None # type: Optional[LogicBlockState] self._start_enabled = None # type: Optional[bool] self.player_state_variable = "{}_state".format(self.name) '''player_var: (logic_block)_state config_section: counters, accruals, sequences desc: A dictionary that stores the internal state of the logic block with the name (logic_block). (In other words, a logic block called *mode1_hit_counter* will store its state in a player variable called ``mode1_hit_counter_state``). The state that's stored in this variable include whether the logic block is enabled and whether it's complete. ''' async def _initialize(self): await super()._initialize() if self.config['start_enabled'] is not None: self._start_enabled = self.config['start_enabled'] else: self._start_enabled = not self.config['enable_events'] def add_control_events_in_mode(self, mode: Mode) -> None: """Do not auto enable this device in modes.""" def validate_and_parse_config(self, config: dict, is_mode_config: bool, debug_prefix: str = None) -> dict: """Validate logic block config.""" del is_mode_config del debug_prefix if 'events_when_complete' not in config: config['events_when_complete'] = [ 'logicblock_' + self.name + '_complete' ] if 'events_when_hit' not in config: config['events_when_hit'] = ['logicblock_' + self.name + '_hit'] self.machine.config_validator.validate_config( self.config_section, config, self.name, ("device", "logic_blocks_common")) self._configure_device_logging(config) return config @property def can_exist_outside_of_game(self) -> bool: """Return true if persist_state is not set.""" return not bool(self.config['persist_state']) def get_start_value(self) -> Any: """Return the start value for this block.""" raise NotImplementedError("implement") async def device_added_system_wide(self): """Initialise internal state.""" self._state = LogicBlockState() self.value = self.get_start_value() await super().device_added_system_wide() if not self.config['enable_events']: self.enable() if self.config['persist_state']: self.raise_config_error( "Cannot set persist_state for system-wide logic_blocks", 1) self.post_update_event() def device_loaded_in_mode(self, mode: Mode, player: Player): """Restore internal state from player if persist_state is set or create new state.""" super().device_loaded_in_mode(mode, player) if self.config['persist_state']: if not player.is_player_var(self.player_state_variable): player[self.player_state_variable] = LogicBlockState() # enable device ONLY when we create a new entry in the player if self._start_enabled: mode.add_mode_event_handler( MODE_STARTING_EVENT_TEMPLATE.format(mode.name), self.event_enable, priority=mode.priority + 1) self._state = player[self.player_state_variable] self.value = self.get_start_value() else: self._state = player[self.player_state_variable] else: self._state = LogicBlockState() self.value = self.get_start_value() if self._start_enabled: mode.add_mode_event_handler( MODE_STARTING_EVENT_TEMPLATE.format(mode.name), self.event_enable, priority=mode.priority + 1) mode.add_mode_event_handler("mode_{}_starting".format(mode.name), self.post_update_event) def device_removed_from_mode(self, mode: Mode): """Unset internal state to prevent leakage.""" super().device_removed_from_mode(mode) self._state = None @property def value(self): """Return value or None if that is currently not possible.""" if self._state: return self._state.value return None @value.setter def value(self, value): """Set the value.""" self._state.value = value @property def enabled(self): """Return if enabled.""" return self._state and self._state.enabled @enabled.setter def enabled(self, value): """Set enable.""" self._state.enabled = value @property def completed(self): """Return if completed.""" return self._state and self._state.completed @completed.setter def completed(self, value): """Set if completed.""" self._state.completed = value def post_update_event(self, **kwargs): """Post an event to notify about changes.""" del kwargs value = self._state.value enabled = self._state.enabled self.machine.events.post("logicblock_{}_updated".format(self.name), value=value, enabled=enabled) '''event: logicblock_(name)_updated config_section: counters, accruals, sequences desc: The logic block called "name" has changed. This might happen when the block advanced, it was resetted or restored. args: value: The current value of this block. enabled: Whatever this block is enabled or not. ''' def enable(self): """Enable this logic block. Automatically called when one of the enable_event events is posted. Can also manually be called. """ super().enable() self.debug_log("Enabling") self.enabled = True self.post_update_event() self._logic_block_timer_start() def _post_hit_events(self, **kwargs): self.post_update_event() for event in self.config['events_when_hit']: self.machine.events.post(event, **kwargs) '''event: logicblock_(name)_hit config_section: counters, accruals, sequences desc: The logic block "name" was just hit. Note that this is the default hit event for logic blocks, but this can be changed in a logic block's "events_when_hit:" setting, so this might not be the actual event that's posted for all logic blocks in your machine. args: depend on the type ''' @event_handler(0) def event_disable(self, **kwargs): """Event handler for disable event.""" del kwargs self.disable() def disable(self): """Disable this logic block. Automatically called when one of the disable_event events is posted. Can also manually be called. """ self.debug_log("Disabling") self.enabled = False self.post_update_event() self.delay.remove("timeout") @event_handler(4) def event_reset(self, **kwargs): """Event handler for reset event.""" del kwargs self.reset() def reset(self): """Reset the progress towards completion of this logic block. Automatically called when one of the reset_event events is called. Can also be manually called. """ self.completed = False self.value = self.get_start_value() self.debug_log("Resetting") self.post_update_event() self._logic_block_timer_start() def _logic_block_timer_start(self): if self.config['logic_block_timeout']: self.debug_log("Setting up a logic block timer for %sms", self.config['logic_block_timeout']) self.delay.reset(name="timeout", ms=self.config['logic_block_timeout'], callback=self._logic_block_timeout) def _logic_block_timeout(self): """Reset the progress towards completion of this logic block when timer expires. Automatically called when one of the logic_block_timer_complete events is called. """ self.info_log("Logic Block timeouted") self.machine.events.post("{}_timeout".format(self.name)) '''event: (name)_timeout config_section: counters, accruals, sequences desc: The logic block called "name" has just timeouted. Timeouts are disabled by default but you can set logic_block_timeout to enable them. They will run from start of your logic block until it is stopped. ''' self.reset() @event_handler(5) def event_restart(self, **kwargs): """Event handler for restart event.""" del kwargs self.restart() def restart(self): """Restart this logic block by calling reset() and enable(). Automatically called when one of the restart_event events is called. Can also be manually called. """ self.debug_log("Restarting (resetting then enabling)") self.reset() self.enable() def complete(self): """Mark this logic block as complete. Posts the 'events_when_complete' events and optionally restarts this logic block or disables it, depending on this block's configuration settings. """ # if already completed do not complete again if self.completed: return # otherwise mark as completed self.completed = True self.delay.remove("timeout") self.debug_log("Complete") if self.config['events_when_complete']: for event in self.config['events_when_complete']: self.machine.events.post(event) '''event: logicblock_(name)_complete config_section: counters, accruals, sequences desc: The logic block called "name" has just been completed. Note that this is the default completion event for logic blocks, but this can be changed in a logic block's "events_when_complete:" setting, so this might not be the actual event that's posted for all logic blocks in your machine. ''' # call reset to reset completion if self.config['reset_on_complete']: self.reset() # disable block if self.config['disable_on_complete']: self.disable()
class Light(SystemWideDevice): """A light in a pinball machine.""" config_section = 'lights' collection = 'lights' class_label = 'light' def __init__(self, machine, name): """Initialise light.""" self.hw_drivers = {} self.platforms = set() # type: Set[LightsPlatform] super().__init__(machine, name) self.machine.light_controller.initialise_light_subsystem() self.delay = DelayManager(self.machine.delayRegistry) self.default_fade_ms = None self._color_correction_profile = None self.stack = list() """A list of dicts which represents different commands that have come in to set this light to a certain color (and/or fade). Each entry in the list contains the following key/value pairs: priority: The relative priority of this color command. Higher numbers take precedent, and the highest priority entry will be the command that's currently active. In the event of a tie, whichever entry was added last wins (based on 'start_time' below). start_time: The clock time when this command was added. Primarily used to calculate fades, but also used as a tie-breaker for multiple entries with the same priority. start_color: RGBColor() of the color of this light when this command came in. dest_time: Clock time that represents when a fade (from start_color to dest_color) will be done. If this is 0, that means there is no fade. When a fade is complete, this value is reset to 0. dest_color: RGBColor() of the destination this light is fading to. If a command comes in with no fade, then this will be the same as the 'color' below. key: An arbitrary unique identifier to keep multiple entries in the stack separate. If a new color command comes in with a key that already exists for an entry in the stack, that entry will be replaced by the new entry. The key is also used to remove entries from the stack (e.g. when shows or modes end and they want to remove their commands from the light). """ @classmethod def device_class_init(cls, machine: MachineController): """Register handler for duplicate light number checks.""" machine.events.add_handler("init_phase_4", cls._check_duplicate_light_numbers, machine=machine) @staticmethod def _check_duplicate_light_numbers(machine, **kwargs): del kwargs check_set = set() for light in machine.lights: for driver in light.hw_drivers.values(): key = (light.platform, driver.number, type(driver)) if key in check_set: raise AssertionError( "Duplicate light number {} {} for light {}".format( type(driver), driver.number, light)) check_set.add(key) def _map_channels_to_colors(self, channel_list) -> dict: if self.config['type']: color_channels = self.config['type'] else: if len(channel_list) == 1: # for one channel default to a white channel color_channels = "w" elif len(channel_list) == 3: # for three channels default to RGB color_channels = "rgb" else: raise AssertionError( "Please provide a type for light {}. No default for channels {}." .format(self.name, channel_list)) if len(channel_list) != len(color_channels): raise AssertionError( "Type {} does not match channels {} for light {}".format( color_channels, channel_list, self.name)) channels = {} for color_name in color_channels: # red channel if color_name == 'r': channels["red"] = channel_list.pop(0) # green channel elif color_name == 'g': channels["green"] = channel_list.pop(0) # blue channel elif color_name == 'b': channels["blue"] = channel_list.pop(0) # simple white channel elif color_name == 'w': channels["white"] = channel_list.pop(0) else: raise AssertionError( "Invalid element {} in type {} of light {}".format( color_name, self.config['type'], self.name)) return channels def _load_hw_drivers(self): """Load hw drivers.""" if self.config['platform'] == "drivers": channel_list = [{ "number": self.config['number'], "platform": "drivers" }] # map channel to color channels = self._map_channels_to_colors(channel_list) elif not self.config['channels']: # get channels from number + platform platform = self.machine.get_platform_sections( 'lights', self.config['platform']) try: channel_list = platform.parse_light_number_to_channels( self.config['number'], self.config['subtype']) except AssertionError as e: raise AssertionError( "Failed to parse light number {} in platform. See error above" .format(self.name)) from e # copy platform and platform_settings to all channels for channel, _ in enumerate(channel_list): channel_list[channel]['subtype'] = self.config['subtype'] channel_list[channel]['platform'] = self.config['platform'] channel_list[channel]['platform_settings'] = self.config[ 'platform_settings'] # map channels to colors channels = self._map_channels_to_colors(channel_list) else: if self.config['number'] or self.config['platform'] or self.config[ 'platform_settings']: raise AssertionError( "Light {} cannot contain platform/platform_settings/number and channels" .format(self.name)) # alternatively use channels from config channels = self.config['channels'] if not channels: raise AssertionError("Light {} has no channels.".format(self.name)) for color, channel in channels.items(): channel = self.machine.config_validator.validate_config( "light_channels", channel) self.hw_drivers[color] = self._load_hw_driver(channel) def _load_hw_driver(self, channel): """Load one channel.""" if channel['platform'] == "drivers": 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']) self.platforms.add(platform) try: return platform.configure_light(channel['number'], channel['subtype'], channel['platform_settings']) except AssertionError as e: raise AssertionError( "Failed to configure light {} in platform. See error above" .format(self.name)) from e def _initialize(self): self._load_hw_drivers() self.config['default_on_color'] = RGBColor( self.config['default_on_color']) if self.config['color_correction_profile'] is not None: profile_name = self.config['color_correction_profile'] elif 'light_settings' in self.machine.config and \ self.machine.config['light_settings']['default_color_correction_profile'] is not None: profile_name = self.machine.config['light_settings'][ 'default_color_correction_profile'] else: profile_name = None if profile_name: if profile_name in self.machine.light_controller.light_color_correction_profiles: profile = self.machine.light_controller.light_color_correction_profiles[ profile_name] if profile is not None: self._set_color_correction_profile(profile) else: # pragma: no cover error = "Color correction profile '{}' was specified for light '{}'"\ " but the color correction profile does not exist."\ .format(profile_name, self.name) self.error_log(error) raise ValueError(error) if self.config['fade_ms'] is not None: self.default_fade_ms = self.config['fade_ms'] else: self.default_fade_ms = ( self.machine.config['light_settings']['default_fade_ms']) self.debug_log( "Initializing Light. CC Profile: %s, " "Default fade: %sms", self._color_correction_profile, self.default_fade_ms) def _set_color_correction_profile(self, profile): """Apply a color correction profile to this light. Args: profile: An RGBColorCorrectionProfile() instance """ self._color_correction_profile = profile def color(self, color, fade_ms=None, priority=0, key=None): """Add or update a color entry in this light's stack. Calling this methods is how you tell this light what color you want it to be. Args: color: RGBColor() instance, or a string color name, hex value, or 3-integer list/tuple of colors. fade_ms: Int of the number of ms you want this light to fade to the color in. A value of 0 means it's instant. A value of None (the default) means that it will use this light's and/or the machine's default fade_ms setting. priority: Int value of the priority of these incoming settings. If this light has current settings in the stack at a higher priority, the settings you're adding here won't take effect. However they're still added to the stack, so if the higher priority settings are removed, then the next-highest apply. key: An arbitrary identifier (can be any immutable object) that's used to identify these settings for later removal. If any settings in the stack already have this key, those settings will be replaced with these new settings. """ self.debug_log( "Received color() command. color: %s, fade_ms: %s" "priority: %s, key: %s", color, fade_ms, priority, key) if not isinstance(color, RGBColor): color = RGBColor(color) if fade_ms is None: fade_ms = self.default_fade_ms start_time = self.machine.clock.get_time() color_changes = not self.stack or self.stack[0]['priority'] <= priority self._add_to_stack(color, fade_ms, priority, key, start_time) if color_changes: self._schedule_update() def on(self, fade_ms=None, brightness=None, priority=0, key=None, **kwargs): """Turn light on. Args: key: key for removal later on priority: priority on stack fade_ms: duration of fade """ del kwargs if brightness is not None: color = (brightness, brightness, brightness) else: color = self.config['default_on_color'] self.color(color=color, fade_ms=fade_ms, priority=priority, key=key) def off(self, fade_ms=None, priority=0, key=None, **kwargs): """Turn light off. Args: key: key for removal later on priority: priority on stack fade_ms: duration of fade """ del kwargs self.color(color=RGBColor(), fade_ms=fade_ms, priority=priority, key=key) # pylint: disable-msg=too-many-arguments def _add_to_stack(self, color, fade_ms, priority, key, start_time): """Add color to stack.""" # handle None to make keys sortable if not key: key = "" else: key = str(key) if priority < self._get_priority_from_key(key): self.debug_log( "Incoming priority %s is lower than an existing " "stack item with the same key %s. Not adding to " "stack.", priority, key) return if self.stack and priority == self.stack[0]['priority']: self.debug_log( "Light stack contains two entries with the same priority. %s", self.stack) if fade_ms: dest_time = start_time + (fade_ms / 1000) else: dest_time = 0 color_below = self.get_color_below(priority, key) self._remove_from_stack_by_key(key) self.stack.append( dict(priority=priority, start_time=start_time, start_color=color_below, dest_time=dest_time, dest_color=color, key=key)) self.stack.sort(key=itemgetter('priority', 'key'), reverse=True) self.debug_log("+-------------- Adding to stack ----------------+") self.debug_log("priority: %s", priority) self.debug_log("start_time: %s", self.machine.clock.get_time()) self.debug_log("start_color: %s", color_below) self.debug_log("dest_time: %s", dest_time) self.debug_log("dest_color: %s", color) self.debug_log("key: %s", key) def remove_from_stack_by_key(self, key, fade_ms=None): """Remove a group of color settings from the stack. Args: key: The key of the settings to remove (based on the 'key' parameter that was originally passed to the color() method.) This method triggers a light update, so if the highest priority settings 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. """ if not self.stack: # no stack return if fade_ms is None: fade_ms = self.default_fade_ms key = str(key) priority = None color_changes = True stack = [] for i, entry in enumerate(self.stack): if entry["key"] == key: stack = self.stack[i:] priority = entry["priority"] break elif entry["key"] != key and entry["dest_color"] is not None: # no transparency above key color_changes = False # key not in stack if not stack: return if fade_ms: color_of_key = self._get_color_and_fade(stack, 0)[0] self._remove_from_stack_by_key(key) if fade_ms: start_time = self.machine.clock.get_time() self.stack.append( dict(priority=priority, start_time=start_time, start_color=color_of_key, dest_time=start_time + fade_ms / 1000.0, dest_color=None, key=key)) self.delay.reset(ms=fade_ms, callback=partial(self._remove_fade_out, key=key), name="remove_fade") self.stack.sort(key=itemgetter('priority', 'key'), reverse=True) if color_changes: self._schedule_update() def _remove_fade_out(self, key): """Remove a timed out fade out.""" if not self.stack: return self.debug_log("Removing key '%s' from stack", key) self.stack[:] = [ x for x in self.stack if x['key'] != key or x['dest_color'] is not None ] def _remove_from_stack_by_key(self, key): """Remove a key from stack.""" # tune the common case if not self.stack: return self.debug_log("Removing key '%s' from stack", key) self.stack[:] = [x for x in self.stack if x['key'] != key] def _schedule_update(self): for color, hw_driver in self.hw_drivers.items(): hw_driver.set_fade( partial(self._get_brightness_and_fade, color=color)) for platform in self.platforms: platform.light_sync() def clear_stack(self): """Remove all entries from the stack and resets this light to 'off'.""" self.stack[:] = [] self.debug_log("Clearing Stack") self._schedule_update() def _get_priority_from_key(self, key): try: return [x for x in self.stack if x['key'] == key][0]['priority'] except IndexError: return 0 def gamma_correct(self, color): """Apply max brightness correction to color. Args: color: The RGBColor() instance you want to have gamma applied. Returns: An updated RGBColor() instance with gamma corrected. """ factor = self.machine.get_machine_var("brightness") if not factor: return color else: return RGBColor([int(x * factor) for x in color]) def color_correct(self, color): """Apply the current color correction profile to the color passed. Args: color: The RGBColor() instance you want to get color corrected. Returns: An updated RGBColor() instance with the current color correction profile applied. Note that if there is no current color correction profile applied, the returned color will be the same as the color that was passed. """ if self._color_correction_profile is None: return color else: self.debug_log( "Applying color correction: %s (applied " "'%s' color correction profile)", self._color_correction_profile.apply(color), self._color_correction_profile.name) return self._color_correction_profile.apply(color) # pylint: disable-msg=too-many-return-statements def _get_color_and_fade(self, stack, max_fade_ms: int) -> Tuple[RGBColor, int]: try: color_settings = stack[0] except IndexError: # no stack return RGBColor('off'), -1 dest_color = color_settings['dest_color'] # no fade if not color_settings['dest_time']: # if we are transparent just return the lower layer if dest_color is None: return self._get_color_and_fade(stack[1:], max_fade_ms) return dest_color, -1 current_time = self.machine.clock.get_time() # fade is done if current_time >= color_settings['dest_time']: # if we are transparent just return the lower layer if dest_color is None: return self._get_color_and_fade(stack[1:], max_fade_ms) return color_settings['dest_color'], -1 if dest_color is None: dest_color, lower_fade_ms = self._get_color_and_fade( stack[1:], max_fade_ms) if lower_fade_ms > 0: max_fade_ms = min(lower_fade_ms, max_fade_ms) 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 dest_color, int( (color_settings['dest_time'] - current_time) * 1000) # figure out the ratio of how far along we are try: ratio = ( (target_time - color_settings['start_time']) / (color_settings['dest_time'] - color_settings['start_time'])) except ZeroDivisionError: ratio = 1.0 return RGBColor.blend(color_settings['start_color'], 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( self.stack, max_fade_ms) corrected_color = self.gamma_correct(uncorrected_color) corrected_color = self.color_correct(corrected_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: raise AssertionError("Invalid color {}".format(color)) return brightness, fade_ms @property def _color(self): """Getter for color.""" return self.get_color() def get_color_below(self, priority, key): """Return an RGBColor() instance of the 'color' setting of the highest color below a certain key. Similar to get_color. """ stack = [] for i, entry in enumerate(self.stack): if entry['priority'] <= priority and entry["key"] <= key: stack = self.stack[i:] return self._get_color_and_fade(stack, 0)[0] 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(self.stack, 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())
class DropTarget(SystemWideDevice): """Represents a single drop target in a pinball machine. Args: Same as the `Target` parent class """ config_section = 'drop_targets' collection = 'drop_targets' class_label = 'drop_target' def __init__(self, machine: "MachineController", name: str) -> None: """Initialise drop target.""" self.reset_coil = None # type: Driver self.knockdown_coil = None # type: Driver self.banks = None # type: Set[DropTargetBank] super().__init__(machine, name) self._in_ball_search = False self.complete = False self.delay = DelayManager(machine.delayRegistry) self._ignore_switch_hits = False def _initialize(self) -> None: self.reset_coil = self.config['reset_coil'] self.knockdown_coil = self.config['knockdown_coil'] self.banks = set() # can't read the switch until the switch controller is set up self.machine.events.add_handler('init_phase_4', self._update_state_from_switch, priority=2) self.machine.events.add_handler('init_phase_4', self._register_switch_handlers, priority=1) if self.config['ball_search_order']: self.config['playfield'].ball_search.register( self.config['ball_search_order'], self._ball_search, self.name) def _ignore_switch_hits_for(self, ms): """Ignore switch hits for ms.""" self._ignore_switch_hits = True self.delay.reset(name="ignore_switch", callback=self._restore_switch_hits, ms=ms) def _restore_switch_hits(self): self._ignore_switch_hits = False self._update_state_from_switch(reconcile=True) def _ball_search_phase1(self): if not self.complete and self.reset_coil: self.reset_coil.pulse() return True # if down. knock down again elif self.complete and self.knockdown_coil: self.knockdown_coil.pulse() return True def _ball_search_phase2(self): if self.reset_coil and self.knockdown_coil: if self.complete: self._in_ball_search = True self.reset_coil.pulse() self.delay.add(100, self._ball_search_knockdown) return True else: self._in_ball_search = True self.knockdown_coil.pulse() self.delay.add(100, self._ball_search_reset) return True else: # fall back to phase1 return self._ball_search_phase1() def _ball_search_phase3(self): if self.complete: if self.reset_coil: self.reset_coil.pulse() if self.knockdown_coil: self._in_ball_search = True self.delay.add(100, self._ball_search_knockdown) return True else: return self._ball_search_phase1() else: if self.knockdown_coil: self.knockdown_coil.pulse() if self.reset_coil: self._in_ball_search = True self.delay.add(100, self._ball_search_reset) return True else: return self._ball_search_phase1() def _ball_search_iteration_finish(self): self._in_ball_search = False def _ball_search_knockdown(self): self.knockdown_coil.pulse() self.delay.add(100, self._ball_search_iteration_finish) def _ball_search_reset(self): self.reset_coil.pulse() self.delay.add(100, self._ball_search_iteration_finish) def _ball_search(self, phase, iteration): del iteration if phase == 1: # phase 1: do not change state. # if up. reset again return self._ball_search_phase1() elif phase == 2: # phase 2: if we can reset and knockdown the target we will do that return self._ball_search_phase2() else: # phase3: reset no matter what return self._ball_search_phase3() def _register_switch_handlers(self, **kwargs): del kwargs # register for notification of switch state # this is in addition to the parent since drop targets track # self.complete in separately self.machine.switch_controller.add_switch_handler( self.config['switch'].name, self._update_state_from_switch, 0) self.machine.switch_controller.add_switch_handler( self.config['switch'].name, self._update_state_from_switch, 1) @event_handler(6) def enable_keep_up(self, **kwargs): """Keep the target up by enabling the coil.""" del kwargs if self.reset_coil: self.reset_coil.enable() @event_handler(5) def disable_keep_up(self, **kwargs): """No longer keep up the target up.""" del kwargs if self.reset_coil: self.reset_coil.disable() @event_handler(7) def knockdown(self, **kwargs): """Pulse the knockdown coil to knock down this drop target.""" del kwargs if self.knockdown_coil and not self.machine.switch_controller.is_active( self.config['switch'].name): self._ignore_switch_hits_for(ms=self.config['ignore_switch_ms']) self.knockdown_coil.pulse( self.config['knockdown_coil_max_wait_ms']) def _update_state_from_switch(self, reconcile=False, **kwargs): del kwargs is_complete = self.machine.switch_controller.is_active( self.config['switch'].name) if (self._in_ball_search or self._ignore_switch_hits or is_complete == self.complete): return if not reconcile: self.config['playfield'].mark_playfield_active_from_device_action() if is_complete != self.complete: if is_complete: self._down() else: self._up() self._update_banks() def _down(self): self.complete = True self.machine.events.post('drop_target_' + self.name + '_down', device=self) '''event: drop_target_(name)_down desc: The drop target with the (name) has just changed to the "down" state.''' def _up(self): self.complete = False self.machine.events.post('drop_target_' + self.name + '_up', device=self) '''event: drop_target_(name)_up desc: The drop target (name) has just changed to the "up" state.''' def _update_banks(self): for bank in self.banks: bank.member_target_change() def add_to_bank(self, bank): """Add this drop target to a drop target bank. This allows the bank to update its status based on state changes to this drop target. Args: bank: DropTargetBank object to add this drop target to. """ self.banks.add(bank) def remove_from_bank(self, bank): """Remove the DropTarget from a bank. Args: bank: DropTargetBank object to remove """ self.banks.remove(bank) @event_handler(1) def reset(self, **kwargs): """Reset this drop target. If this drop target is configured with a reset coil, then this method will pulse that coil. If not, then it checks to see if this drop target is part of a drop target bank, and if so, it calls the reset() method of the drop target bank. This method does not reset the target profile, however, the switch event handler should reset the target profile on its own when the drop target physically moves back to the up position. """ del kwargs if self.reset_coil and self.machine.switch_controller.is_active( self.config['switch'].name): self._ignore_switch_hits_for(ms=self.config['ignore_switch_ms']) self.reset_coil.pulse(self.config['reset_coil_max_wait_ms'])
class DigitalOutput(SystemWideDevice): """A digital output on either a light or driver platform.""" config_section = 'digital_outputs' collection = 'digital_outputs' class_label = 'digital_output' __slots__ = ["hw_driver", "platform", "type", "__dict__"] def __init__(self, machine: MachineController, name: str) -> None: """Initialise digital output.""" self.hw_driver = None # type: Optional[Union[DriverPlatformInterface, LightPlatformInterface]] self.platform = None # type: Optional[Union[DriverPlatform, LightsPlatform]] self.type = None # type: Optional[str] super().__init__(machine, name) self.delay = DelayManager(self.machine) async def _initialize(self): """Initialise the hardware driver for this digital output.""" await super()._initialize() if self.config['type'] == "driver": self._initialize_driver() elif self.config['type'] == "light": self._initialize_light() else: raise AssertionError(INVALID_TYPE_ERROR.format( self.config['type'])) def _initialize_light(self): """Configure a light as digital output.""" self.platform = self.machine.get_platform_sections( 'lights', self.config['platform']) self.platform.assert_has_feature("lights") self.type = "light" if not self.platform.features[ 'allow_empty_numbers'] and self.config['number'] is None: self.raise_config_error("Digital Output must have a number.", 1) config = LightConfig(name=self.name, color=LightConfigColors.NONE) try: self.hw_driver = self.platform.configure_light( self.config['number'], self.config['light_subtype'], config, {}) except AssertionError as e: raise AssertionError( "Failed to configure light {} in platform. See error above". format(self.name)) from e def validate_and_parse_config(self, config: dict, is_mode_config: bool, debug_prefix: str = None) -> dict: """Return the parsed and validated config. Args: ---- config: Config of device is_mode_config: Whether this device is loaded in a mode or system-wide debug_prefix: Prefix to use when logging. Returns: Validated config """ config = super().validate_and_parse_config(config, is_mode_config, debug_prefix) if config['type'] == "driver": platform = self.machine.get_platform_sections( 'coils', getattr(config, "platform", None)) platform.assert_has_feature("drivers") config['platform_settings'] = platform.validate_coil_section( self, config.get('platform_settings', None)) elif config['type'] == "light": platform = self.machine.get_platform_sections( 'coils', getattr(config, "platform", None)) platform.assert_has_feature("lights") else: raise AssertionError(INVALID_TYPE_ERROR.format(config['type'])) return config def _initialize_driver(self): """Configure a driver as digital output.""" self.platform = self.machine.get_platform_sections( 'coils', self.config['platform']) self.platform.assert_has_feature("drivers") self.type = "driver" config = DriverConfig(name=self.name, default_pulse_ms=255, default_pulse_power=1.0, default_hold_power=1.0, default_recycle=False, max_pulse_ms=255, max_pulse_power=1.0, max_hold_power=1.0) if not self.platform.features[ 'allow_empty_numbers'] and self.config['number'] is None: self.raise_config_error("Digital Output must have a number.", 2) try: self.hw_driver = self.platform.configure_driver( config, self.config['number'], self.config['platform_settings']) except AssertionError as e: raise AssertionError( "Failed to configure driver {} in platform. See error above". format(self.name)) from e @event_handler(3) def event_pulse(self, pulse_ms, **kwargs): """Handle pulse control event.""" del kwargs self.pulse(pulse_ms) def pulse(self, pulse_ms): """Pulse digital output.""" if self.type == "driver": self.hw_driver.pulse(PulseSettings(power=1.0, duration=pulse_ms)) elif self.type == "light": self.hw_driver.set_fade(1.0, -1, 1.0, -1) self.platform.light_sync() self.delay.reset(name='timed_disable', ms=pulse_ms, callback=self.disable) else: raise AssertionError(INVALID_TYPE_ERROR.format(self.type)) @event_handler(2) def event_enable(self, **kwargs): """Handle enable control event.""" del kwargs self.enable() def enable(self): """Enable digital output.""" if self.type == "driver": self.hw_driver.enable(PulseSettings(power=1.0, duration=0), HoldSettings(power=1.0)) elif self.type == "light": self.hw_driver.set_fade(1.0, -1, 1.0, -1) self.platform.light_sync() self.delay.remove(name='timed_disable') else: raise AssertionError(INVALID_TYPE_ERROR.format(self.type)) @event_handler(1) def event_disable(self, **kwargs): """Handle disable control event.""" del kwargs self.disable() def disable(self): """Disable digital output.""" if self.type == "driver": self.hw_driver.disable() elif self.type == "light": self.hw_driver.set_fade(0.0, -1, 0.0, -1) self.platform.light_sync() self.delay.remove(name='timed_disable') else: raise AssertionError(INVALID_TYPE_ERROR.format(self.type))
class Light(SystemWideDevice, DevicePositionMixin): """A light in a pinball machine.""" config_section = 'lights' collection = 'lights' class_label = 'light' __slots__ = ["hw_drivers", "platforms", "delay", "default_fade_ms", "_color_correction_profile", "stack", "_off_color", "_drivers_loaded", "_last_fade_target"] def __init__(self, machine, name): """Initialise light.""" self.hw_drivers = {} # type: Dict[str, List[LightPlatformInterface]] self.platforms = set() # type: Set[LightsPlatform] super().__init__(machine, name) self.machine.light_controller.initialise_light_subsystem() self.delay = DelayManager(self.machine) self._drivers_loaded = asyncio.Future(loop=self.machine.clock.loop) self.default_fade_ms = None self._off_color = RGBColor("off") self._color_correction_profile = None self._last_fade_target = None self.stack = list() # type: List[LightStackEntry] """A list of dicts which represents different commands that have come in to set this light to a certain color (and/or fade). Each entry in the list contains the following key/value pairs: priority: The relative priority of this color command. Higher numbers take precedent, and the highest priority entry will be the command that's currently active. In the event of a tie, whichever entry was added last wins (based on 'start_time' below). start_time: The clock time when this command was added. Primarily used to calculate fades, but also used as a tie-breaker for multiple entries with the same priority. start_color: RGBColor() of the color of this light when this command came in. dest_time: Clock time that represents when a fade (from start_color to dest_color) will be done. If this is 0, that means there is no fade. When a fade is complete, this value is reset to 0. dest_color: RGBColor() of the destination this light is fading to. If a command comes in with no fade, then this will be the same as the 'color' below. key: An arbitrary unique identifier to keep multiple entries in the stack separate. If a new color command comes in with a key that already exists for an entry in the stack, that entry will be replaced by the new entry. The key is also used to remove entries from the stack (e.g. when shows or modes end and they want to remove their commands from the light). """ @classmethod def device_class_init(cls, machine: MachineController): """Register handler for duplicate light number checks.""" machine.events.add_handler("init_phase_4", cls._check_duplicate_light_numbers, machine=machine) def get_hw_numbers(self): """Return a list of all hardware driver numbers.""" numbers = [] for _, drivers in sorted(self.hw_drivers.items()): for driver in sorted(drivers, key=lambda x: x.number): numbers.append(driver.number) return numbers @staticmethod def _check_duplicate_light_numbers(machine: MachineController, **kwargs): del kwargs check_set = set() for light in machine.lights.values(): for drivers in light.hw_drivers.values(): for driver in drivers: key = (light.config['platform'], driver.number, type(driver)) if key in check_set: raise AssertionError( "Duplicate light number {} {} for light {}".format( type(driver), driver.number, light)) check_set.add(key) def _map_channels_to_colors(self, channel_list) -> dict: if self.config['type']: color_channels = self.config['type'] else: if len(channel_list) == 1: # for one channel default to a white channel color_channels = "w" elif len(channel_list) == 3: # for three channels default to RGB color_channels = "rgb" else: raise AssertionError("Please provide a type for light {}. No default for channels {}.". format(self.name, channel_list)) if len(channel_list) != len(color_channels): raise AssertionError("Type {} does not match channels {} for light {}".format( color_channels, channel_list, self.name )) channels = {} # type: Dict[str, List[Any]] for color_name in color_channels: # red channel if color_name == 'r': full_color_name = "red" # green channel elif color_name == 'g': full_color_name = "green" # blue channel elif color_name == 'b': full_color_name = "blue" # simple white channel elif color_name == 'w': full_color_name = "white" else: raise AssertionError("Invalid element {} in type {} of light {}".format( color_name, self.config['type'], self.name)) if full_color_name not in channels: channels[full_color_name] = [] channels[full_color_name].append(channel_list.pop(0)) return channels def wait_for_loaded(self): """Return future.""" return asyncio.shield(self._drivers_loaded, loop=self.machine.clock.loop) def get_successor_number(self): """Get the number of the next light channel. We first have to find the last channel and then get the next number based on that. """ all_drivers = [] for drivers in self.hw_drivers.values(): all_drivers.extend(drivers) sorted_channels = sorted(all_drivers) return sorted_channels[-1].get_successor_number() def _load_hw_driver_sequentially(self, next_channel): if self.config['number'] or self.config['channels']: self.raise_config_error("Cannot use start_channel/previous and number or channels.", 3) if not self.config['type']: self.raise_config_error("Cannot use previous or start_channel without type. " "Add a type setting to your light.", 2) for color_name in self.config['type']: # red channel if color_name == 'r': full_color_name = "red" # green channel elif color_name == 'g': full_color_name = "green" # blue channel elif color_name == 'b': full_color_name = "blue" # simple white channel elif color_name == 'w': full_color_name = "white" else: raise AssertionError("Invalid element {} in type {} of light {}".format( color_name, self.config['type'], self.name)) if full_color_name not in self.hw_drivers: self.hw_drivers[full_color_name] = [] channel = {'subtype': self.config['subtype'], 'platform': self.config['platform'], 'platform_settings': self.config['platform_settings'], 'number': next_channel} channel = self.machine.config_validator.validate_config("light_channels", channel) driver = self._load_hw_driver(channel) next_channel = driver.get_successor_number() self.hw_drivers[full_color_name].append(driver) def _load_hw_drivers(self): if not self.config['channels']: # get channels from number + platform platform = self.machine.get_platform_sections('lights', self.config['platform']) platform.assert_has_feature("lights") try: channel_list = platform.parse_light_number_to_channels(self.config['number'], self.config['subtype']) except AssertionError as e: raise AssertionError("Failed to parse light number {} in platform. See error above". format(self.name)) from e # copy platform and platform_settings to all channels for channel, _ in enumerate(channel_list): channel_list[channel]['subtype'] = self.config['subtype'] channel_list[channel]['platform'] = self.config['platform'] channel_list[channel]['platform_settings'] = self.config['platform_settings'] # map channels to colors channels = self._map_channels_to_colors(channel_list) else: if self.config['number'] or self.config['platform'] or self.config['platform_settings']: raise AssertionError("Light {} cannot contain platform/platform_settings/number and channels". format(self.name)) # alternatively use channels from config channels = self.config['channels'] # ensure that we got lists for channel in channels: if not isinstance(channels[channel], list): channels[channel] = [channels[channel]] if not channels: raise AssertionError("Light {} has no channels.".format(self.name)) for color, channel_list in channels.items(): self.hw_drivers[color] = [] for channel in channel_list: channel = self.machine.config_validator.validate_config("light_channels", channel) driver = self._load_hw_driver(channel) self.hw_drivers[color].append(driver) def _load_hw_driver(self, channel): """Load one channel.""" platform = self.machine.get_platform_sections('lights', channel['platform']) self.platforms.add(platform) if not platform.features['allow_empty_numbers'] and channel['number'] is None: self.raise_config_error("Light must have a number.", 1) try: return platform.configure_light(channel['number'], channel['subtype'], channel['platform_settings']) except AssertionError as e: raise AssertionError("Failed to configure light {} in platform. See error above". format(self.name)) from e async def _initialize(self): await super()._initialize() if self.config['previous']: await self.config['previous'].wait_for_loaded() start_channel = self.config['previous'].get_successor_number() self._load_hw_driver_sequentially(start_channel) elif self.config['start_channel']: self._load_hw_driver_sequentially(self.config['start_channel']) else: self._load_hw_drivers() self._drivers_loaded.set_result(True) self.config['default_on_color'] = RGBColor(self.config['default_on_color']) if self.config['color_correction_profile'] is not None: profile_name = self.config['color_correction_profile'] elif 'light_settings' in self.machine.config and \ self.machine.config['light_settings']['default_color_correction_profile'] is not None: profile_name = self.machine.config['light_settings']['default_color_correction_profile'] else: profile_name = None if profile_name: if profile_name in self.machine.light_controller.light_color_correction_profiles: profile = self.machine.light_controller.light_color_correction_profiles[profile_name] if profile is not None: self._set_color_correction_profile(profile) else: # pragma: no cover error = "Color correction profile '{}' was specified for light '{}'"\ " but the color correction profile does not exist."\ .format(profile_name, self.name) self.error_log(error) raise ValueError(error) if self.config['fade_ms'] is not None: self.default_fade_ms = self.config['fade_ms'] else: self.default_fade_ms = (self.machine.config['light_settings'] ['default_fade_ms']) self.debug_log("Initializing Light. CC Profile: %s, " "Default fade: %sms", self._color_correction_profile, self.default_fade_ms) def _set_color_correction_profile(self, profile): """Apply a color correction profile to this light. Args: profile: An RGBColorCorrectionProfile() instance """ self._color_correction_profile = profile # pylint: disable-msg=too-many-arguments def color(self, color, fade_ms=None, priority=0, key=None, start_time=None): """Add or update a color entry in this light's stack. Calling this methods is how you tell this light what color you want it to be. Args: color: RGBColor() instance, or a string color name, hex value, or 3-integer list/tuple of colors. fade_ms: Int of the number of ms you want this light to fade to the color in. A value of 0 means it's instant. A value of None (the default) means that it will use this light's and/or the machine's default fade_ms setting. priority: Int value of the priority of these incoming settings. If this light has current settings in the stack at a higher priority, the settings you're adding here won't take effect. However they're still added to the stack, so if the higher priority settings are removed, then the next-highest apply. key: An arbitrary identifier (can be any immutable object) that's used to identify these settings for later removal. If any settings in the stack already have this key, those settings will be replaced with these new settings. start_time: Time this occured to synchronize lights. """ if self._debug: self.debug_log("Received color() command. color: %s, fade_ms: %s " "priority: %s, key: %s", color, fade_ms, priority, key) if isinstance(color, str) and color == "on": color = self.config['default_on_color'] elif not isinstance(color, RGBColor): color = RGBColor(color) if fade_ms is None: fade_ms = self.default_fade_ms if not start_time: start_time = self.machine.clock.get_time() color_changes = not self.stack or self.stack[0].priority <= priority or self.stack[0].dest_color is None self._add_to_stack(color, fade_ms, priority, key, start_time) if color_changes: self._schedule_update() def on(self, brightness=None, fade_ms=None, priority=0, key=None, **kwargs): """Turn light on. Args: brightness: Brightness factor for "on". key: key for removal later on priority: priority on stack fade_ms: duration of fade """ del kwargs color = self.config['default_on_color'] if brightness is not None: color *= brightness / 255 self.color(color=color, fade_ms=fade_ms, priority=priority, key=key) def off(self, fade_ms=None, priority=0, key=None, **kwargs): """Turn light off. Args: key: key for removal later on priority: priority on stack fade_ms: duration of fade """ del kwargs self.color(color=self._off_color, fade_ms=fade_ms, priority=priority, key=key) # pylint: disable-msg=too-many-arguments def _add_to_stack(self, color, fade_ms, priority, key, start_time): """Add color to stack.""" # handle None to make keys sortable if key is None: key = "" elif not isinstance(key, str): raise AssertionError("Key should be string") if self.stack and priority < self._get_priority_from_key(key): if self._debug: self.debug_log("Incoming priority %s is lower than an existing " "stack item with the same key %s. Not adding to " "stack.", priority, key) return if self.stack and self._debug and priority == self.stack[0].priority and key != self.stack[0].key: self.debug_log("Light stack contains two entries with the same priority %s but different keys: %s", priority, self.stack) if fade_ms: dest_time = start_time + (fade_ms / 1000) color_below = self.get_color_below(priority, key) else: dest_time = 0 color_below = None if self.stack: self._remove_from_stack_by_key(key) self.stack.append(LightStackEntry(priority, key, start_time, color_below, dest_time, color)) if len(self.stack) > 1: self.stack.sort(reverse=True) if self._debug: self.debug_log("+-------------- Adding to stack ----------------+") self.debug_log("priority: %s", priority) self.debug_log("start_time: %s", self.machine.clock.get_time()) self.debug_log("start_color: %s", color_below) self.debug_log("dest_time: %s", dest_time) self.debug_log("dest_color: %s", color) self.debug_log("key: %s", key) def remove_from_stack_by_key(self, key, fade_ms=None): """Remove a group of color settings from the stack. Args: key: The key of the settings to remove (based on the 'key' parameter that was originally passed to the color() method.) fade_ms: Time to fade out the light. This method triggers a light update, so if the highest priority settings 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. """ if not self.stack: # no stack return if fade_ms is None: fade_ms = self.default_fade_ms key = str(key) priority = None color_changes = True stack = [] for i, entry in enumerate(self.stack): if entry.key == key: stack = self.stack[i:] priority = entry.priority break elif entry.dest_color is not None: # no transparency above key color_changes = False # key not in stack if not stack: return # this is already a fadeout. do not fade out the fade out. if stack[0].dest_color is None: fade_ms = None if fade_ms: # fade to underlaying color color_of_key = self._get_color_and_fade(stack, 0)[0] self._remove_from_stack_by_key(key) start_time = self.machine.clock.get_time() self.stack.append(LightStackEntry(priority, key, start_time, color_of_key, start_time + fade_ms / 1000.0, None)) self.delay.reset(ms=fade_ms, callback=partial(self._remove_fade_out, key=key), name="remove_fade_{}".format(key)) if len(self.stack) > 1: self.stack.sort(reverse=True) else: # no fade -> just remove color from stack self._remove_from_stack_by_key(key) if color_changes: self._schedule_update() def _remove_fade_out(self, key): """Remove a timed out fade out.""" if not self.stack: return found = False color_change = True for _, entry in enumerate(self.stack): if entry.key == key and entry.dest_color is None: found = True break elif entry.dest_color is not None: # found entry above the removed which is non-transparent color_change = False if found: if self._debug: self.debug_log("Removing fadeout for key '%s' from stack", key) self.stack = [x for x in self.stack if x.key != key or x.dest_color is not None] if found and color_change: self._schedule_update() def _remove_from_stack_by_key(self, key): """Remove a key from stack.""" # tune the common case if not self.stack: return if self._debug: self.debug_log("Removing key '%s' from stack", key) if len(self.stack) == 1: if self.stack[0].key == key: self.stack = [] else: self.stack = [x for x in self.stack if x.key != key] def _schedule_update(self): start_color, start_time, target_color, target_time = self._get_color_and_target_time(self.stack) # check if our fade target really changed if (start_color, start_time, target_color, target_time) == self._last_fade_target: # nope its the same -> nothing to do return if self._last_fade_target and target_color == self._last_fade_target[2] and \ (self._last_fade_target[3] < 0 or self._last_fade_target[3] < self.machine.clock.get_time()): # last fade had the same target and finished already -> nothing to do return self._last_fade_target = (start_color, start_time, target_color, target_time) if start_color != target_color: start_color = self.color_correct(self.gamma_correct(start_color)) target_color = self.color_correct(self.gamma_correct(target_color)) else: start_color = self.color_correct(self.gamma_correct(start_color)) target_color = start_color for color, drivers in self.hw_drivers.items(): if color in ["red", "blue", "green"]: start_brightness = getattr(start_color, color) / 255.0 target_brightness = getattr(target_color, color) / 255.0 elif color == "white": start_brightness = min(start_color.red, start_color.green, start_color.blue) / 255.0 target_brightness = min(target_color.red, target_color.green, target_color.blue) / 255.0 else: raise ColorException("Invalid color {}".format(color)) for driver in drivers: driver.set_fade(start_brightness, start_time, target_brightness, target_time) for platform in self.platforms: platform.light_sync() def clear_stack(self): """Remove all entries from the stack and resets this light to 'off'.""" self.stack = [] if self._debug: self.debug_log("Clearing Stack") self._schedule_update() def _get_priority_from_key(self, key): if not self.stack: return 0 if self.stack[0].key == key: return self.stack[0].priority try: return [x for x in self.stack if x.key == key][0].priority except IndexError: return 0 def gamma_correct(self, color): """Apply max brightness correction to color. Args: color: The RGBColor() instance you want to have gamma applied. Returns an updated RGBColor() instance with gamma corrected. """ factor = self.machine.light_controller.brightness_factor if factor == 1.0: return color return RGBColor([int(x * factor) for x in color]) def color_correct(self, color): """Apply the current color correction profile to the color passed. Args: color: The RGBColor() instance you want to get color corrected. Returns an updated RGBColor() instance with the current color correction profile applied. Note that if there is no current color correction profile applied, the returned color will be the same as the color that was passed. """ if self._color_correction_profile is None: return color if self._debug: self.debug_log("Applying color correction: %s (applied " "'%s' color correction profile)", self._color_correction_profile.apply(color), self._color_correction_profile.name) return self._color_correction_profile.apply(color) def _get_color_and_target_time(self, stack) -> Tuple[RGBColor, int, RGBColor, int]: try: color_settings = stack[0] except IndexError: # no stack return self._off_color, -1, self._off_color, -1 dest_color = color_settings.dest_color dest_time = color_settings.dest_time # no fade if not dest_time: # if we are transparent just return the lower layer if dest_color is None: return self._get_color_and_target_time(stack[1:]) return dest_color, -1, dest_color, -1 # fade out if dest_color is None: _, _, lower_dest_color, lower_dest_time = self._get_color_and_target_time(stack[1:]) start_time = color_settings.start_time if lower_dest_time < 0: # no fade going on below current layer dest_color = lower_dest_color elif start_time < lower_dest_time < dest_time: # fade below is shorter than fade out. removing the fade will trigger a new fade in this case ratio = (lower_dest_time - dest_time) / (dest_time - start_time) dest_color = RGBColor.blend(color_settings.start_color, dest_color, ratio) dest_time = lower_dest_time else: # upper fade is longer. use color target below. this might be slightly inaccurate dest_color = lower_dest_color # return destination color and time return color_settings.start_color, color_settings.start_time, dest_color, dest_time # pylint: disable-msg=too-many-return-statements def _get_color_and_fade(self, stack, max_fade_ms: int, *, current_time=None) -> Tuple[RGBColor, int, bool]: try: color_settings = stack[0] except IndexError: # no stack return self._off_color, -1, True dest_color = color_settings.dest_color # no fade if not color_settings.dest_time: # if we are transparent just return the lower layer if dest_color is None: return self._get_color_and_fade(stack[1:], max_fade_ms) return dest_color, -1, True if current_time is None: current_time = self.machine.clock.get_time() # fade is done if current_time >= color_settings.dest_time: # if we are transparent just return the lower layer if dest_color is None: return self._get_color_and_fade(stack[1:], max_fade_ms) return color_settings.dest_color, -1, True if dest_color is None: dest_color, lower_fade_ms, _ = self._get_color_and_fade(stack[1:], max_fade_ms) if lower_fade_ms > 0: max_fade_ms = lower_fade_ms 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 dest_color, int((color_settings.dest_time - current_time) * 1000), True # figure out the ratio of how far along we are try: ratio = ((target_time - color_settings.start_time) / (color_settings.dest_time - color_settings.start_time)) except ZeroDivisionError: ratio = 1.0 return RGBColor.blend(color_settings.start_color, dest_color, ratio), max_fade_ms, False def _get_brightness_and_fade(self, max_fade_ms: int, color: str, *, current_time=None) -> Tuple[float, int, bool]: uncorrected_color, fade_ms, done = self._get_color_and_fade(self.stack, max_fade_ms, current_time=current_time) corrected_color = self.gamma_correct(uncorrected_color) corrected_color = self.color_correct(corrected_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: raise ColorException("Invalid color {}".format(color)) return brightness, fade_ms, done @property def _color(self): """Getter for color.""" return self.get_color() def get_color_below(self, priority, key): """Return an RGBColor() instance of the 'color' setting of the highest color below a certain key. Similar to get_color. """ if not self.stack: # no stack -> we are black return self._off_color if self.stack[0].key == key and self.stack[0].priority == priority: # fast path for resetting the top element return self._get_color_and_fade(self.stack, 0)[0] stack = [] for i, entry in enumerate(self.stack): if entry.priority <= priority and entry.key <= key: stack = self.stack[i:] break return self._get_color_and_fade(stack, 0)[0] 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(self.stack, 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())
class Servo(SystemWideDevice): """Represents a servo in a pinball machine. Args: Same as the Device parent class. """ config_section = 'servos' collection = 'servos' class_label = 'servo' def __init__(self, machine, name): """Initialise servo.""" self.hw_servo = None self.platform = None # type: ServoPlatform self._position = None self.speed_limit = None self.acceleration_limit = None self._ball_search_started = False self.delay = DelayManager(machine) super().__init__(machine, name) async def _initialize(self): await super()._initialize() self.platform = self.machine.get_platform_sections( 'servo_controllers', self.config['platform']) self.platform.assert_has_feature("servos") for position in self.config['positions']: self.machine.events.add_handler(self.config['positions'][position], self._position_event, position=position) if not self.platform.features[ 'allow_empty_numbers'] and self.config['number'] is None: self.raise_config_error("Servo must have a number.", 1) self.hw_servo = await self.platform.configure_servo( self.config['number']) self._position = self.config['reset_position'] self.speed_limit = self.config['speed_limit'] self.acceleration_limit = self.config['acceleration_limit'] if self.config['include_in_ball_search']: self.machine.events.add_handler("ball_search_started", self._ball_search_start) self.machine.events.add_handler("ball_search_stopped", self._ball_search_stop) self.set_speed_limit(self.speed_limit) self.set_acceleration_limit(self.acceleration_limit) self.machine.events.add_handler("shutdown", self.event_stop) @event_handler(1) def event_reset(self, **kwargs): """Event handler for reset event.""" del kwargs self.reset() def reset(self): """Go to reset position.""" self.go_to_position(self.config['reset_position']) @event_handler(10) def event_stop(self, **kwargs): """Event handler for stop event.""" del kwargs self.stop() def stop(self): """Stop this servo. This should either home the servo or disable the output. """ self.debug_log("Stopping servo") self.hw_servo.stop() @event_handler(5) def _position_event(self, position, **kwargs): del kwargs self.go_to_position(position) def go_to_position(self, position): """Move servo to position.""" self._position = position if self._ball_search_started: return self._go_to_position(position) def _go_to_position(self, position): # linearly interpolate between servo limits corrected_position = self.config['servo_min'] + position * ( self.config['servo_max'] - self.config['servo_min']) self.debug_log("Moving to position %s (corrected: %s)", position, corrected_position) # call platform with calculated position self.hw_servo.go_to_position(corrected_position) if self.config["stop_timeout_after_last_move"] is not None: self.delay.reset(self.config["stop_timeout_after_last_move"], self.stop, "movement_timeout") def set_speed_limit(self, speed_limit): """Set speed parameter.""" self.hw_servo.set_speed_limit(speed_limit) def set_acceleration_limit(self, acceleration_limit): """Set acceleration parameter.""" self.hw_servo.set_acceleration_limit(acceleration_limit) def _ball_search_start(self, **kwargs): del kwargs # we do not touch self._position during ball search so we can reset to # it later self._ball_search_started = True self._ball_search_go_to_min() def _ball_search_go_to_min(self): self._go_to_position(self.config['ball_search_min']) self.delay.add(name="ball_search", callback=self._ball_search_go_to_max, ms=self.config['ball_search_wait']) def _ball_search_go_to_max(self): self._go_to_position(self.config['ball_search_max']) self.delay.add(name="ball_search", callback=self._ball_search_go_to_min, ms=self.config['ball_search_wait']) def _ball_search_stop(self, **kwargs): del kwargs # stop delay self.delay.remove("ball_search") self._ball_search_started = False # move to last position set self._go_to_position(self._position)