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
Exemple #2
0
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
Exemple #3
0
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
Exemple #4
0
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
Exemple #5
0
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
Exemple #6
0
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
Exemple #7
0
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))
Exemple #8
0
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)
Exemple #9
0
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
Exemple #10
0
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'])
Exemple #11
0
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'])
Exemple #12
0
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()
Exemple #13
0
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'])
Exemple #15
0
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))
Exemple #16
0
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())
Exemple #17
0
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)