Example #1
0
class PKONEHardwarePlatform(SwitchPlatform, DriverPlatform, LightsPlatform,
                            ServoPlatform):
    """Platform class for the PKONE Nano hardware controller.

    Args:
    ----
        machine: The MachineController instance.
    """

    __slots__ = [
        "config", "serial_connections", "pkone_extensions", "pkone_lightshows",
        "_light_system", "_watchdog_task", "hw_switch_data",
        "controller_connection", "pkone_commands"
    ]

    def __init__(self, machine) -> None:
        """Initialize PKONE platform."""
        super().__init__(machine)
        self.controller_connection = None
        self.serial_connections = set()  # type: Set[PKONESerialCommunicator]
        self.pkone_extensions = {}  # type: Dict[int, PKONEExtensionBoard]
        self.pkone_lightshows = {}  # type: Dict[int, PKONELightshowBoard]
        self._light_system = None  # type: Optional[PlatformBatchLightSystem]
        self._watchdog_task = None
        self.hw_switch_data = dict()

        self.pkone_commands = {
            'PCN': lambda x, y: None,  # connected Nano processor
            'PCB': lambda x, y: None,  # connected board
            'PWD': lambda x, y: None,  # watchdog
            'PWF': lambda x, y: None,  # watchdog stop
            'PSA': self.receive_all_switches,  # all switch states
            'PSW': self.receive_switch,  # switch state change
            'PXX': self.receive_error,  # error
        }

        # Set platform features. Each platform interface can change
        # these to notify the framework of the specific features it supports.
        self.features['max_pulse'] = 250
        self.features['tickless'] = True
        self.features['allow_empty_numbers'] = False

        self.config = self.machine.config_validator.validate_config(
            "pkone", self.machine.config['pkone'])
        self._configure_device_logging_and_debug("PKONE", self.config)
        self.debug_log("Configuring PKONE hardware.")

    async def initialize(self):
        """Initialize connection to PKONE Nano hardware."""
        await self._connect_to_hardware()

        # Setup the batch light system
        self._light_system = PlatformBatchLightSystem(
            self.machine.clock, self._send_multiple_light_update,
            self.machine.config['mpf']['default_light_hw_update_hz'], 64)

    def stop(self):
        """Stop platform and close connections."""
        if self._light_system:
            self._light_system.stop()
        if self.controller_connection:
            # send reset message to turn off all lights, disable all drivers, stop the watchdog process, etc.
            self.controller_connection.send('PRS')

        if self._watchdog_task:
            self._watchdog_task.cancel()
            self._watchdog_task = None

        # wait 100ms for the messages to be send
        self.machine.clock.loop.run_until_complete(asyncio.sleep(.1))

        if self.controller_connection:
            self.controller_connection.stop()
            self.controller_connection = None

        self.serial_connections = set()

    async def start(self):
        """Start listening for commands and schedule watchdog."""
        if self.config['watchdog']:
            # Configure the watchdog timeout interval and start it
            self.controller_connection.send('PWS{:04d}'.format(
                self.config['watchdog']))

            # Schedule the watchdog task to send at half the configured interval
            self._watchdog_task = self.machine.clock.schedule_interval(
                self._update_watchdog, self.config['watchdog'] / 2000)

        for connection in self.serial_connections:
            await connection.start_read_loop()

        self._initialize_led_hw_driver_alignment()
        self._light_system.start()

    def _update_watchdog(self):
        """Send Watchdog ping command."""
        self.controller_connection.send('PWD')

    def get_info_string(self):
        """Dump infos about boards."""
        if not self.serial_connections:
            return "No connection to any Penny K Pinball PKONE controller board."

        infos = "Penny K Pinball Hardware\n"
        infos += "------------------------\n"
        infos += " - Connected Controllers:\n"
        for connection in sorted(self.serial_connections,
                                 key=lambda x: x.port):
            infos += "   -> PKONE Nano - Port: {} at {} baud " \
                     "(firmware v{}, hardware rev {}).\n".format(connection.port,
                                                                 connection.baud,
                                                                 connection.remote_firmware,
                                                                 connection.remote_hardware_rev)

        infos += "\n - Extension boards:\n"
        for extension in self.pkone_extensions.values():
            infos += "   -> Address ID: {} (firmware v{}, hardware rev {})\n".format(
                extension.addr, extension.firmware_version,
                extension.hardware_rev)

        infos += "\n - Lightshow boards:\n"
        for lightshow in self.pkone_lightshows.values():
            infos += "   -> Address ID: {} ({} firmware v{}, " \
                     "hardware rev {})\n".format(lightshow.addr,
                                                 'RGBW' if lightshow.rgbw_firmware else 'RGB',
                                                 lightshow.firmware_version,
                                                 lightshow.hardware_rev)

        return infos

    async def _connect_to_hardware(self):
        """Connect to the port in the config."""
        comm = PKONESerialCommunicator(platform=self,
                                       port=self.config['port'],
                                       baud=self.config['baud'])
        await comm.connect()
        self.serial_connections.add(comm)

    def register_extension_board(self, board: PKONEExtensionBoard):
        """Register an Extension board."""
        if board.addr in self.pkone_extensions or board.addr in self.pkone_lightshows:
            raise AssertionError(
                "Duplicate address id: a board has already been "
                "registered at address {}".format(board.addr))

        if board.addr not in range(8):
            raise AssertionError(
                "Address out of range: Extension board address id must be between 0 and 7"
            )

        self.pkone_extensions[board.addr] = board

    def register_lightshow_board(self, board: PKONELightshowBoard):
        """Register a Lightshow board."""
        if board.addr in self.pkone_extensions or board.addr in self.pkone_lightshows:
            raise AssertionError(
                "Duplicate address id: a board has already been "
                "registered at address {}".format(board.addr))

        if board.addr not in range(4):
            raise AssertionError(
                "Address out of range: Lightshow board address id must be between 0 and 3"
            )

        self.pkone_lightshows[board.addr] = board

    def process_received_message(self, msg: str):
        """Send an incoming message from the PKONE controller to the proper method for servicing.

        Args:
        ----
            msg: messaged which was received
        """
        assert self.log is not None
        cmd = msg[0:3]
        payload = msg[3:].replace('E', '')

        # Can't use try since it swallows too many errors for now
        if cmd in self.pkone_commands:
            self.pkone_commands[cmd](payload)
        else:  # pragma: no cover
            self.log.warning("Received unknown serial command %s.", msg)

    def receive_error(self, msg):
        """Receive an error message from the controller."""
        self.log.error("Received an error message from the controller: %s",
                       msg)

    def _parse_coil_number(self, number: str) -> PKONECoilNumber:
        try:
            board_id_str, coil_num_str = number.split("-")
        except ValueError:
            raise AssertionError("Invalid coil number {}".format(number))

        board_id = int(board_id_str)
        coil_num = int(coil_num_str)

        if board_id not in self.pkone_extensions:
            raise AssertionError(
                "PKONE Extension {} does not exist for coil {}".format(
                    board_id, number))

        if coil_num == 0:
            raise AssertionError(
                "PKONE coil numbering begins with 1. Coil: {}".format(number))

        coil_count = self.pkone_extensions[board_id].coil_count
        if coil_count < coil_num or coil_num < 1:
            raise AssertionError(
                "PKONE Extension {board_id} only has {coil_count} coils "
                "({first_coil} - {last_coil}). Coil: {number}".format(
                    board_id=board_id,
                    coil_count=coil_count,
                    first_coil=1,
                    last_coil=coil_count,
                    number=number))

        return PKONECoilNumber(board_id, coil_num)

    def configure_driver(self, config: DriverConfig, number: str,
                         platform_settings: dict) -> PKONECoil:
        """Configure a coil/driver.

        Args:
        ----
            config: Coil/driver config.
            number: Number of this coil/driver.
            platform_settings: Platform specific settings.

        Returns: Coil/driver object
        """
        # dont modify the config. make a copy
        platform_settings = deepcopy(platform_settings)

        if not self.controller_connection:
            raise AssertionError(
                'A request was made to configure a PKONE coil, but no '
                'connection to a PKONE controller is available')

        if not number:
            raise AssertionError("Coil number is required")

        coil_number = self._parse_coil_number(str(number))
        return PKONECoil(config, self, coil_number, platform_settings)

    @staticmethod
    def _check_coil_switch_combination(coil: DriverSettings,
                                       switch: SwitchSettings):
        """Check to see if the coil/switch combination is legal for hardware rules."""
        # coil and switch must be on the same extension board (same board address id)
        if switch.hw_switch.number.board_address_id != coil.hw_driver.number.board_address_id:
            raise AssertionError(
                "Coil {} and switch {} are on different boards. Cannot apply hardware rule!"
                .format(coil.hw_driver.number, switch.hw_switch.number))

    def clear_hw_rule(self, switch: SwitchSettings, coil: DriverSettings):
        """Clear a hardware rule.

        This is used if you want to remove the linkage between a switch and
        some coil activity. For example, if you wanted to disable your
        flippers (so that a player pushing the flipper buttons wouldn't cause
        the flippers to flip), you'd call this method with your flipper button
        as the *sw_num*.

        Args:
        ----
            switch: The switch whose rule you want to clear.
            coil: The coil whose rule you want to clear.
        """
        self.debug_log("Clearing Hardware Rule for coil: %s, switch: %s",
                       coil.hw_driver.number, switch.hw_switch.number)
        driver = coil.hw_driver
        driver.clear_hardware_rule()

    def set_pulse_on_hit_rule(self, enable_switch: SwitchSettings,
                              coil: DriverSettings):
        """Set pulse on hit rule on driver.

        Pulses a driver when a switch is hit. When the switch is released the pulse continues. Typically used for
        autofire coils such as pop bumpers.
        """
        self._check_coil_switch_combination(coil, enable_switch)
        driver = coil.hw_driver
        driver.set_hardware_rule(1, enable_switch, None, 0,
                                 coil.pulse_settings, None)

    def set_delayed_pulse_on_hit_rule(self, enable_switch: SwitchSettings,
                                      coil: DriverSettings, delay_ms: int):
        """Set pulse on hit and release rule to driver.

        When a switch is hit and a certain delay passed it pulses a driver.
        When the switch is released the pulse continues.
        Typically used for kickbacks.
        """
        self._check_coil_switch_combination(coil, enable_switch)
        driver = coil.hw_driver
        driver.set_hardware_rule(2, enable_switch, None, delay_ms,
                                 coil.pulse_settings, None)

    def set_pulse_on_hit_and_release_rule(self, enable_switch: SwitchSettings,
                                          coil: DriverSettings):
        """Set pulse on hit and release rule to driver.

        Pulses a driver when a switch is hit. When the switch is released the pulse is canceled. Typically used on
        the main coil for dual coil flippers without eos switch.
        """
        self._check_coil_switch_combination(coil, enable_switch)
        driver = coil.hw_driver
        driver.set_hardware_rule(3, enable_switch, None, 0,
                                 coil.pulse_settings, coil.hold_settings)

    def set_pulse_on_hit_and_enable_and_release_rule(
            self, enable_switch: SwitchSettings, coil: DriverSettings):
        """Set pulse on hit and enable and release rule on driver.

        Pulses a driver when a switch is hit. Then enables the driver (may be with pwm). When the switch is released
        the pulse is canceled and the driver gets disabled. Typically used for single coil flippers.
        """
        self._check_coil_switch_combination(coil, enable_switch)
        driver = coil.hw_driver
        driver.set_hardware_rule(4, enable_switch, None, 0,
                                 coil.pulse_settings, coil.hold_settings)

    def set_pulse_on_hit_and_release_and_disable_rule(
            self, enable_switch: SwitchSettings, eos_switch: SwitchSettings,
            coil: DriverSettings, repulse_settings: Optional[RepulseSettings]):
        """Set pulse on hit and enable and release and disable rule on driver.

        Pulses a driver when a switch is hit. When the switch is released
        the pulse is canceled and the driver gets disabled. When the eos_switch is hit the pulse is canceled
        and the driver becomes disabled. Typically used on the main coil for dual-wound coil flippers with eos switch.
        """
        del repulse_settings
        self._check_coil_switch_combination(coil, enable_switch)
        self._check_coil_switch_combination(coil, eos_switch)
        driver = coil.hw_driver
        driver.set_hardware_rule(5, enable_switch, eos_switch, 0,
                                 coil.pulse_settings, coil.hold_settings)

    def set_pulse_on_hit_and_enable_and_release_and_disable_rule(
            self, enable_switch: SwitchSettings, eos_switch: SwitchSettings,
            coil: DriverSettings, repulse_settings: Optional[RepulseSettings]):
        """Set pulse on hit and enable and release and disable rule on driver.

        Pulses a driver when a switch is hit. Then enables the driver (may be with pwm). When the switch is released
        the pulse is canceled and the driver becomes disabled. When the eos_switch is hit the pulse is canceled
        and the driver becomes enabled (likely with PWM).
        Typically used on the coil for single-wound coil flippers with eos switch.
        """
        raise AssertionError(
            "Single-wound coils with EOS are not implemented in PKONE hardware."
        )

    def _parse_servo_number(self, number: str) -> PKONEServoNumber:
        try:
            board_id_str, servo_num_str = number.split("-")
        except ValueError:
            raise AssertionError("Invalid servo number {}".format(number))

        board_id = int(board_id_str)
        servo_num = int(servo_num_str)

        if board_id not in self.pkone_extensions:
            raise AssertionError(
                "PKONE Extension {} does not exist for servo {}".format(
                    board_id, number))

        # Servos are numbered in sequence immediately after the highest coil number
        driver_count = self.pkone_extensions[board_id].coil_count
        servo_count = self.pkone_extensions[board_id].servo_count
        if servo_num <= driver_count or servo_num > driver_count + servo_count:
            raise AssertionError(
                "PKONE Extension {} supports {} servos ({} - {}). "
                "Servo: {} is not a valid number.".format(
                    board_id, servo_count, driver_count + 1,
                    driver_count + servo_count, number))

        return PKONEServoNumber(board_id, servo_num)

    async def configure_servo(self, number: str) -> PKONEServo:
        """Configure a servo."""
        servo_number = self._parse_servo_number(str(number))
        return PKONEServo(servo_number, self.controller_connection)

    @classmethod
    def get_coil_config_section(cls):
        """Return coil config section."""
        return "pkone_coils"

    def _parse_switch_number(self, number: str) -> PKONESwitchNumber:
        try:
            board_id_str, switch_num_str = number.split("-")
        except ValueError:
            raise AssertionError("Invalid switch number {}".format(number))

        board_id = int(board_id_str)
        switch_num = int(switch_num_str)

        if board_id not in self.pkone_extensions:
            raise AssertionError(
                "PKONE Extension {} does not exist for switch {}".format(
                    board_id, number))

        if switch_num == 0:
            raise AssertionError(
                "PKONE switch numbering begins with 1. Switch: {}".format(
                    number))

        if self.pkone_extensions[board_id].switch_count < switch_num:
            raise AssertionError(
                "PKONE Extension {} only has {} switches. Switch: {}".format(
                    board_id, self.pkone_extensions[board_id].switch_count,
                    number))

        return PKONESwitchNumber(board_id, switch_num)

    def configure_switch(self, number: str, config: SwitchConfig,
                         platform_config: dict) -> PKONESwitch:
        """Configure the switch object for a PKONE controller.

        Args:
        ----
            number: Number of this switch.
            config: Switch config.
            platform_config: Platform specific settings.

        Returns: Switch object.
        """
        del platform_config
        if not number:
            raise AssertionError("Switch requires a number")

        if not self.controller_connection:
            raise AssertionError(
                "A request was made to configure a PKONE switch, but no "
                "connection to PKONE controller is available")

        try:
            switch_number = self._parse_switch_number(number)
        except ValueError:
            raise AssertionError("Could not parse switch number {}/{}. Seems "
                                 "to be not a valid switch number for the"
                                 "PKONE platform.".format(config.name, number))

        self.debug_log("PKONE Switch: %s (%s)", number, config.name)
        return PKONESwitch(config, switch_number, self)

    async def get_hw_switch_states(self) -> Dict[str, bool]:
        """Return hardware states."""
        return self.hw_switch_data

    def receive_all_switches(self, msg):
        """Process the all switch states message."""
        # The PSA message contains the following information:
        # [PSA opcode] + [[board address id] + 0 or 1 for each switch on the board] + E
        self.debug_log("Received all switch states (PSA): %s", msg)

        # the message payload is delimited with an 'X' character for the switches on each board
        # The first character is the board address ID
        board_address_id = int(msg[0])
        switch_states = msg[1:]

        # There is one character for each switch on the board (1 = active, 0 = inactive)
        # Loop over each character and map the state to the appropriate switch number
        for index, state in enumerate(switch_states):
            self.hw_switch_data[PKONESwitchNumber(
                board_address_id=board_address_id,
                switch_number=index + 1)] = int(state)

    def receive_switch(self, msg):
        """Process a single switch state change."""
        # The PSW message contains the following information:
        # [PSW opcode] + [board address id] + switch number + switch state (0 or 1) + E
        self.debug_log("Received switch state change (PSW): %s", msg)
        switch_number = PKONESwitchNumber(int(msg[0]), int(msg[1:3]))
        switch_state = int(msg[-1])
        self.machine.switch_controller.process_switch_by_num(
            state=switch_state, num=switch_number, platform=self)

    # pylint: disable-msg=too-many-locals
    async def _send_multiple_light_update(
            self, sequential_brightness_list: List[Tuple[PKONELEDChannel,
                                                         float, int]]):
        # determine how many channels are to be updated and the common fade time
        first_channel, _, common_fade_ms = sequential_brightness_list[0]
        start_index = first_channel.index

        # get the lightshow board that will be sent the command (need to know if rgb or rgbw board)
        lightshow = self.pkone_lightshows[first_channel.board_address_id]
        if lightshow.rgbw_firmware:
            cmd_opcode = "PWB"
            channel_grouping = 4
        else:
            cmd_opcode = "PLB"
            channel_grouping = 3

        # determine if first and last batch channels are properly aligned to internal LED boundaries
        first_channel_alignment_offset = first_channel.index % channel_grouping
        last_channel_alignment_offset = (
            first_channel.index +
            len(sequential_brightness_list)) % channel_grouping

        # Note: software fading will automatically be used when batch channels
        # are not aligned to hardware LED boundaries

        if first_channel_alignment_offset > 0:
            # the first channel does not align with internal hardware boundary, need to retrieve other
            # channels for the first light
            current_time = self._light_system.clock.get_time()
            channel = first_channel
            previous_channel = None
            for _ in range(first_channel_alignment_offset):
                if channel:
                    previous_channel = lightshow.get_channel_hw_driver(
                        first_channel.group, channel.get_predecessor_number())
                if previous_channel:
                    brightness, fade_ms, _ = previous_channel.get_fade_and_brightness(
                        current_time)
                    channel = previous_channel
                else:
                    brightness = 0.0
                    fade_ms = 0
                    channel = None

                sequential_brightness_list.insert(
                    0, (channel, brightness, fade_ms))
                start_index = start_index - 1

        if last_channel_alignment_offset > 0:
            current_time = self._light_system.clock.get_time()
            channel = sequential_brightness_list[-1][0]
            next_channel = None
            for _ in range(channel_grouping - last_channel_alignment_offset):
                if channel:
                    next_channel = lightshow.get_channel_hw_driver(
                        first_channel.group, channel.get_successor_number())
                if next_channel:
                    brightness, fade_ms, _ = next_channel.get_fade_and_brightness(
                        current_time)
                    channel = next_channel
                else:
                    brightness = 0.0
                    fade_ms = 0
                    channel = None

                sequential_brightness_list.append(
                    (channel, brightness, fade_ms))

        assert start_index % channel_grouping == 0
        assert len(sequential_brightness_list) % channel_grouping == 0

        # generate batch update command using brightness values (3 digit int values)
        # Note: fade time is in 10ms units
        cmd = "{}{}{}{:02d}{:02d}{:04d}{}".format(
            cmd_opcode, first_channel.board_address_id, first_channel.group,
            start_index // channel_grouping + 1,
            len(sequential_brightness_list) // channel_grouping,
            int(common_fade_ms / 10),
            "".join("%03d" % (b[1] * 255) for b in sequential_brightness_list))
        self.controller_connection.send(cmd)

    def configure_light(self, number, subtype, config, platform_settings):
        """Configure light in platform."""
        del platform_settings

        if not self.controller_connection:
            raise AssertionError(
                "A request was made to configure a PKONE light, but no "
                "connection to PKONE controller is available")

        if subtype == "simple":
            # simple LEDs use the format <board_address_id> - <led> (simple LEDs only have 1 channel)
            board_address_id, index = number.split('-')
            return PKONESimpleLED(
                PKONESimpleLEDNumber(int(board_address_id), int(index)),
                self.controller_connection.send, self)

        if not subtype or subtype == "led":
            board_address_id, group, index = number.split("-")
            led_channel = PKONELEDChannel(board_address_id, group, index,
                                          config, self._light_system)
            lightshow = self.pkone_lightshows[int(board_address_id)]
            lightshow.add_channel_hw_driver(int(group), led_channel)
            self._light_system.mark_dirty(led_channel)
            return led_channel

        raise AssertionError("Unknown subtype {}".format(subtype))

    def _led_is_hardware_aligned(self, led_name) -> bool:
        """Determine whether the specified LED is hardware aligned."""
        light = self.machine.lights[led_name]
        hw_numbers = light.get_hw_numbers()
        board_address_id, _, index = hw_numbers[0].split("-")
        lightshow = self.pkone_lightshows[int(board_address_id)]
        if lightshow.rgbw_firmware:
            channel_grouping = 4
        else:
            channel_grouping = 3
        if len(hw_numbers) != channel_grouping:
            return False
        return int(index) % channel_grouping == 0

    def _initialize_led_hw_driver_alignment(self):
        """Set hardware aligned flag for all led hardware driver channels."""
        for _, lightshow in self.pkone_lightshows.items():
            for channel in lightshow.get_all_channel_hw_drivers():
                channel.set_hardware_aligned(
                    self._led_is_hardware_aligned(channel.config.name))

    def parse_light_number_to_channels(self, number: str, subtype: str):
        """Parse light channels from number string."""
        if subtype == "simple":
            # simple LEDs use the format <board_address_id> - <led> (simple LEDs only have 1 channel)
            board_address_id, index = number.split('-')
            return [{"number": "{}-{}".format(board_address_id, index)}]

        if not subtype or subtype == "led":
            # Normal LED number format: <board_address_id> - <group> - <led>
            board_address_id, group, number_str = str(number).split('-')
            index = int(number_str)

            # Determine if there are 3 or 4 channels depending upon firmware on board
            if self.pkone_lightshows[board_address_id].rgbw_firmware:
                # rgbw uses 4 channels per led
                return [
                    {
                        "number":
                        "{}-{}-{}".format(board_address_id, group,
                                          (index - 1) * 4)
                    },
                    {
                        "number":
                        "{}-{}-{}".format(board_address_id, group,
                                          (index - 1) * 4 + 1)
                    },
                    {
                        "number":
                        "{}-{}-{}".format(board_address_id, group,
                                          (index - 1) * 4 + 2)
                    },
                    {
                        "number":
                        "{}-{}-{}".format(board_address_id, group,
                                          (index - 1) * 4 + 3)
                    },
                ]

            # rgb uses 3 channels per led
            return [
                {
                    "number":
                    "{}-{}-{}".format(board_address_id, group, (index - 1) * 3)
                },
                {
                    "number":
                    "{}-{}-{}".format(board_address_id, group,
                                      (index - 1) * 3 + 1)
                },
                {
                    "number":
                    "{}-{}-{}".format(board_address_id, group,
                                      (index - 1) * 3 + 2)
                },
            ]

        raise AssertionError("Unknown light subtype {}".format(subtype))
Example #2
0
class SpikePlatform(SwitchPlatform, LightsPlatform, DriverPlatform,
                    DmdPlatform, StepperPlatform):
    """Stern Spike Platform."""

    __slots__ = [
        "_writer", "_reader", "_inputs", "config", "_poll_task",
        "_sender_task", "_send_key_task", "dmd", "_nodes", "_bus_read",
        "_bus_write", "_cmd_queue", "ticks_per_sec", "_light_system"
    ]

    def __init__(self, machine):
        """Initialise spike hardware platform."""
        super().__init__(machine)
        self._writer = None
        self._reader = None
        self._inputs = {}
        self._poll_task = None
        self._sender_task = None
        self._send_key_task = None
        self.dmd = None

        self._nodes = None
        self._bus_read = asyncio.Lock(loop=self.machine.clock.loop)
        self._bus_write = asyncio.Lock(loop=self.machine.clock.loop)
        self._cmd_queue = asyncio.Queue(loop=self.machine.clock.loop)

        self._light_system = None

        self.ticks_per_sec = {0: 1}

        self.config = self.machine.config_validator.validate_config(
            "spike", self.machine.config['spike'])
        self._configure_device_logging_and_debug("Spike", self.config)

    @asyncio.coroutine
    def _send_multiple_light_update(self, sequential_brightness_list):
        common_fade_ms = sequential_brightness_list[0][2]
        if common_fade_ms < 0:
            common_fade_ms = 0
        fade_time = int(
            common_fade_ms *
            self.ticks_per_sec[sequential_brightness_list[0][0].node] / 1000)

        data = bytearray([fade_time])
        for _, brightness, _ in sequential_brightness_list:
            data.append(int(255 * brightness))

        self.send_cmd_async(
            sequential_brightness_list[0][0].node,
            SpikeNodebus.SetLed + sequential_brightness_list[0][0].index, data)

    @staticmethod
    def _light_key(light: SpikeLight):
        """Sort lights by this key."""
        return light.node * 100 + light.index

    @staticmethod
    def _are_lights_sequential(a, b):
        """Return True if lights are sequential."""
        return a.node == b.node and a.index + 1 == b.index

    # pylint: disable-msg=too-many-arguments
    def _write_rule(self, node, enable_switch_index, disable_switch_index,
                    coil_index, pulse_settings: PulseSettings,
                    hold_settings: Optional[HoldSettings], param1, param2,
                    param3):
        """Write a hardware rule to Stern Spike.

        We do not yet understand param1, param2 and param3:
        param1 == 2 -> second switch (eos switch)
        param2 == 1 -> ??
        param2 == 6 -> ??
        param3 == 5 -> allow enable
        """
        pulse_value = int(pulse_settings.duration * self.ticks_per_sec[node] /
                          1000)

        self.send_cmd_async(
            node,
            SpikeNodebus.CoilSetReflex,
            bytearray([
                coil_index,  # coil [3]
                int(pulse_settings.power * 255),  # pulse power [4]
                pulse_value & 0xFF,  # pulse time lower [5]
                (pulse_value & 0xFF00) >> 8,  # pulse time upper [6]
                int(hold_settings.power *
                    255) if hold_settings else 0,  # hold power [7]
                0x00,  # some time lower (probably hold) [8]
                0x00,  # some time upper (probably hold) [9]
                0x00,  # some time lower (unknown) [10]
                0x00,  # some time upper (unknown) [11]
                0,  # a unknown time lower (1) [12]
                0,  # a unknown time upper (1) [13]
                0,  # a unknown time lower (2) [14]
                0,  # a unknown time upper (2) [15]
                0,  # a unknown time lower (3) [16]
                0,  # a unknown time upper (3) [17]
                0,  # a unknown time lower (4) [18]
                0,  # a unknown time upper (4) [19]
                0x40 ^ enable_switch_index,  # enable switch [20]
                0x40 ^ disable_switch_index if disable_switch_index is not None
                else 0,  # disable switch [21]
                0,  # another switch? [22]
                param1,  # param5 [23]
                param2,  # some time (param6) [24]
                param3  # param7 [25]
            ]))

    @staticmethod
    def _check_coil_switch_combination(switch, coil):
        if switch.hw_switch.node != coil.hw_driver.node:
            raise AssertionError(
                "Coil {} and Switch {} need to be on the same node to write a rule"
                .format(coil, switch))

    def set_pulse_on_hit_rule(self, enable_switch: SwitchSettings,
                              coil: DriverSettings):
        """Set pulse on hit rule on driver.

        This is mostly used for popbumpers. Example from WWE:
        Type: 8 Cmd: 65 Node: 9 Msg: 0x00 0xa6 0x28 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x14 0x00 0x00 0x00 0x38
        0x00 0x40 0x00 0x00 0x00 0x00 0x00 Len: 25
        """
        self._check_coil_switch_combination(enable_switch, coil)
        self._write_rule(
            coil.hw_driver.node,
            enable_switch.hw_switch.index ^ (enable_switch.invert * 0x40),
            None, coil.hw_driver.index, coil.pulse_settings, None, 0, 0, 0)

    def set_pulse_on_hit_and_enable_and_release_rule(
            self, enable_switch: SwitchSettings, coil: DriverSettings):
        """Set pulse on hit and enable and relase rule on driver.

        Used for single coil flippers. Examples from WWE:
        Dual-wound flipper hold coil:
        Type: 8 Cmd: 65 Node: 8 Msg: 0x02 0xff 0x46 0x01 0xff 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x3a
        0x00 0x42 0x40 0x00 0x00 0x01 0x00  Len: 25

        Ring Slings (different flags):
        Type: 8 Cmd: 65 Node: 10 Msg: 0x00 0xff 0x19 0x00 0x14 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x80
        0x00 0x4a 0x40 0x00 0x00 0x06 0x05  Len: 25
        """
        self._check_coil_switch_combination(enable_switch, coil)
        self._write_rule(
            coil.hw_driver.node,
            enable_switch.hw_switch.index ^ (enable_switch.invert * 0x40),
            None, coil.hw_driver.index, coil.pulse_settings,
            coil.hold_settings, 0, 6, 5)

    def set_pulse_on_hit_and_enable_and_release_and_disable_rule(
            self, enable_switch: SwitchSettings,
            disable_switch: SwitchSettings, coil: DriverSettings):
        """Set pulse on hit and release rule to driver.

        Used for high-power coil on dual-wound flippers. Example from WWE:
        Type: 8 Cmd: 65 Node: 8 Msg: 0x00 0xff 0x33 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
        0x00 0x42 0x40 0x00 0x02 0x06 0x00  Len: 25
        """
        self._check_coil_switch_combination(enable_switch, coil)
        self._check_coil_switch_combination(disable_switch, coil)
        self._write_rule(
            coil.hw_driver.node,
            enable_switch.hw_switch.index ^ (enable_switch.invert * 0x40),
            disable_switch.hw_switch.index ^ (disable_switch.invert * 0x40),
            coil.hw_driver.index, coil.pulse_settings, coil.hold_settings, 2,
            6, 0)

    def set_pulse_on_hit_and_release_rule(self, enable_switch: SwitchSettings,
                                          coil: DriverSettings):
        """Set pulse on hit and release rule to driver.

        I believe that param2 == 1 means that it will cancel the pulse when the switch is released.

        Used for high-power coils on dual-wound flippers. Example from WWE:
        Type: 8 Cmd: 65 Node: 8 Msg: 0x03 0xff 0x46 0x01 0xff 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
        0x00 0x43 0x40 0x00 0x00 0x01 0x00  Len: 25

        """
        self._check_coil_switch_combination(enable_switch, coil)
        self._write_rule(
            coil.hw_driver.node,
            enable_switch.hw_switch.index ^ (enable_switch.invert * 0x40),
            None, coil.hw_driver.index, coil.pulse_settings, None, 0, 1, 0)

    def clear_hw_rule(self, switch, coil):
        """Disable hardware rule for this coil."""
        del switch
        self.send_cmd_async(
            coil.hw_driver.node, SpikeNodebus.CoilSetReflex,
            bytearray([
                coil.hw_driver.index, 0, 0, 0, 0, 0x00, 0x00, 0x00, 0x00, 0, 0,
                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
            ]))

    def configure_driver(self, config: DriverConfig, number: str,
                         platform_settings: dict):
        """Configure a driver on Stern Spike."""
        del platform_settings
        return SpikeDriver(config, number, self)

    def parse_light_number_to_channels(self, number: str, subtype: str):
        """Return a single light."""
        return [{
            "number": number,
        }]

    def configure_light(
            self, number, subtype,
            platform_settings) -> Union[SpikeLight, SpikeBacklight]:
        """Configure a light on Stern Spike."""
        del platform_settings, subtype
        if number == "0-0":
            return SpikeBacklight(number, self, self.machine.clock.loop, 3)

        # TODO: validate that light number is not used in a stepper and a light
        return SpikeLight(number, self, self._light_system)

    def configure_switch(self, number: str, config: SwitchConfig,
                         platform_config: dict):
        """Configure switch on Stern Spike."""
        del platform_config
        return SpikeSwitch(config, number, self)

    @asyncio.coroutine
    def get_hw_switch_states(self):
        """Return current switch states."""
        hw_states = dict()
        for node in self._nodes:
            curr_bit = 1
            for index in range(0, 64):
                hw_states[str(node) + '-' +
                          str(index)] = (curr_bit & self._inputs[node]) == 0
                curr_bit <<= 1
        return hw_states

    @asyncio.coroutine
    def initialize(self):
        """Initialise platform."""
        port = self.config['port']
        baud = self.config['baud']
        flow_control = self.config['flow_control']
        self.debug = self.config['debug']
        self._nodes = self.config['nodes']

        if 0 not in self._nodes:
            raise AssertionError(
                "Please include CPU node 0 in nodes for Spike.")

        yield from self._connect_to_hardware(port,
                                             baud,
                                             flow_control=flow_control)

        self._poll_task = self.machine.clock.loop.create_task(self._poll())
        self._poll_task.add_done_callback(self._done)

        self._sender_task = self.machine.clock.loop.create_task(self._sender())
        self._sender_task.add_done_callback(self._done)

        if self.config['use_send_key']:
            self._send_key_task = self.machine.clock.loop.create_task(
                self._send_key())
            self._send_key_task.add_done_callback(self._done)

        self._light_system = PlatformBatchLightSystem(
            self.machine.clock, self._light_key, self._are_lights_sequential,
            self._send_multiple_light_update,
            self.machine.machine_config['mpf']['default_light_hw_update_hz'],
            self.config['max_led_batch_size'])
        self._light_system.start()

    @asyncio.coroutine
    def _connect_to_hardware(self,
                             port,
                             baud,
                             *,
                             flow_control=False,
                             xonxoff=False):
        self.log.info("Connecting to %s at %sbps", port, baud)

        connector = self.machine.clock.open_serial_connection(
            url=port, baudrate=baud, rtscts=flow_control, xonxoff=xonxoff)
        self._reader, self._writer = yield from connector
        self._writer.transport.set_write_buffer_limits(2048, 1024)

        yield from self._initialize()

    @asyncio.coroutine
    def _update_switches(self, node):
        if node not in self._nodes:  # pragma: no cover
            self.log.warning(
                "Cannot read node %s because it is not configured.", node)
            return False

        new_inputs_str = yield from self._read_inputs(node)
        if not new_inputs_str:  # pragma: no cover
            self.log.info("Node: %s did not return any inputs.", node)
            return True

        new_inputs = self._input_to_int(new_inputs_str)

        if self.debug:
            self.log.debug("Inputs node: %s State: %s Old: %s New: %s", node,
                           "".join(bin(b) + " " for b in new_inputs_str[0:8]),
                           self._inputs[node], new_inputs)

        changes = self._inputs[node] ^ new_inputs
        if changes != 0:
            curr_bit = 1
            for index in range(0, 64):
                if (curr_bit & changes) != 0:
                    self.machine.switch_controller.process_switch_by_num(
                        state=(curr_bit & new_inputs) == 0,
                        num=str(node) + "-" + str(index),
                        platform=self)
                curr_bit <<= 1
        elif self.debug:  # pragma: no cover
            self.log.debug("Got input activity but inputs did not change.")

        self._inputs[node] = new_inputs

        return True

    @asyncio.coroutine
    def _sender(self):
        while True:
            cmd, wait_ms = yield from self._cmd_queue.get()
            yield from self._send_cmd_without_response(cmd, wait_ms)

    @asyncio.coroutine
    def _send_key(self):
        while True:
            for node in self._nodes:
                # do not send watchdog to cpu since it is a virtual node
                if node == 0:
                    continue

                # generate super secret "key"
                key = bytearray()
                for _ in range(16):
                    key.append(random.randint(0, 255))

                # send SendKey message
                yield from self.send_cmd_sync(node, SpikeNodebus.SendKey, key)

                # wait one second
                yield from asyncio.sleep(1, loop=self.machine.clock.loop)

    @asyncio.coroutine
    def _poll(self):
        while True:
            with (yield from self._bus_read):
                with (yield from self._bus_write):
                    yield from self._send_raw(bytearray([0]))

                try:
                    result = yield from asyncio.wait_for(
                        self._read_raw(1), 2, loop=self.machine.clock.loop)
                except asyncio.TimeoutError:  # pragma: no cover
                    self.log.warning("Spike watchdog expired.")
                    # clear buffer
                    # pylint: disable-msg=protected-access
                    self._reader._buffer = bytearray()
                    continue

            if not result:
                self.log.warning("Empty poll result. Spike desynced.")
                # give it a break of 50ms
                yield from asyncio.sleep(.05, loop=self.machine.clock.loop)
                # clear buffer
                # pylint: disable-msg=protected-access
                self._reader._buffer = bytearray()
                continue

            ready_node = result[0]

            if 0 < ready_node <= 0x0F or ready_node == 0xF0:
                # valid node ids
                if ready_node == 0xF0:
                    # virtual cpu node returns 0xF0 instead of 0 to make it distinguishable
                    ready_node = 0
                result = yield from self._update_switches(ready_node)
                if not result:
                    self.log.warning("Spike desynced during input.")
                    yield from asyncio.sleep(.05, loop=self.machine.clock.loop)
                    # clear buffer
                    # pylint: disable-msg=protected-access
                    self._reader._buffer = bytearray()
            elif ready_node > 0:  # pragma: no cover
                # invalid node ids
                self.log.warning("Spike desynced (invalid node %s).",
                                 ready_node)
                # give it a break of 50ms
                yield from asyncio.sleep(.05, loop=self.machine.clock.loop)
                # clear buffer
                # pylint: disable-msg=protected-access
                self._reader._buffer = bytearray()
            else:
                # sleep only if spike is idle
                yield from asyncio.sleep(1 / self.config['poll_hz'],
                                         loop=self.machine.clock.loop)

    def stop(self):
        """Stop hardware and close connections."""
        if self._light_system:
            self._light_system.stop()
        if self._poll_task:
            self._poll_task.cancel()
            try:
                self.machine.clock.loop.run_until_complete(self._poll_task)
            except asyncio.CancelledError:
                pass

        if self._sender_task:
            self._sender_task.cancel()
            try:
                self.machine.clock.loop.run_until_complete(self._sender_task)
            except asyncio.CancelledError:
                pass

        if self._send_key_task:
            self._send_key_task.cancel()
            try:
                self.machine.clock.loop.run_until_complete(self._send_key_task)
            except asyncio.CancelledError:
                pass

        if self._writer:
            # shutdown the bridge
            self._writer.write(b'\xf5')
            # send ctrl+c to stop the mpf-spike-bridge
            self._writer.write(b'\x03reset\n')
            self._writer.close()

    @staticmethod
    def _done(future):  # pragma: no cover
        """Evaluate result of task.

        Will raise exceptions from within task.
        """
        try:
            future.result()
        except asyncio.CancelledError:
            pass

    @asyncio.coroutine
    def _send_raw(self, data):
        if self.debug:
            self.log.debug("Sending: %s", "".join("%02x " % b for b in data))
        for start in range(0, len(data), 256):
            block = data[start:start + 256]
            self._writer.write(block)
        yield from self._writer.drain()

    @asyncio.coroutine
    def _read_raw(self, msg_len: int) -> Generator[int, None, bytearray]:
        if not msg_len:
            raise AssertionError("Cannot read 0 length")

        if self.debug:
            self.log.debug("Reading %s bytes", msg_len)

        data = yield from self._reader.readexactly(msg_len)

        if self.debug:
            self.log.debug("Data: %s", "".join("%02x " % b for b in data))

        return data

    @staticmethod
    def _checksum(cmd_str):
        checksum = 0
        for i in cmd_str:
            checksum += i
        return (256 - (checksum % 256)) % 256

    @asyncio.coroutine
    def send_cmd_and_wait_for_response(self, node, cmd, data, response_len)\
            -> Generator[int, None, Optional[bytearray]]:
        """Send cmd and wait for response."""
        assert response_len > 0
        if node > 15:
            raise AssertionError("Node must be 0-15.")
        cmd_str = bytearray()
        cmd_str.append((8 << 4) + node)
        cmd_str.append(len(data) + 2)
        cmd_str.append(cmd)
        cmd_str.extend(data)
        cmd_str.append(self._checksum(cmd_str))
        cmd_str.append(response_len)
        with (yield from self._bus_read):
            with (yield from self._bus_write):
                yield from self._send_raw(cmd_str)
            try:
                response = yield from asyncio.wait_for(
                    self._read_raw(response_len),
                    2,
                    loop=self.machine.clock.loop)  # type: bytearray
            except asyncio.TimeoutError:  # pragma: no cover
                self.log.warning("Failed to read %s bytes from Spike",
                                 response_len)
                return None

            if response[-1] != 0:
                self.log.info("Bridge Status: %s != 0", response[-1])
            if self._checksum(response[0:-1]) != 0:  # pragma: no cover
                self.log.warning("Checksum mismatch for response: %s",
                                 "".join("%02x " % b for b in response))
                # we resync by flushing the input
                self._writer.transport.serial.reset_input_buffer()
                # pylint: disable-msg=protected-access
                self._reader._buffer = bytearray()
                return None

            return response

    def _create_cmd_str(self, node, cmd, data):
        if node > 15:
            raise AssertionError("Node must be 0-15.")
        cmd_str = bytearray()
        cmd_str.append((8 << 4) + node)
        cmd_str.append(len(data) + 2)
        cmd_str.append(cmd)
        cmd_str.extend(data)
        cmd_str.append(self._checksum(cmd_str))
        cmd_str.append(0)
        return cmd_str

    @asyncio.coroutine
    def send_cmd_sync(self, node, cmd, data):
        """Send cmd which does not require a response."""
        cmd_str = self._create_cmd_str(node, cmd, data)
        if (cmd & 0xF0) == 0x80:
            # special case for LED updates
            wait_ms = self.config['wait_times'][0x80] if 0x80 in self.config[
                'wait_times'] else 0
        else:
            wait_ms = self.config['wait_times'][cmd] if cmd in self.config[
                'wait_times'] else 0

        yield from self._send_cmd_without_response(cmd_str, wait_ms)

    @asyncio.coroutine
    def _send_cmd_without_response(self, cmd_str, wait_ms):
        with (yield from self._bus_write):
            yield from self._send_raw(cmd_str)
            if wait_ms:
                yield from self._send_raw(bytearray([1, wait_ms]))

    @asyncio.coroutine
    def send_cmd_raw(self, data, wait_ms=0):
        """Send raw command."""
        with (yield from self._bus_write):
            yield from self._send_raw(data)
            if wait_ms:
                yield from self._send_raw(bytearray([1, wait_ms]))

    def send_cmd_async(self, node, cmd, data):
        """Send cmd which does not require a response."""
        cmd_str = self._create_cmd_str(node, cmd, data)
        wait_ms = self.config['wait_times'][cmd] if cmd in self.config[
            'wait_times'] else 0
        # queue command
        self._cmd_queue.put_nowait((cmd_str, wait_ms))

    def send_cmd_raw_async(self, data, wait_ms=0):
        """Send raw cmd which does not require a response."""
        # queue command
        self._cmd_queue.put_nowait((data, wait_ms))

    def _read_inputs(self, node):
        return self.send_cmd_and_wait_for_response(node,
                                                   SpikeNodebus.GetInputState,
                                                   bytearray(), 10)

    @staticmethod
    def _input_to_int(state):
        if state is False or state is None or len(state) < 8:
            return 0

        result = 0
        for i in range(8):
            result += pow(256, i) * int(state[i])

        return result

    def configure_dmd(self):
        """Configure a DMD."""
        if self.dmd:
            raise AssertionError("Can only configure dmd once.")
        self.dmd = SpikeDMD(self)
        return self.dmd

    @asyncio.coroutine
    def configure_stepper(self, number: str,
                          config: dict) -> "StepperPlatformInterface":
        """Configure a stepper in Spike."""
        # TODO: validate that light number is not used in a stepper and a light
        return SpikeStepper(number, config, self)

    @classmethod
    def get_stepper_config_section(cls):
        """Return config validator name."""
        return "spike_stepper_settings"

    @asyncio.coroutine
    def _init_bridge(self):
        # send ctrl+c to stop whatever is running
        self.log.debug("Resetting console")
        self._writer.write(b'\x03reset\n')
        # wait for the serial
        yield from asyncio.sleep(.1, loop=self.machine.clock.loop)
        # flush input
        self._writer.transport.serial.reset_input_buffer()
        # pylint: disable-msg=protected-access
        self._reader._buffer = bytearray()
        # start mpf-spike-bridge
        self.log.debug("Starting MPF bridge")
        if self.config['bridge_debug']:
            log_file = self.config['bridge_debug_log']
            binary = "RUST_BACKTRACE=full {}".format(
                self.config['bridge_path'])
        else:
            log_file = "/dev/null"
            binary = self.config['bridge_path']

        if self.config['spike_version'] == "1":
            spike_version = "SPIKE1"
        else:
            spike_version = "SPIKE2"
        self._writer.write("{} {} {} 2>{}\r\n".format(
            binary, self.config['runtime_baud'], spike_version,
            log_file).encode())

        welcome_str = b'MPF Spike Bridge!'
        yield from asyncio.sleep(.1, loop=self.machine.clock.loop)
        # read until first capital M
        while True:
            byte = yield from self._reader.readexactly(1)
            if ord(byte) == welcome_str[0]:
                break

        data = yield from self._reader.read(100)
        if data[:len(welcome_str) - 1] != welcome_str[1:]:
            raise AssertionError("Expected '{}' got '{}'".format(
                welcome_str[1:], data[:len(welcome_str) - 1]))
        self.log.debug("Bridge started")

        if self.config['runtime_baud']:
            # increase baud rate
            self.log.debug("Increasing baudrate to %s",
                           self.config['runtime_baud'])
            self._writer.transport.serial.baudrate = self.config[
                'runtime_baud']

        yield from asyncio.sleep(.1, loop=self.machine.clock.loop)
        self._reader._buffer = bytearray()

    @asyncio.coroutine
    def _initialize(self) -> Generator[int, None, None]:
        yield from self._init_bridge()

        self.log.debug("Resetting node bus and configuring traffic.")
        yield from self.send_cmd_sync(0, SpikeNodebus.Reset, bytearray())
        # wait 3s (same as spike)
        for _ in range(12):
            yield from self._send_raw(bytearray([1, 250]))

        yield from self.send_cmd_sync(0, SpikeNodebus.SetTraffic,
                                      bytearray([34]))  # block traffic (false)
        yield from self.send_cmd_sync(0, SpikeNodebus.SetTraffic,
                                      bytearray([17]))  # set traffic

        initialized_nodes = {0}

        while True:
            # poll to iterate nodes
            yield from self.send_cmd_raw([0])
            node = yield from self._read_raw(1)
            if node is None:
                self.log.warning("Initial poll timeouted")
                yield from asyncio.sleep(.5, loop=self.machine.clock.loop)

            node = ord(node)
            if node == 0:
                # all nodes initialised
                break

            if node == 0xF0:
                # local switches. just read them to make them go away
                yield from self._read_inputs(0)
                continue

            initialized_nodes.add(node)
            self.log.debug("Poll nodes: %s", node)

            yield from self.send_cmd_sync(node, SpikeNodebus.SetTraffic,
                                          bytearray([16]))  # clear traffic
            yield from self.send_cmd_sync(node, SpikeNodebus.SetTraffic,
                                          bytearray(
                                              [32]))  # block traffic (true)

            if node not in self._nodes:
                self.log.warning(
                    "Found a node %s during initial polling which is not configured",
                    node)

        if set(self._nodes) - initialized_nodes:
            self.log.warning(
                "Not all nodes found during init. Missing %s Found: %s",
                set(self._nodes) - initialized_nodes, initialized_nodes)

        yield from self.send_cmd_sync(0, SpikeNodebus.SetTraffic,
                                      bytearray([34]))  # block traffic (false)

        # get bridge version
        yield from self.send_cmd_raw([SpikeNodebus.GetBridgeVersion, 0, 3], 0)
        bridge_version = yield from self._read_raw(3)
        self.log.debug("Bridge version: %s",
                       "".join("0x%02x " % b for b in bridge_version))

        # get bridge state
        yield from self.send_cmd_raw([SpikeNodebus.GetBridgeState, 0, 1], 0)
        bridge_state = yield from self._read_raw(1)
        self.log.debug("Bridge state: %s",
                       "".join("0x%02x " % b for b in bridge_state))

        for node in self._nodes:
            if node == 0:
                continue
            self.log.debug("GetVersion on node %s", node)
            fw_version = yield from self.send_cmd_and_wait_for_response(
                node, SpikeNodebus.GetVersion, bytearray(), 12)
            if fw_version:
                self.log.debug("Node: %s Version: %s", node,
                               "".join("0x%02x " % b for b in fw_version))
            if fw_version[0] != node:
                self.log.warning(
                    "Node: %s Version Response looks bogus (node ID does not match): %s",
                    node, "".join("0x%02x " % b for b in fw_version))

            # we need this to calculate the right times for this node
            self.ticks_per_sec[node] = (fw_version[9] << 8) + fw_version[8]

            # Set response time (redundant but send multiple times)
            # wait time based on the baud rate of the bus: (460800 * 0x98852841 * 200) >> 0x30 = 0x345
            response_time = self.config['response_time']
            yield from self.send_cmd_raw([
                SpikeNodebus.SetResponseTime, 0x02,
                int(response_time & 0xff),
                int(response_time >> 8), 0
            ], 0)

            self.log.debug("GetChecksum on node %s", node)
            checksum = yield from self.send_cmd_and_wait_for_response(
                node, SpikeNodebus.GetChecksum, bytearray([0xff, 0x00]), 4)
            self.log.debug("Got Checksum %s for node %s",
                           "".join("0x%02x " % b for b in checksum), node)

        for node in self._nodes:
            self.log.debug("Initial read inputs on node %s", node)
            initial_inputs = yield from self._read_inputs(node)
            self._inputs[node] = self._input_to_int(initial_inputs)

        for node in self._nodes:
            if node == 0:
                continue
            self.log.debug("GetStatus and GetCoilCurrent on node %s", node)
            yield from self.send_cmd_and_wait_for_response(
                node, SpikeNodebus.GetStatus, bytearray(), 10)
            yield from self.send_cmd_and_wait_for_response(
                node, SpikeNodebus.GetCoilCurrent, bytearray([0]), 12)

        self.log.debug("Configuring traffic.")
        yield from self.send_cmd_sync(0, SpikeNodebus.SetTraffic,
                                      bytearray([17]))  # set traffic

        self.log.info("SPIKE init done.")
Example #3
0
class PROCBasePlatform(LightsPlatform,
                       SwitchPlatform,
                       DriverPlatform,
                       ServoPlatform,
                       StepperPlatform,
                       metaclass=abc.ABCMeta):
    """Platform class for the P-Roc and P3-ROC hardware controller.

    Args:
        machine: The MachineController instance.
    """

    __slots__ = [
        "pdbconfig", "pinproc", "proc", "log", "hw_switch_rules", "version",
        "revision", "hardware_version", "dipswitches", "machine_type",
        "event_task", "pdled_state", "proc_thread", "proc_process",
        "proc_process_instance", "_commands_running", "config", "_light_system"
    ]

    def __init__(self, machine):
        """Make sure pinproc was loaded."""
        super().__init__(machine)

        if not PINPROC_IMPORTED:
            raise AssertionError(
                'Could not import "pinproc". Most likely you do not '
                'have libpinproc and/or pypinproc installed. You can '
                'run MPF in software-only "virtual" mode by using '
                'the -x command like option for now instead.'
            ) from IMPORT_ERROR

        self.pdbconfig = None
        self.pinproc = pinproc
        self.log = None
        self.hw_switch_rules = {}
        self.version = None
        self.revision = None
        self.hardware_version = None
        self.dipswitches = None
        self.event_task = None
        self.proc_thread = None
        self.proc_process = None
        self.proc_process_instance = None
        self._commands_running = 0
        self.config = {}
        self.pdled_state = defaultdict(PdLedStatus)
        self._light_system = None

        self.machine_type = pinproc.normalize_machine_type(
            self.machine.config['hardware']['driverboards'])

    def _decrement_running_commands(self, future):
        del future
        self._commands_running -= 1

    def run_proc_cmd(self, cmd, *args):
        """Run a command in the p-roc thread and return a future."""
        if self.debug:
            self.debug_log("Calling P-Roc cmd: %s (%s)", cmd, args)
        future = asyncio.wrap_future(asyncio.run_coroutine_threadsafe(
            self.proc_process.run_command(cmd, *args),
            self.proc_process_instance),
                                     loop=self.machine.clock.loop)
        future.add_done_callback(Util.raise_exceptions)
        return future

    def run_proc_cmd_no_wait(self, cmd, *args):
        """Run a command in the p-roc thread."""
        self.run_proc_cmd(cmd, *args)

    def run_proc_cmd_sync(self, cmd, *args):
        """Run a command in the p-roc thread and return the result."""
        return self.machine.clock.loop.run_until_complete(
            self.run_proc_cmd(cmd, *args))

    async def initialize(self):
        """Set machine vars."""
        await self.connect()
        self.machine.variables.set_machine_var("p_roc_version", self.version)
        '''machine_var: p_roc_version

        desc: Holds the firmware version number of the P-ROC or P3-ROC controller that's
        attached to MPF.
        '''

        self.machine.variables.set_machine_var("p_roc_revision", self.revision)
        '''machine_var: p_roc_revision

        desc: Holds the firmware revision number of the P-ROC or P3-ROC controller
        that's attached to MPF.
        '''

        self.machine.variables.set_machine_var("p_roc_hardware_version",
                                               self.hardware_version)
        '''machine_var: p_roc_hardware_version

        desc: Holds the hardware version number of the P-ROC or P3-ROC controller
        that's attached to MPF.
        '''

        self._light_system = PlatformBatchLightSystem(
            self.machine.clock, self._light_key, self._are_lights_sequential,
            self._send_multiple_light_update,
            self.machine.machine_config['mpf']['default_light_hw_update_hz'],
            65535)

    async def _send_multiple_light_update(self, sequential_brightness_list):
        for light, brightness, fade_ms in sequential_brightness_list:
            light.set_fade_to_hw(brightness, fade_ms)

    @staticmethod
    def _light_key(light: PDBLED):
        """Sort lights by this key."""
        return light.board * 1000 + light.address

    @staticmethod
    def _are_lights_sequential(a: PDBLED, b: PDBLED):
        """Return True if lights are sequential."""
        return a.board == b.board and a.address + 1 == b.address

    async def start(self):
        """Start listening for switches."""
        self.event_task = self.machine.clock.loop.create_task(
            self._poll_events())
        self.event_task.add_done_callback(Util.raise_exceptions)
        self._light_system.start()

    def process_events(self, events):
        """Process events from the P-Roc."""
        raise NotImplementedError()

    async def _poll_events(self):
        poll_sleep = 1 / self.machine.config['mpf']['default_platform_hz']
        while True:
            events = await asyncio.wrap_future(
                asyncio.run_coroutine_threadsafe(
                    self.proc_process.read_events_and_watchdog(poll_sleep),
                    self.proc_process_instance),
                loop=self.machine.clock.loop)
            if events:
                self.process_events(events)

            await asyncio.sleep(poll_sleep, loop=self.machine.clock.loop)

    def stop(self):
        """Stop proc."""
        if self._light_system:
            self._light_system.stop()
        if self.proc_process and self.proc_process_instance:
            self.proc_process_instance.call_soon_threadsafe(
                self.proc_process.stop)
        if self.proc_thread:
            self.debug_log("Waiting for pinproc thread.")
            self.proc_thread.join()
            self.proc_thread = None
            self.debug_log("pinproc thread finished.")

    def _start_proc_process(self):
        self.proc_process = ProcProcess()
        if self.config["use_separate_thread"]:
            # Create a new loop
            self.proc_process_instance = asyncio.new_event_loop()
            # Assign the loop to another thread
            self.proc_thread = Thread(
                target=self.proc_process.start_proc_process,
                args=(self.machine_type, self.proc_process_instance,
                      self.config['trace_bus']
                      and self.config['debug'], self.log))
            self.proc_thread.start()

        else:
            # use existing loop
            self.proc_process_instance = self.machine.clock.loop
            self.proc_process.start_pinproc(loop=self.machine.clock.loop,
                                            machine_type=self.machine_type,
                                            trace=self.config['trace_bus']
                                            and self.config['debug'],
                                            log=self.log)

    async def connect(self):
        """Connect to the P-ROC.

        Keep trying if it doesn't work the first time.
        """
        self.log.info("Connecting to P-ROC")

        self._start_proc_process()

        version_revision = await self.run_proc_cmd("read_data", 0x00, 0x01)

        self.revision = version_revision & 0xFFFF
        self.version = (version_revision & 0xFFFF0000) >> 16
        dipswitches = await self.run_proc_cmd("read_data", 0x00, 0x03)
        self.hardware_version = (dipswitches & 0xF00) >> 8
        self.dipswitches = ~dipswitches & 0x3F

        self.log.info(
            "Successfully connected to P-ROC/P3-ROC. Firmware Version: %s. Firmware Revision: %s. "
            "Hardware Board ID: %s", self.version, self.revision,
            self.hardware_version)

        # for unknown reasons we have to postpone this a bit after init
        self.machine.delay.add(100, self._configure_pd_led)

    def _configure_pd_led(self):
        """Configure PD-LEDs."""
        for pd_number, config in self.config['pd_led_boards'].items():
            self._write_ws2811_ctrl(pd_number, config['ws281x_low_bit_time'],
                                    config['ws281x_high_bit_time'],
                                    config['ws281x_end_bit_time'],
                                    config['ws281x_reset_bit_time'])
            self._write_ws2811_range(pd_number, 0,
                                     config['ws281x_0_first_address'],
                                     config['ws281x_0_last_address'])
            self._write_ws2811_range(pd_number, 1,
                                     config['ws281x_1_first_address'],
                                     config['ws281x_1_last_address'])
            self._write_ws2811_range(pd_number, 2,
                                     config['ws281x_2_first_address'],
                                     config['ws281x_2_last_address'])
            self._write_lpd8806_range(pd_number, 0,
                                      config['lpd880x_0_first_address'],
                                      config['lpd880x_0_last_address'])
            self._write_lpd8806_range(pd_number, 1,
                                      config['lpd880x_1_first_address'],
                                      config['lpd880x_1_last_address'])
            self._write_lpd8806_range(pd_number, 2,
                                      config['lpd880x_2_first_address'],
                                      config['lpd880x_2_last_address'])
            self._write_pdled_serial_control(
                pd_number, (config['use_ws281x_0'] * 1 << 0) +
                (config['use_ws281x_1'] * 1 << 1) +
                (config['use_ws281x_2'] * 1 << 2) +
                (config['use_lpd880x_0'] * 1 << 3) +
                (config['use_lpd880x_1'] * 1 << 4) +
                (config['use_lpd880x_2'] * 1 << 5) +
                (config['use_stepper_0'] * 1 << 8) +
                (config['use_stepper_1'] * 1 << 9))

            # configure servos
            self.write_pdled_config_reg(pd_number, 20,
                                        (config['use_servo_0'] * 1 << 0) +
                                        (config['use_servo_1'] * 1 << 1) +
                                        (config['use_servo_2'] * 1 << 2) +
                                        (config['use_servo_3'] * 1 << 3) +
                                        (config['use_servo_4'] * 1 << 4) +
                                        (config['use_servo_5'] * 1 << 5) +
                                        (config['use_servo_6'] * 1 << 6) +
                                        (config['use_servo_7'] * 1 << 7) +
                                        (config['use_servo_8'] * 1 << 8) +
                                        (config['use_servo_9'] * 1 << 9) +
                                        (config['use_servo_10'] * 1 << 10) +
                                        (config['use_servo_11'] * 1 << 11))
            self.write_pdled_config_reg(pd_number, 21,
                                        config['max_servo_value'])

            # configure steppers
            if config['use_stepper_0'] or config['use_stepper_1']:
                self.write_pdled_config_reg(pd_number, 22,
                                            config['stepper_speed'])

    def write_pdled_config_reg(self, board_addr, addr, reg_data):
        """Write a pdled config register.

        Args:
            board_addr: Address of the board
            addr: Register address
            reg_data: Register data

        Write the 'regData' into the PD-LEDs address register because when writing a config register
        The data (16 bits) goes into the address field, and the address (8-bits) goes into the data field.
        """
        self._write_addr(board_addr, reg_data)
        self._write_reg_data(board_addr, addr)

    def write_pdled_color(self, board_addr, addr, color):
        """Set a color instantly.

        This command will internally increment the index on the PD-LED board.
        Therefore, it will be much more efficient if you set colors for addresses sequentially.
        """
        self._write_addr(board_addr, addr)
        self._write_color(board_addr, color)

    def write_pdled_color_fade(self, board_addr, addr, color, fade_time):
        """Fade a LED to a color.

        This command will internally increment the index on the PD-LED board.
        It will only set the fade time if it changes.
        Therefore, it will be much more efficient if you set colors for addresses sequentially.
        """
        self._write_fade_time(board_addr, fade_time)
        self._write_addr(board_addr, addr)
        self._write_fade_color(board_addr, color)

    def _write_fade_time(self, board_addr, fade_time):
        """Set the fade time for a board."""
        if self.pdled_state[board_addr].fade_time == fade_time:
            # fade time is already set
            return

        proc_output_module = 3
        proc_pdb_bus_addr = 0xC00
        base_reg_addr = 0x01000000 | (board_addr & 0x3F) << 16
        data = base_reg_addr | (3 << 8) | (fade_time & 0xFF)
        self.run_proc_cmd_no_wait("write_data", proc_output_module,
                                  proc_pdb_bus_addr, data)
        data = base_reg_addr | (4 << 8) | ((fade_time >> 8) & 0xFF)
        self.run_proc_cmd_no_wait("write_data", proc_output_module,
                                  proc_pdb_bus_addr, data)

        # remember fade time
        self.pdled_state[board_addr].fade_time = fade_time
        # invalidate addr
        self.pdled_state[board_addr].addr = None

    def _write_addr(self, board_addr, addr):
        """Write an address to pdled."""
        if self.pdled_state[board_addr].addr == addr:
            # addr is already set
            return

        base_reg_addr = 0x01000000 | (board_addr & 0x3F) << 16
        proc_output_module = 3
        proc_pdb_bus_addr = 0xC00

        # Write the low address bits into reg addr 0
        data = base_reg_addr | (addr & 0xFF)
        self.run_proc_cmd_no_wait("write_data", proc_output_module,
                                  proc_pdb_bus_addr, data)

        # Write the high address bits into reg addr 6
        data = base_reg_addr | (6 << 8) | ((addr >> 8) & 0xFF)
        self.run_proc_cmd_no_wait("write_data", proc_output_module,
                                  proc_pdb_bus_addr, data)

        # remember the address
        self.pdled_state[board_addr].addr = addr

    def _write_reg_data(self, board_addr, data):
        """Write data to pdled."""
        base_reg_addr = 0x01000000 | (board_addr & 0x3F) << 16
        proc_output_module = 3
        proc_pdb_bus_addr = 0xC00

        # Write 0 into reg addr 7, which is the data word, which is actually the address when
        # writing a config write.  The config register is mapped to 0.
        word = base_reg_addr | (7 << 8) | data
        self.run_proc_cmd_no_wait("write_data", proc_output_module,
                                  proc_pdb_bus_addr, word)
        # invalidate addr
        self.pdled_state[board_addr].addr = None

    def _write_color(self, board_addr, color):
        base_reg_addr = 0x01000000 | (board_addr & 0x3F) << 16
        proc_output_module = 3
        proc_pdb_bus_addr = 0xC00

        data = base_reg_addr | (1 << 8) | (color & 0xFF)
        self.run_proc_cmd_no_wait("write_data", proc_output_module,
                                  proc_pdb_bus_addr, data)

        # writing a color increases the addr on the board internally
        self.pdled_state[board_addr].addr += 1

    def _write_fade_color(self, board_addr, fade_color):
        """Fade the LED at the current index for a board to a certain color."""
        proc_output_module = 3
        proc_pdb_bus_addr = 0xC00
        base_reg_addr = 0x01000000 | (board_addr & 0x3F) << 16
        data = base_reg_addr | (2 << 8) | (fade_color & 0xFF)
        self.run_proc_cmd_no_wait("write_data", proc_output_module,
                                  proc_pdb_bus_addr, data)
        # writing a fade color increases the addr on the board internally
        self.pdled_state[board_addr].addr += 1

    # pylint: disable-msg=too-many-arguments
    def _write_ws2811_ctrl(self, board_addr, lbt, hbt, ebt, rbt):
        self.write_pdled_config_reg(board_addr, 4, lbt)
        self.write_pdled_config_reg(board_addr, 5, hbt)
        self.write_pdled_config_reg(board_addr, 6, ebt)
        self.write_pdled_config_reg(board_addr, 7, rbt)

    def _write_pdled_serial_control(self, board_addr, index_mask):
        self.write_pdled_config_reg(board_addr, 0, index_mask)

    def _write_ws2811_range(self, board_addr, index, first_addr, last_addr):
        self.write_pdled_config_reg(board_addr, 8 + index * 2, first_addr)
        self.write_pdled_config_reg(board_addr, 9 + index * 2, last_addr)

    def _write_lpd8806_range(self, board_addr, index, first_addr, last_addr):
        self.write_pdled_config_reg(board_addr, 16 + index * 2, first_addr)
        self.write_pdled_config_reg(board_addr, 17 + index * 2, last_addr)

    @classmethod
    def _get_event_type(cls, sw_activity, debounced):
        if sw_activity == 0 and debounced:
            return "open_debounced"
        if sw_activity == 0 and not debounced:
            return "open_nondebounced"
        if sw_activity == 1 and debounced:
            return "closed_debounced"
        # if sw_activity == 1 and not debounced:
        return "closed_nondebounced"

    def _add_hw_rule(self,
                     switch: SwitchSettings,
                     coil: DriverSettings,
                     rule,
                     invert=False):
        rule_type = self._get_event_type(switch.invert == invert,
                                         switch.debounce)

        # overwrite rules for the same switch and coil combination
        for rule_num, rule_obj in enumerate(
                switch.hw_switch.hw_rules[rule_type]):
            if rule_obj[0] == switch.hw_switch.number and rule_obj[
                    1] == coil.hw_driver.number:
                del switch.hw_switch.hw_rules[rule_type][rule_num]

        switch.hw_switch.hw_rules[rule_type].append(
            (switch.hw_switch.number, coil.hw_driver.number, rule))

    def _add_pulse_rule_to_switch(self, switch, coil):
        """Add a rule to pulse a coil on switch hit for a certain duration and optional with PWM."""
        if coil.pulse_settings.power < 1.0:
            pwm_on, pwm_off = coil.hw_driver.get_pwm_on_off_ms(
                coil.pulse_settings)
            self._add_hw_rule(
                switch, coil,
                self.pinproc.driver_state_pulsed_patter(
                    coil.hw_driver.state(), pwm_on, pwm_off,
                    coil.pulse_settings.duration, True))
        else:
            self._add_hw_rule(
                switch, coil,
                self.pinproc.driver_state_pulse(coil.hw_driver.state(),
                                                coil.pulse_settings.duration))

    def _add_pulse_and_hold_rule_to_switch(self, switch: SwitchSettings,
                                           coil: DriverSettings):
        """Add a rule to pulse a coil on switch hit for a certain duration and enable the coil with optional PWM.

        The initial pulse will always be at full power and this method will error out if it is set differently.
        """
        if coil.pulse_settings.power < 1.0:
            self.raise_config_error(
                "Any rules with hold need to have pulse_power set to 1.0. This is a limitation "
                "with the P/P3-Roc.", 6)

        if coil.hold_settings.power < 1.0:
            pwm_on, pwm_off = coil.hw_driver.get_pwm_on_off_ms(
                coil.hold_settings)
            self._add_hw_rule(
                switch, coil,
                self.pinproc.driver_state_patter(coil.hw_driver.state(),
                                                 pwm_on, pwm_off,
                                                 coil.pulse_settings.duration,
                                                 True))
        else:
            # This method is called in the p-roc thread. we can call hw_driver.state()
            self._add_hw_rule(
                switch, coil,
                self.pinproc.driver_state_pulse(coil.hw_driver.state(), 0))

    def _add_release_disable_rule_to_switch(self, switch: SwitchSettings,
                                            coil: DriverSettings):
        self._add_hw_rule(switch,
                          coil,
                          self.pinproc.driver_state_disable(
                              coil.hw_driver.state()),
                          invert=True)

    def _add_disable_rule_to_switch(self, switch: SwitchSettings,
                                    coil: DriverSettings):
        self._add_hw_rule(
            switch, coil,
            self.pinproc.driver_state_disable(coil.hw_driver.state()))

    def _write_rules_to_switch(self, switch, coil, drive_now):
        for event_type, driver_rules in switch.hw_switch.hw_rules.items():
            driver = []
            for x in driver_rules:
                driver.append(x[2])
            rule = {
                'notifyHost':
                bool(switch.hw_switch.notify_on_nondebounce) ==
                event_type.endswith("nondebounced"),
                'reloadActive':
                bool(coil.recycle)
            }
            if drive_now is None:
                self.run_proc_cmd_no_wait("switch_update_rule",
                                          switch.hw_switch.number, event_type,
                                          rule, driver)
            else:
                self.run_proc_cmd_no_wait("switch_update_rule",
                                          switch.hw_switch.number, event_type,
                                          rule, driver, drive_now)

    def set_pulse_on_hit_rule(self, enable_switch: SwitchSettings,
                              coil: DriverSettings):
        """Set pulse on hit rule on driver."""
        self.debug_log(
            "Setting HW Rule on pulse on hit. Switch: %s, Driver: %s",
            enable_switch.hw_switch.number, coil.hw_driver.number)
        self._add_pulse_rule_to_switch(enable_switch, coil)
        self._write_rules_to_switch(enable_switch, coil, False)

    def set_pulse_on_hit_and_release_rule(self, enable_switch: SwitchSettings,
                                          coil: DriverSettings):
        """Set pulse on hit and release rule to driver."""
        self.debug_log(
            "Setting HW Rule on pulse on hit and relesae. Switch: %s, Driver: %s",
            enable_switch.hw_switch.number, coil.hw_driver.number)
        self._add_pulse_rule_to_switch(enable_switch, coil)
        self._add_release_disable_rule_to_switch(enable_switch, coil)

        self._write_rules_to_switch(enable_switch, coil, False)

    def set_pulse_on_hit_and_enable_and_release_rule(
            self, enable_switch: SwitchSettings, coil: DriverSettings):
        """Set pulse on hit and enable and relase rule on driver."""
        self.debug_log(
            "Setting Pulse on hit and enable and release HW Rule. Switch: %s, Driver: %s",
            enable_switch.hw_switch.number, coil.hw_driver.number)
        self._add_pulse_and_hold_rule_to_switch(enable_switch, coil)
        self._add_release_disable_rule_to_switch(enable_switch, coil)

        self._write_rules_to_switch(enable_switch, coil, False)

    def set_pulse_on_hit_and_enable_and_release_and_disable_rule(
            self, enable_switch: SwitchSettings,
            disable_switch: SwitchSettings, coil: DriverSettings):
        """Set pulse on hit and enable and release and disable rule on driver."""
        self.debug_log(
            "Setting Pulse on hit and enable and release and disable HW Rule. Enable Switch: %s,"
            "Disable Switch: %s, Driver: %s", enable_switch.hw_switch.number,
            disable_switch.hw_switch.number, coil.hw_driver.number)
        self._add_pulse_and_hold_rule_to_switch(enable_switch, coil)
        self._add_release_disable_rule_to_switch(enable_switch, coil)
        self._add_disable_rule_to_switch(disable_switch, coil)

        self._write_rules_to_switch(enable_switch, coil, False)
        self._write_rules_to_switch(disable_switch, coil, False)

    def clear_hw_rule(self, switch, coil):
        """Clear a hardware rule.

        This is used if you want to remove the linkage between a switch and
        some driver activity. For example, if you wanted to disable your
        flippers (so that a player pushing the flipper buttons wouldn't cause
        the flippers to flip), you'd call this method with your flipper button
        as the *sw_num*.

        Args:
            switch: Switch object
            coil: Coil object
        """
        self.debug_log("Clearing HW rule for switch: %s coil: %s",
                       switch.hw_switch.number, coil.hw_driver.number)
        coil_number = False
        for entry, element in switch.hw_switch.hw_rules.items():
            if not element:
                continue
            for rule_num, rule in enumerate(element):
                if rule[0] == switch.hw_switch.number and rule[
                        1] == coil.hw_driver.number:
                    coil_number = rule[2]['driverNum']
                    del switch.hw_switch.hw_rules[entry][rule_num]

        if coil_number:
            self.run_proc_cmd_no_wait("driver_disable", coil_number)
            self._write_rules_to_switch(switch, coil, None)

    def _get_default_subtype(self):
        """Return default subtype for either P3-Roc or P-Roc."""
        raise NotImplementedError

    def parse_light_number_to_channels(self, number: str, subtype: str):
        """Parse light number to a list of channels."""
        if not subtype:
            subtype = self._get_default_subtype()
        if subtype == "matrix":
            return [{"number": number}]
        if subtype == "led":
            # split the number (which comes in as a string like w-x-y-z) into parts
            number_parts = str(number).split('-')

            if len(number_parts) != 4:
                raise AssertionError(
                    "Invalid address for LED {}".format(number))

            return [
                {
                    "number": number_parts[0] + "-" + number_parts[1]
                },
                {
                    "number": number_parts[0] + "-" + number_parts[2]
                },
                {
                    "number": number_parts[0] + "-" + number_parts[3]
                },
            ]

        raise AssertionError("Unknown subtype {}".format(subtype))

    def configure_light(self, number, subtype,
                        platform_settings) -> LightPlatformInterface:
        """Configure a light channel."""
        if not subtype:
            subtype = self._get_default_subtype()
        if subtype == "matrix":
            if self.machine_type == self.pinproc.MachineTypePDB:
                proc_num = self.pdbconfig.get_proc_light_number(str(number))
                if proc_num == -1:
                    raise AssertionError(
                        "Matrixlight {} cannot be controlled by the P-ROC. ".
                        format(str(number)))

            else:
                proc_num = self.pinproc.decode(self.machine_type, str(number))

            return PROCMatrixLight(proc_num, self.machine, self)
        if subtype == "led":
            board, index = number.split("-")
            polarity = platform_settings and platform_settings.get(
                "polarity", False)
            return PDBLED(int(board), int(index), polarity,
                          self.config.get("debug", False), self,
                          self._light_system)

        raise AssertionError("unknown subtype {}".format(subtype))

    def _configure_switch(self, config: SwitchConfig, proc_num) -> PROCSwitch:
        """Configure a P3-ROC switch.

        Args:
            config: Dictionary of settings for the switch.
            proc_num: decoded switch number

        Returns a reference to the switch object that was just created.
        """
        if proc_num == -1:
            raise AssertionError("Switch {} cannot be controlled by the "
                                 "P-ROC/P3-ROC.".format(proc_num))

        switch = PROCSwitch(config, proc_num, config.debounce == "quick", self)
        # The P3-ROC needs to be configured to notify the host computers of
        # switch events. (That notification can be for open or closed,
        # debounced or nondebounced.)
        self.debug_log(
            "Configuring switch's host notification settings. P3-ROC"
            "number: %s, debounce: %s", proc_num, config.debounce)
        if config.debounce == "quick":
            self.run_proc_cmd_no_wait("switch_update_rule", proc_num,
                                      'closed_nondebounced', {
                                          'notifyHost': True,
                                          'reloadActive': False
                                      }, [], False)
            self.run_proc_cmd_no_wait("switch_update_rule", proc_num,
                                      'open_nondebounced', {
                                          'notifyHost': True,
                                          'reloadActive': False
                                      }, [], False)
        else:
            self.run_proc_cmd_no_wait("switch_update_rule", proc_num,
                                      'closed_debounced', {
                                          'notifyHost': True,
                                          'reloadActive': False
                                      }, [], False)
            self.run_proc_cmd_no_wait("switch_update_rule", proc_num,
                                      'open_debounced', {
                                          'notifyHost': True,
                                          'reloadActive': False
                                      }, [], False)
        return switch

    async def configure_servo(self, number: str) -> ServoPlatformInterface:
        """Configure a servo on a PD-LED board.

        Args:
            number: Number of the servo
        """
        try:
            board, number = number.split("-")
        except ValueError:
            self.raise_config_error(
                "Servo number should be board-number but is {}".format(number),
                1)
        if 0 > int(number) >= 12:
            self.raise_config_error(
                "PD-LED only supports 12 servos {}".format(number), 5)

        return PdLedServo(board, number, self, self.config.get("debug", False))

    async def configure_stepper(self, number: str,
                                config: dict) -> PdLedStepper:
        """Configure a stepper (axis) device in platform.

        Args:
            number: Number of the stepper.
            config: Config for this stepper.
        """
        try:
            board, number = number.split("-")
        except ValueError:
            self.raise_config_error(
                "Stepper number should be board-number but is {}".format(
                    number), 3)
        if 0 > int(number) >= 2:
            self.raise_config_error(
                "PD-LED only supports two steppers {}".format(number), 4)

        pd_led = self.config['pd_led_boards'].get(board, {})
        stepper_speed = pd_led.get("stepper_speed", 13524)

        return PdLedStepper(board, number, self,
                            self.config.get("debug", False), stepper_speed)