diff --git a/mpf/config_spec.yaml b/mpf/config_spec.yaml index 66fa47500..d5d7ef04c 100644 --- a/mpf/config_spec.yaml +++ b/mpf/config_spec.yaml @@ -676,7 +676,6 @@ fast_breakout: led_ports: list|subconfig(fast_led_port)|None fast_led_port: port: single|str| - type: single|enum(ws2812,apa-102)|ws2812 leds: single|int|32 fast_aud: port: list|str|auto @@ -1543,6 +1542,7 @@ shots: switches: list|machine(switches)|None start_enabled: single|bool|None delay_switch: dict|machine(switches):ms|None + delay_event_list: dict|event_handler:ms|None persist_enable: single|bool|true playfield: single|machine(playfields)|playfield priority: single|int|0 diff --git a/mpf/devices/shot.py b/mpf/devices/shot.py index cd8466415..52dbe7031 100644 --- a/mpf/devices/shot.py +++ b/mpf/devices/shot.py @@ -55,8 +55,7 @@ async def _initialize(self) -> None: for switch in self.config['switches'] + list(self.config['delay_switch'].keys()): # mark the playfield active no matter what - switch.add_handler(self._mark_active, - callback_kwargs={'playfield': switch.config['playfield']}) + switch.add_handler(self._mark_active, callback_kwargs={'playfield': switch.config['playfield']}) def _mark_active(self, playfield, **kwargs): """Mark playfield active.""" @@ -85,17 +84,27 @@ def validate_and_parse_config(self, config: dict, is_mode_config: bool, debug_pr def _register_switch_handlers(self): self._handlers = [] + priority = self.mode.priority + self.config['priority'] for switch in self.config['switches']: self._handlers.append(self.machine.events.add_handler("{}_active".format(switch.name), self.event_hit, - priority=self.mode.priority + self.config['priority'], + priority=priority, blocking_facility="shot")) - for switch in list(self.config['delay_switch'].keys()): + for switch, ms in list(self.config['delay_switch'].items()): self._handlers.append(self.machine.events.add_handler("{}_active".format(switch.name), self._delay_switch_hit, - switch_name=switch.name, - priority=self.mode.priority + self.config['priority'], + name=switch.name, + ms=ms, + priority=priority, + blocking_facility="shot")) + + for event, ms in list(self.config['delay_event_list'].items()): + self._handlers.append(self.machine.events.add_handler(event, + self._delay_switch_hit, + name=event, + ms=ms, + priority=priority, blocking_facility="shot")) def _remove_switch_handlers(self): @@ -406,18 +415,17 @@ def _notify_monitors(self, profile, state): callback(name=self.name, profile=profile, state=state) @event_handler(4) - def _delay_switch_hit(self, switch_name, **kwargs): + def _delay_switch_hit(self, name, ms, **kwargs): del kwargs if not self.enabled: return - self.delay.reset(name=switch_name + '_delay_timer', - ms=self.config['delay_switch'] - [self.machine.switches[switch_name]], + self.delay.reset(name=name + '_delay_timer', + ms=ms, callback=self._release_delay, - switch=switch_name) + switch=name) - self.active_delays.add(switch_name) + self.active_delays.add(name) def _release_delay(self, switch): self.active_delays.remove(switch) diff --git a/mpf/platforms/fast/communicators/base.py b/mpf/platforms/fast/communicators/base.py index 743f5eefe..d09810f6a 100644 --- a/mpf/platforms/fast/communicators/base.py +++ b/mpf/platforms/fast/communicators/base.py @@ -250,66 +250,172 @@ def stop(self): raise e self.writer = None + # async def send_and_wait_for_response(self, msg, pause_sending_until, log_msg=None): + # """Sends a message and awaits until the response is received. + + # Parameters + # ---------- + # msg (_type_): Message to send + # pause_sending_until (_type_): Response to wait for before sending the next message + # log_msg (_type_, optional): Optional version of the message that will be used in logs. + # Typically used with binary messages so the longs can contain human readable versions. + # Defaults to None which means the actual msg will be used in the logs. + # """ + # await self.no_response_waiting.wait() + # self.no_response_waiting.clear() + # self.send_with_confirmation(msg, pause_sending_until, log_msg) + async def send_and_wait_for_response(self, msg, pause_sending_until, log_msg=None): - """Sends a message and awaits until the response is received. + """Send a message and block until the matching response ARRIVES. - Parameters - ---------- - msg (_type_): Message to send - pause_sending_until (_type_): Response to wait for before sending the next message - log_msg (_type_, optional): Optional version of the message that will be used in logs. - Typically used with binary messages so the longs can contain human readable versions. - Defaults to None which means the actual msg will be used in the logs. + 'done_waiting' is set by the parser *when the response arrives*. + The caller should not assume processing is finished yet. """ + # Ensure no other in-flight waits await self.no_response_waiting.wait() self.no_response_waiting.clear() - self.send_with_confirmation(msg, pause_sending_until, log_msg) - # pylint: disable-msg=too-many-arguments - async def send_and_wait_for_response_processed(self, msg, pause_sending_until, timeout=1, - max_retries=0, log_msg=None): - """Send a message and wait for the response to be processed. + # We are about to wait for a *new* response + self.done_waiting.clear() - Unlike send_and_wait_for_response(), this method will not release the wait when the response is received. - Instead, the wait must manually be released by calling done_processing_msg_response(). This is useful for - messages that require multiple responses, or for messages that require real processing where you don't want - the next messages to be sent until the processing is complete. + # Non-blocking send + self.send_with_confirmation(msg, pause_sending_until, log_msg) - Parameters - ---------- - msg (_type_): Message to send - pause_sending_until (_type_): Response to wait for before sending the next message - timeout (int, optional): The time (in seconds) this communicator will wait for a response. - If a response is not received by then (based on the pause_sending_until), the message will be resent. - Defaults to 1. - max_retries (int, optional): How many times the message will be resent if the response is not - received by the timeout. -1 means unlimited retries. Defaults to 0. - log_msg (_type_, optional): Optional version of the message that will be used in logs. - Typically used with binary messages so the longs can contain human readable versions. - Defaults to None which means the actual msg will be used in the logs. + try: + # Wait until the parser signals that the matching response ARRIVED + await self.done_waiting.wait() + finally: + # Do NOT set no_response_waiting here; that’s for "processed" phase + # We leave the gate closed while the handler finishes processing. + pass + + + # # pylint: disable-msg=too-many-arguments + # async def send_and_wait_for_response_processed(self, msg, pause_sending_until, timeout=1, + # max_retries=0, log_msg=None): + # """Send a message and wait for the response to be processed. + + # Unlike send_and_wait_for_response(), this method will not release the wait when the response is received. + # Instead, the wait must manually be released by calling done_processing_msg_response(). This is useful for + # messages that require multiple responses, or for messages that require real processing where you don't want + # the next messages to be sent until the processing is complete. + + # Parameters + # ---------- + # msg (_type_): Message to send + # pause_sending_until (_type_): Response to wait for before sending the next message + # timeout (int, optional): The time (in seconds) this communicator will wait for a response. + # If a response is not received by then (based on the pause_sending_until), the message will be resent. + # Defaults to 1. + # max_retries (int, optional): How many times the message will be resent if the response is not + # received by the timeout. -1 means unlimited retries. Defaults to 0. + # log_msg (_type_, optional): Optional version of the message that will be used in logs. + # Typically used with binary messages so the longs can contain human readable versions. + # Defaults to None which means the actual msg will be used in the logs. + # """ + # self.done_waiting.clear() + + # retries = 0 + + # while max_retries == -1 or retries <= max_retries: + # try: + # self.log.info("queue send, retries left %s", retries) + # await asyncio.wait_for(self.send_and_wait_for_response(msg, pause_sending_until, + # log_msg), timeout=timeout) + # break + # except asyncio.TimeoutError: + # self.log.error("Timeout waiting for response to %s. Retrying...", msg) + # retries += 1 + + # await self.done_waiting.wait() + + async def send_and_wait_for_response_processed( + self, + msg, + pause_sending_until, + timeout: float = 2.0, + max_retries: int = 1, # -1 means unlimited + log_msg=None, + processing_timeout: float | None = None, + raise_on_timeout: bool = False, + ): + """Send a message and wait for ARRIVAL and then PROCESSING completion. + + - 'timeout' caps waiting for the matching response to ARRIVE (parser must set done_waiting). + - 'processing_timeout' caps waiting for processing completion (handler must call done_processing_msg_response()). + - Returns True on success; False if total timeout; raises if raise_on_timeout=True. """ - self.done_waiting.clear() - - retries = 0 + if processing_timeout is None: + processing_timeout = timeout - while max_retries == -1 or retries <= max_retries: + attempt = 0 + # attempts_allowed = infinite if max_retries == -1, else (max_retries + 1) + while (max_retries == -1) or (attempt < (max_retries + 1)): + attempt += 1 try: - await asyncio.wait_for(self.send_and_wait_for_response(msg, pause_sending_until, - log_msg), timeout=timeout) - break + self.log.info( + "TX attempt %s/%s: %s (await '%s' arrival, timeout=%.2fs)", + attempt, + "∞" if max_retries == -1 else (max_retries + 1), + (log_msg or msg), + pause_sending_until, + timeout, + ) + + # Phase 1: ARRIVAL (bounded) + await asyncio.wait_for( + self.send_and_wait_for_response(msg, pause_sending_until, log_msg), + timeout=timeout, + ) + + self.log.info( + "RX matched '%s' for %s; waiting for processing completion (timeout=%.2fs)", + pause_sending_until, (log_msg or msg), processing_timeout + ) + + # Phase 2: PROCESSING (bounded) + # Handler should call done_processing_msg_response(), which must set `no_response_waiting`. + await asyncio.wait_for(self.no_response_waiting.wait(), timeout=processing_timeout) + + self.log.info("Processing complete for %s", (log_msg or msg)) + return True + except asyncio.TimeoutError: - self.log.error("Timeout waiting for response to %s. Retrying...", msg) - retries += 1 + # Open the gate so subsequent attempts (or other sends) aren’t blocked + self.no_response_waiting.set() + + if (max_retries == -1) or (attempt < (max_retries + 1)): + self.log.warning( + "Timeout on '%s' for %s; retrying (%s/%s)...", + pause_sending_until, (log_msg or msg), + attempt + 1, "∞" if max_retries == -1 else (max_retries + 1), + ) + continue + + # Exhausted + self.log.error( + "No '%s' after %s attempt(s) for %s; giving up.", + pause_sending_until, attempt, (log_msg or msg) + ) + if raise_on_timeout: + raise + return False + + + # def done_processing_msg_response(self): + # """Releases the wait for the response to be processed. - await self.done_waiting.wait() + # This is used in conjunction with send_and_wait_for_response_processed(). + # May be called safely if there's no wait to release. + # """ + # self.done_waiting.set() def done_processing_msg_response(self): - """Releases the wait for the response to be processed. + if not self.no_response_waiting.is_set(): + self.no_response_waiting.set() # allow next send + if not self.done_waiting.is_set(): + self.done_waiting.set() # be safe if arrival wasn’t set - This is used in conjunction with send_and_wait_for_response_processed(). - May be called safely if there's no wait to release. - """ - self.done_waiting.set() def send_with_confirmation(self, msg, pause_sending_until, log_msg=None): """Sends a message without blocking (returns immediately). @@ -325,6 +431,9 @@ def send_with_confirmation(self, msg, pause_sending_until, log_msg=None): if log_msg: self.send_queue.put_nowait((f'{msg}\r'.encode(), pause_sending_until, log_msg)) else: + ################## + self.log.info("send_with_confirmation: %s waiting for %s response", msg, pause_sending_until) + ################## self.send_queue.put_nowait((f'{msg}\r'.encode(), pause_sending_until, msg)) def send_and_forget(self, msg, log_msg=None): @@ -339,46 +448,124 @@ def send_bytes(self, msg, log_msg): # Forcing log_msg since bytes are not human readable self.send_queue.put_nowait((msg, None, log_msg)) - def parse_incoming_raw_bytes(self, msg): - """Parse a bytestring from the serial communicator.""" + # def parse_incoming_raw_bytes(self, msg): + # """Parse a bytestring from the serial communicator.""" + # self.received_msg += msg + + # while True: + # pos = self.received_msg.find(b'\r') + + # # no more complete messages + # if pos == -1: + # break + + # msg = self.received_msg[:pos] + # self.received_msg = self.received_msg[pos + 1:] + + # if not msg: + # continue + + # try: + # msg = msg.decode() + # except UnicodeDecodeError: + + # if self.machine.is_shutting_down: + # return + + # self.log.warning("Interference / bad data received: %s", msg) + # if not self.ignore_decode_errors: + # raise + + # if self.port_debug: + # self.log.info("<<<< %s", msg) + + # self._dispatch_incoming_msg(msg) + + def parse_incoming_raw_bytes(self, msg: bytes): + """Parse a bytestring from the serial communicator (robust to noise and leading junk).""" self.received_msg += msg + # Build a dynamic set of known headers from registered processors. + # Many FAST headers are 3 chars (including colon), e.g. 'ID:', 'SA:', '/L:', '-L:' + # Also include a small default set for early boot before processors are registered. + default_headers = ('ID:', 'ER:', 'BR:', 'MS:', 'XX:', 'SA:', 'SD:', 'SL:', 'SC:', '/L:', '-L:') + known_headers = tuple(self.message_processors.keys()) or default_headers + while True: pos = self.received_msg.find(b'\r') - - # no more complete messages if pos == -1: break - msg = self.received_msg[:pos] + raw = self.received_msg[:pos] self.received_msg = self.received_msg[pos + 1:] - if not msg: + if not raw: continue + # Safe decode; keep going on junk try: - msg = msg.decode() + line = raw.decode("ascii") except UnicodeDecodeError: - - if self.machine.is_shutting_down: + if getattr(self.machine, "is_shutting_down", False): return + self.log.warning("Interference / bad data received: %r", raw) + line = raw.decode("ascii", "ignore") - self.log.warning("Interference / bad data received: %s", msg) - if not self.ignore_decode_errors: - raise + # Strip control chars (keep TAB); CR already removed + line = "".join(ch for ch in line if ch == "\t" or 32 <= ord(ch) <= 126) + if not line: + continue + + # Fast path: header already at start? (2–3 visible chars + colon) + if not (len(line) >= 3 and line[2] == ":" and line[:2].isprintable()): + # Not a clean header at pos 0; try to find the first known header inside the line + idxs = [] + for h in known_headers: + i = line.find(h) + if i != -1: + idxs.append(i) + if idxs: + hdr_idx = min(idxs) + if hdr_idx > 0: + self.log.debug("Dropped %d bytes of leading noise before header: %r", + hdr_idx, line[:hdr_idx]) + line = line[hdr_idx:] + else: + # As a last resort, accept any 1–3 printable chars followed by colon as a header + m = re.search(r'([ -~]{1,3}):', line) + if m: + if m.start() > 0: + self.log.debug("Heuristic header recovery; dropped noise: %r", line[:m.start()]) + line = line[m.start():] + else: + # No recognizable frame—drop quietly + self.log.debug("Dropping non-frame line: %r", line) + continue + + # Ignore fully if configured + if line in getattr(self, "IGNORED_MESSAGES", []): + continue if self.port_debug: - self.log.info("<<<< %s", msg) + self.log.info("<<<< %s", line) + + try: + self._dispatch_incoming_msg(line) + except Exception as e: + # Never let a bad frame crash the reader loop + self.log.exception("Error dispatching line %r: %s", line, e) + - self._dispatch_incoming_msg(msg) def _dispatch_incoming_msg(self, msg): + # Figures out what to do with incoming messages if msg in self.IGNORED_MESSAGES: return - + self.log.warning(msg) ### ADDED TO DIAGNOSE EXP BOARD VERIFICATION ### msg_header = msg[:3] if msg_header in self.message_processors: + #self.log.warning(msg_header) ### ADDED TO DIAGNOSE EXP BOARD VERIFICATION ### self.message_processors[msg_header](msg[3:]) self.no_response_waiting.set() @@ -464,3 +651,17 @@ def write_to_port(self, msg, log_msg=None): self.writer.write(msg) except AttributeError: self.log.warning("Serial connection is not open. Cannot send message: %s", msg) + + def _safe_ascii_lines(self, b: bytes): + """Return a list of ASCII lines from a raw chunk, dropping non-ASCII noise.""" + try: + s = b.decode("ascii") + except UnicodeDecodeError: + # Keep the original bytes in logs to debug wiring/noise + self.log.warning("Interference / bad data received: %r", b) + # Decode what we can and drop the junk + s = b.decode("ascii", "ignore") + # Keep printable + CR/LF/TAB; strip other control chars + s = "".join(ch for ch in s if ch in ("\r", "\n", "\t") or 32 <= ord(ch) <= 126) + # Split into individual responses (FAST frames are ASCII, CR/LF delimited) + return [line for line in s.replace("\r", "\n").split("\n") if line] \ No newline at end of file diff --git a/mpf/platforms/fast/communicators/exp.py b/mpf/platforms/fast/communicators/exp.py index f4d0e98c7..ac71e80b9 100644 --- a/mpf/platforms/fast/communicators/exp.py +++ b/mpf/platforms/fast/communicators/exp.py @@ -1,5 +1,6 @@ """FAST Expansion Board Serial Communicator.""" # mpf/platforms/fast/communicators/exp.py +from pprint import pformat from functools import partial @@ -54,43 +55,103 @@ async def soft_reset(self): async def query_exp_boards(self): """Query the EXP bus for connected boards.""" - for board_name, board_config in self.config['boards'].items(): + boards = self.config['boards'] + + #PRINT NUMBER OF BOARDS IN CONFIG + self.log.info("EXP: %d boards found in config", len(boards)) + + for board_name, board_config in boards.items(): + + # Keep a copy of the raw fields for logging + model_raw = board_config.get('model') + addr_cfg = board_config.get('address') # FP-eXp-0071-2 -> FP-EXP-0071 board_config['model'] = ('-').join(board_config['model'].split('-')[:3]).upper() if board_config['address']: # need to do it this way since valid config will have 'address' = None board_address = board_config['address'] + self.log.info("Use config board address: %s", board_address) else: board_address = EXPANSION_BOARD_FEATURES[board_config['model']]['default_address'] + self.log.info("Use default board address: %s", board_address) + + + self.log.info('EXP CURRENT BOARD -> board_name=%s board_address=%s model=%s', + board_name, board_address, board_config['model']) + # Got an ID for a board that's already registered. This shouldn't happen? if board_address in self.exp_boards_by_address: raise AssertionError(f'Expansion Board at address {board_address} is already registered') board_obj = FastExpansionBoard(board_name, self, board_address, board_config) + + self.log.info("exp_boards_by_address: registering board_obj with EXP communicator") self.exp_boards_by_address[board_address] = board_obj # registers with this EXP communicator + + self.log.info("register_expansion_board: registering board_obj with the platform") self.platform.register_expansion_board(board_obj) # registers with the platform - + + self.log.info("setting active_board slot to %s", board_address) self.active_board = board_address - await self.send_and_wait_for_response_processed(f'ID@{board_address}:', 'ID:') + self.log.info("send_and_wait_for_response_processed for ID@%s",board_address) + await self.send_and_wait_for_response_processed(f'ID@{board_address}:', 'ID:',timeout=5) + + + self.log.info("loop through breakout_boards") for breakout_board in board_obj.breakouts.values(): + self.log.info("set active_board to address: %s", breakout_board.address) self.active_board = breakout_board.address - await self.send_and_wait_for_response_processed(f'ID@{breakout_board.address}:', 'ID:') + self.log.info("send_and_wait_for_response_processed for ID@%s",breakout_board.address) + await self.send_and_wait_for_response_processed(f'ID@{breakout_board.address}:', 'ID:',timeout=5) + + self.log.info("awaiting board_obj reset") await board_obj.reset() + # After registering & resetting all boards: + self.log.info("self.exp_boards_by_address now has %d verified board(s).", len(self.exp_boards_by_address)) + self.log_board_index() + + + # def _process_id(self, msg: str): + # # self.exp_boards_by_address[self.active_board[:2]].verify_hardware(msg, self.active_board) + # self.active_board = None + # self.done_processing_msg_response() + def _process_id(self, msg: str): - self.exp_boards_by_address[self.active_board[:2]].verify_hardware(msg, self.active_board) - self.active_board = None - self.done_processing_msg_response() + # 1) ARRIVAL: release anyone waiting for the 'ID:' arrival + if not self.done_waiting.is_set(): + self.done_waiting.set() + + # 2) Do the real work (verify, etc.) + try: + # If you still want verification, put it back: + self.exp_boards_by_address[self.active_board[:2]].verify_hardware(msg, self.active_board) + pass + finally: + # 3) PROCESSING DONE: release the send gate for the next message + self.active_board = None + self.done_processing_msg_response() # this MUST call no_response_waiting.set() under the hood + + + # def _process_br(self, msg): + # del msg + # self.active_board = None + # self.done_processing_msg_response() def _process_br(self, msg): del msg + # ARRIVAL + if not self.done_waiting.is_set(): + self.done_waiting.set() + # PROCESSING DONE self.active_board = None self.done_processing_msg_response() + def set_led_fade_rate(self, board_address: str, rate: int) -> None: """Sets the hardware LED fade rate for an EXP board. @@ -125,3 +186,17 @@ def _process_device_msg(self, message_prefix, message): device_id = message.split(",")[0] for board_callback in self._device_processors[message_prefix].values(): board_callback[device_id](message) + + def log_board_index(self) -> None: + """Log a readable map of EXP boards by hex address.""" + # sort by hex value so 48, 84, B4, etc. are in numeric order + items = sorted(self.exp_boards_by_address.items(), key=lambda kv: int(kv[0], 16)) + mapping = { + addr: { + "name": b.name, + "model": b.model, + "breakouts": sorted(list(b.breakouts.keys())), # e.g. ["0","1"] + } + for addr, b in items + } + self.log.info("EXP board index:\n%s", pformat(mapping)) diff --git a/mpf/platforms/fast/communicators/net_neuron.py b/mpf/platforms/fast/communicators/net_neuron.py index d2020e3a4..23c1d87bf 100644 --- a/mpf/platforms/fast/communicators/net_neuron.py +++ b/mpf/platforms/fast/communicators/net_neuron.py @@ -20,8 +20,8 @@ class FastNetNeuronCommunicator(FastSerialCommunicator): MIN_FW = version.parse('2.06') IO_MIN_FW = version.parse('1.09') MAX_IO_BOARDS = 9 - MAX_SWITCHES = 104 - MAX_DRIVERS = 48 + MAX_SWITCHES = 160 + MAX_DRIVERS = 85 IGNORED_MESSAGES = ['WD:P', 'TL:P'] TRIGGER_CMD = 'TL' DRIVER_CMD = 'DL' diff --git a/mpf/platforms/fast/fast.py b/mpf/platforms/fast/fast.py index 9bc02bcc6..f7d94958a 100644 --- a/mpf/platforms/fast/fast.py +++ b/mpf/platforms/fast/fast.py @@ -626,7 +626,13 @@ def configure_light(self, number, subtype, config, platform_settings) -> LightPl # TODO change to mpf config exception raise AssertionError(f'Board {exp_board} does not have a config entry for Breakout {breakout}') - index = self.port_idx_to_hex(port, led, 32, config.name) + port_configurations = exp_board.led_port_configurations + if port_configurations: + ports_on_breakout = port_configurations[int(breakout)] + else: + ports_on_breakout = None + + index = self.port_idx_to_hex(port, led, 32, config.name, ports_on_breakout) this_led_number = f'{brk_board.address}{index}' # this code runs once for each channel, so it will be called 3x per LED which @@ -676,13 +682,43 @@ def configure_light(self, number, subtype, config, platform_settings) -> LightPl return fast_led_channel raise AssertionError(f"Unknown light subtype {subtype}") - def port_idx_to_hex(self, port, device_num, devices_per_port, name=None): + def port_idx_to_hex_with_configurations(self, port, device_num, ports_on_breakout, name=None): + """Converts port number and LED index into the proper FAST hex number. + + This accounts for rewriting LED chain lengths with the FAST EXP ER command + + port: the LED port number printed on the board. First port is 1. No zeros. + device_num: LED position in the change, First LED is 1. No zeros. + name: used for config error logging + ports_on_breakout: configurations from the exp board breakout, using 0 based port numbers 0-7 + + Returns: FAST hex string for the LED + """ + port_offset = sum([pc['led_count'] for pc in ports_on_breakout if pc['normalized_port'] % 4 < port - 1]) + total_on_port = next(filter(lambda pc, p=port - 1: + pc['normalized_port'] % 4 == p, ports_on_breakout))['led_count'] + + if device_num > total_on_port: + if name: + self.raise_config_error(f"Device number {device_num} exceeds the number of devices per port " + f"({total_on_port}) for LED {name}", 9) + else: + raise AssertionError(f"Device number {device_num} exceeds the number of devices per port " + f"({total_on_port})") + + actual_position = port_offset + device_num - 1 + + return f'{(actual_position):02X}' + + # pylint: disable-msg=too-many-arguments + def port_idx_to_hex(self, port, device_num, devices_per_port, name=None, ports_on_breakout=None): """Converts port number and LED index into the proper FAST hex number. port: the LED port number printed on the board. First port is 1. No zeros. device_num: LED position in the change, First LED is 1. No zeros. devices_per_port: number of LEDs per port. Typically 32. name: used for config error logging + ports_on_breakout: ports on breakout with number 0-7 Returns: FAST hex string for the LED """ @@ -696,6 +732,9 @@ def port_idx_to_hex(self, port, device_num, devices_per_port, name=None): if port < 1: raise AssertionError(f"Port {port} is not valid for device {device_num}") + if ports_on_breakout: + return self.port_idx_to_hex_with_configurations(port, device_num, ports_on_breakout, name) + if device_num > devices_per_port: if name: self.raise_config_error(f"Device number {device_num} exceeds the number of devices per port " diff --git a/mpf/platforms/fast/fast_defines.py b/mpf/platforms/fast/fast_defines.py index 7e01eb65e..0f3e64cb2 100644 --- a/mpf/platforms/fast/fast_defines.py +++ b/mpf/platforms/fast/fast_defines.py @@ -88,7 +88,7 @@ }, 'FP-EXP-0081': { 'min_fw': '0.11', - 'led_ports': 4, + 'led_ports': 8, }, 'FP-EXP-0091': { 'min_fw': '0.11', diff --git a/mpf/platforms/fast/fast_exp_board.py b/mpf/platforms/fast/fast_exp_board.py index b821cf683..3160a4a7a 100644 --- a/mpf/platforms/fast/fast_exp_board.py +++ b/mpf/platforms/fast/fast_exp_board.py @@ -7,6 +7,7 @@ from packaging import version +from mpf.core.utility_functions import Util from mpf.platforms.fast.fast_defines import (BREAKOUT_FEATURES, EXPANSION_BOARD_FEATURES) @@ -17,7 +18,8 @@ class FastExpansionBoard: # pylint: disable-msg=too-many-instance-attributes __slots__ = ["name", "communicator", "config", "platform", "log", "address", "model", "features", "breakouts", - "breakouts_with_leds", "firmware_version", "hw_verified", "led_fade_rate"] + "breakouts_with_leds", "firmware_version", "hw_verified", "led_fade_rate", + "led_ports", "led_port_configurations"] def __init__(self, name: str, communicator, address: str, config: dict) -> None: """Initializes a FAST Expansion Board. @@ -51,6 +53,7 @@ def __init__(self, name: str, communicator, address: str, config: dict) -> None: self.features = EXPANSION_BOARD_FEATURES[self.model] # ([local model numbers,], num of remotes) tuple self.breakouts = dict() self.breakouts_with_leds = list() + self.led_port_configurations = list() if self.config['led_hz'] > 31.25: self.config['led_hz'] = 31.25 @@ -67,6 +70,173 @@ def __init__(self, name: str, communicator, address: str, config: dict) -> None: self.create_breakout(brk) + # # pylint: disable-msg=too-many-locals + # def create_led_ports(self): + # """Parse the LED port overrides and create port configurations.""" + # led_port_configurations = [[], []] # grouped into breakout 0 and 1 + # for led_port in self.config['led_ports']: + # normalized_port_number = int(led_port['port']) - 1 + # port_config = { + # 'normalized_port': normalized_port_number, + # 'led_count': int(led_port['leds']) + # } + # if normalized_port_number < 4: + # led_port_configurations[0].append(port_config) + # else: + # led_port_configurations[1].append(port_config) + + # breakout_led_group_number = -1 + # final_configurations = [] + # for port_group_configurations in led_port_configurations: + # breakout_led_group_number += 1 + # if len(port_group_configurations) == 0: + # continue # no need to configure if no overrides are made in the breakout group + + # total_leds = sum(map(lambda item: item['led_count'], port_group_configurations)) + + # unclaimed_count = 128 - total_leds + # if unclaimed_count < 0: # each breakout supports 128 lights total + # self.log.error(f"Error configuring FAST EXP {self.address} breakout leds : " + # f"{total_leds} total assigned but only 128 allowed per block of 4 ports") + + # addresses = [ + # breakout_led_group_number * 4 + 0, + # breakout_led_group_number * 4 + 1, + # breakout_led_group_number * 4 + 2, + # breakout_led_group_number * 4 + 3 + # ] + # prepared_sets = [] + # attempts = 0 + # leds_claimed = 0 + # while len(addresses) > 0: + # attempts += 1 + # address = addresses.pop(0) + # config_data = next(filter( + # lambda x, a=address: x['normalized_port'] == a, + # port_group_configurations), None) + # if config_data: + # leds_claimed += config_data['led_count'] + # prepared_sets.append(config_data) + # else: + # if attempts <= 4: + # addresses.append(address) # put at end for retry after defined set + # else: + # # claim 32 or whatever is left, never less than 0 + # usable_leds = max(min(32, 128 - leds_claimed), 0) + # leds_claimed += usable_leds + # prepared_sets.append({'normalized_port': address, 'led_count': usable_leds}) + + # led_offset = 0 + # sorted_configs = sorted(prepared_sets, key=lambda x: x['normalized_port']) + # for port_configuration in sorted_configs: + # number = port_configuration['normalized_port'] % 4 + # chain_type = '0' + # start = led_offset + # count = port_configuration['led_count'] + # led_offset += count + # if start <= 128: # start at 128 for 0 lights is a possible case + # hex_start = Util.int_to_hex_string(start) + # hex_count = Util.int_to_hex_string(count) + # breakout_address = f'{self.address}{breakout_led_group_number}' + # message = f'ER@{breakout_address}:{number},{chain_type},{hex_start},{hex_count}' + # self.log.info(message) + # self.communicator.send_with_confirmation(message, 'ER:P') + # final_configurations.append(prepared_sets) + # self.led_port_configurations = final_configurations + + def create_led_ports(self): + """Parse LED port overrides and program EXP LED groups (4 ports per group, 128 LEDs max per group).""" + # Collect overrides as normalized 0..7 ports: [{'normalized_port': N, 'led_count': C}, ...] + led_port_configurations = [[], []] # two groups of 4 ports each + for led_port in self.config['led_ports']: + normalized_port_number = int(led_port['port']) - 1 # YAML is 1-based + port_config = { + 'normalized_port': normalized_port_number, + 'led_count': int(led_port['leds']) + } + if normalized_port_number < 4: + led_port_configurations[0].append(port_config) + else: + led_port_configurations[1].append(port_config) + + breakout_led_group_number = -1 + final_configurations = [] + + for port_group_configurations in led_port_configurations: + breakout_led_group_number += 1 + + # Skip empty group (no overrides provided for this group) + if not port_group_configurations: + continue + + # Validate per-group budget (128 LEDs) + total_leds = sum(item['led_count'] for item in port_group_configurations) + unclaimed_count = 128 - total_leds + if unclaimed_count < 0: + self.log.error( + "Error configuring FAST EXP %s group %s: %s total assigned but only 128 allowed per block of 4 ports", + self.address, breakout_led_group_number, total_leds + ) + + # Build a complete ordered list for the group's 4 addresses (0..3), inserting overrides and auto-filling gaps + addresses = [ + breakout_led_group_number * 4 + 0, + breakout_led_group_number * 4 + 1, + breakout_led_group_number * 4 + 2, + breakout_led_group_number * 4 + 3 + ] + prepared_sets = [] + attempts = 0 + leds_claimed = 0 + + while addresses: + attempts += 1 + address = addresses.pop(0) + config_data = next((x for x in port_group_configurations if x['normalized_port'] == address), None) + if config_data: + leds_claimed += config_data['led_count'] + prepared_sets.append(config_data) + else: + if attempts <= 4: + # rotate once to let defined ports land first + addresses.append(address) + else: + # auto-claim remaining LEDs up to 32 on this port (never negative) + usable_leds = max(min(32, 128 - leds_claimed), 0) + leds_claimed += usable_leds + prepared_sets.append({'normalized_port': address, 'led_count': usable_leds}) + + # Program ports in numeric order; compute running start offset within the 128-LED group + led_offset = 0 + sorted_configs = sorted(prepared_sets, key=lambda x: x['normalized_port']) + self.log.info("[G%d] LEDCFG:G%d overrides=%s total_leds=%d", + breakout_led_group_number, breakout_led_group_number, + port_group_configurations, sum(p['led_count'] for p in port_group_configurations)) + self.log.info("[G%d] LEDCFG:G%d prepared=%s", breakout_led_group_number, breakout_led_group_number, sorted_configs) + + for port_configuration in sorted_configs: + number = port_configuration['normalized_port'] % 4 # 0..3 inside this group + chain_type = '0' # native FAST chain type for EXP LEDs + start = led_offset + count = port_configuration['led_count'] + led_offset += count + + # Start can be 128 when a port gets 0 LEDs (allowed) + if start <= 128: + hex_start = Util.int_to_hex_string(start) + hex_count = Util.int_to_hex_string(count) + breakout_address = f'{self.address}{breakout_led_group_number}' # e.g. '84' + '1' => '841' + msg = f'ER@{breakout_address}:{number},{chain_type},{hex_start},{hex_count}' + self.log.info("[G%d] ER msg='%s' exists=%s", + breakout_led_group_number, msg, True) + # Send and wait for ER:P ACK so programming is deterministic + self.communicator.send_with_confirmation(msg, 'ER:P') + + final_configurations.append(sorted_configs) + + self.led_port_configurations = final_configurations + + def create_breakout(self, config: dict) -> None: """Define a breakout board within an EXP board.""" if BREAKOUT_FEATURES[config['model']].get('device_class'): @@ -140,6 +310,8 @@ async def reset(self): """Send a reset command to the EXP board.""" await self.communicator.send_and_wait_for_response_processed(f'BR@{self.address}:', 'BR:P') + self.create_led_ports() + # TODO move this to mixin classes for device types? if self.config['led_fade_time']: self.set_led_fade(self.config['led_fade_time']) @@ -170,7 +342,6 @@ def update_leds(self): msg += f'{led_num[3:]}{color}' log_msg = f'RD@{breakout_address}:{msg}' # pretty version of the message for the log - try: self.communicator.send_bytes(b16decode(f'{msg_header}{msg}'), log_msg) except binasciiError as e: diff --git a/mpf/tests/machine_files/fast/config/exp.yaml b/mpf/tests/machine_files/fast/config/exp.yaml index 9437cfe7b..3def74c53 100644 --- a/mpf/tests/machine_files/fast/config/exp.yaml +++ b/mpf/tests/machine_files/fast/config/exp.yaml @@ -14,13 +14,11 @@ fast: model: FP-BRK-0001 - port: 2 model: FP-DRV-0800 - led_ports: - - port: 1 - leds: 32 - type: ws2812 - - port: 2 - leds: 32 - type: apa-102 + # led_ports: + # - port: 1 + # leds: 32 + # - port: 2 + # leds: 32 led_hz: 30 aaron: model: FP-EXP-0091-2 # test hw revision number included @@ -29,24 +27,33 @@ fast: breakouts: - port: 2 model: FP-BRK-0001 - led_ports: - - port: 1 - leds: 32 - type: ws2812 - - port: 2 - leds: 32 - type: apa-102 + # led_ports: + # - port: 1 + # leds: 32 + # - port: 2 + # leds: 32 dave: model: FP-EXP-0071 + # led_ports: + # - port: 1 + # leds: 32 + # - port: 2 + # leds: 32 + eli: + model: FP-EXP-0081-1 # test including hw revision number led_ports: - port: 1 - leds: 32 - type: ws2812 + leds: 16 #give up 16 - port: 2 - leds: 32 - type: apa-102 - eli: - model: FP-EXP-0081-1 # test including hw revision number + leds: 50 #take 16 from 1 and 2 from 3 + # port 3 left as default to prove automatic gapping works, should have 8 + - port: 4 + leds: 54 #take 22 from 3 + + - port: 6 + leds: 64 #take 32 from 1 + - port: 7 + leds: 64 #take 32 from 8 neuron: model: FP-EXP-2000 breakouts: @@ -94,7 +101,7 @@ lights: led18: number: dave-4-11 led19: - number: eli-8-1 # 84160 + number: eli-7-64 # 8417f led20: number: neuron-1-1 led21: @@ -105,13 +112,13 @@ lights: led23: # previous numbering, RGB LED previous: led22 # 48003-0, 48003-1, 48003-2 type: rgb - led24: # start channel, RGBW LED + led24: # start channel, RGBW LED using only RGB channels start_channel: neuron-1-5-0 #48004-0, 48004-1, 48004-2, 48005-0 type: rgbw - led25: # start channel, RGBW LED + led25: # start channel, RGBW LED using only RGB channels start_channel: neuron-1-6-1 # 48005-1, 48005-2, 48006-0, 48006-1 type: rgbw - led26: # previous numbering, RGBW LED + led26: # previous numbering, RGBW LED using only RGB channels previous: led25 # 48006-2, 48007-0, 48007-1, 48007-2 type: rgbw led27: # make sure we can get back to normal diff --git a/mpf/tests/machine_files/shots/config/test_shots.yaml b/mpf/tests/machine_files/shots/config/test_shots.yaml index 46677421f..a66c84f63 100644 --- a/mpf/tests/machine_files/shots/config/test_shots.yaml +++ b/mpf/tests/machine_files/shots/config/test_shots.yaml @@ -60,6 +60,8 @@ switches: number: switch_28: number: + switch_29: + number: lights: light_1: diff --git a/mpf/tests/machine_files/shots/modes/base2/config/base2.yaml b/mpf/tests/machine_files/shots/modes/base2/config/base2.yaml index cf82e013d..604ccce38 100644 --- a/mpf/tests/machine_files/shots/modes/base2/config/base2.yaml +++ b/mpf/tests/machine_files/shots/modes/base2/config/base2.yaml @@ -36,6 +36,10 @@ shots: switch: switch_15 delay_switch: switch_15: 2s + shot_delay_event: + switch: switch_29 + delay_event_list: + my_delay_event: 2s default_show_light: switch: switch_5 show_tokens: diff --git a/mpf/tests/test_Fast_Exp.py b/mpf/tests/test_Fast_Exp.py index 3ed26ef53..23d690099 100644 --- a/mpf/tests/test_Fast_Exp.py +++ b/mpf/tests/test_Fast_Exp.py @@ -17,29 +17,49 @@ def create_expected_commands(self): # These are all the defaults based on the config file for this test. # Individual tests can override / add as needed - self.serial_connections['exp'].expected_commands = {'RA@880:000000': '', - 'RA@881:000000': '', - 'RA@882:000000': '', - 'RA@890:000000': '', - 'RA@892:000000': '', - 'RA@B40:000000': '', - 'RA@840:000000': '', - 'RA@841:000000': '', - 'RA@480:000000': '', - 'RA@481:000000': '', - 'RA@482:000000': '', - 'RF@89:5DC': '', - 'EM@B40:0,1,7D0,1F4,9C4,5DC': '', - 'EM@B40:1,1,7D0,3E8,7D0,5DC': '', - 'EM@882:7,1,7D0,3E8,7D0,5DC': '', - 'MP@B40:0,7F,7D0': '', - 'MP@B40:1,7F,7D0': '', - 'MP@882:7,7F,7D0': '',} + self.serial_connections['exp'].expected_commands = { + # ER=configure non-default headers + # ER:,,, + 'ER@840:0,0,00,10': '', #16 on port 1 + 'ER@840:1,0,10,32': '', #50 on port 2 + 'ER@840:2,0,42,08': '', # 8 on port 3 + 'ER@840:3,0,4A,36': '', #54 on port 4 + 'ER@841:0,0,00,00': '', # 0 on port 5 + 'ER@841:1,0,00,40': '', #64 on port 6 + 'ER@841:2,0,40,40': '', #64 on port 7 + 'ER@841:3,0,80,00': '', # 0 on port 8 + + #RA=set all on breakout to color + 'RA@880:000000': '', + 'RA@881:000000': '', + 'RA@882:000000': '', + 'RA@890:000000': '', + 'RA@892:000000': '', + 'RA@B40:000000': '', + 'RA@840:000000': '', + 'RA@841:000000': '', + 'RA@480:000000': '', + 'RA@481:000000': '', + 'RA@482:000000': '', + + #RF=set default fade rate + 'RF@89:5DC': '', + + #EM=configure motor + 'EM@B40:0,1,7D0,1F4,9C4,5DC': '', + 'EM@B40:1,1,7D0,3E8,7D0,5DC': '', + 'EM@882:7,1,7D0,3E8,7D0,5DC': '', + + #MP=set motor position + 'MP@B40:0,7F,7D0': '', + 'MP@B40:1,7F,7D0': '', + 'MP@882:7,7F,7D0': '', + } def test_servo(self): # go to min position self.exp_cpu.expected_commands = { - "MP@B40:0,00,7D0": "" # MP:,, + "MP@B40:0,00,7D0": "" # MP:,, } self.machine.servos["servo1"].go_to_position(0) self.advance_time_and_run(1) @@ -133,9 +153,10 @@ def _test_led_internals(self): def _test_led_colors(self): self.exp_cpu.expected_commands = { - 'RD@880:0201ff123402121212': '', + 'RD@880:0201ff123402121212': '', #RD=set individual leds by binary 'RD@881:0100ffffff': '', - 'RD@841:0160ffffff': ','} + 'RD@841:017fffffff': ',' + } self.led1.on() self.led2.color("ff1234") @@ -172,7 +193,8 @@ def _test_exp_board_reset(self): self.exp_cpu.expected_commands = { 'RD@881:0100ff1234': '', 'RD@880:0102467fff': '', - 'RD@B40:016a6a6a6a': '',} + 'RD@B40:016a6a6a6a': '', + } self.led1.color("ff1234") self.led3.color("467fff") @@ -218,11 +240,13 @@ def _test_led_channels(self): def _test_led_software_fade(self): - self.exp_cpu.expected_commands = {'RD@B40:0169151515': '', - 'RD@B40:01692b2b2b': '', - 'RD@B40:0169424242': '', - 'RD@B40:0169585858': '', - 'RD@B40:0169646464': '',} + self.exp_cpu.expected_commands = { + 'RD@B40:0169151515': '', + 'RD@B40:01692b2b2b': '', + 'RD@B40:0169424242': '', + 'RD@B40:0169585858': '', + 'RD@B40:0169646464': '', + } self.led17.color(RGBColor((100, 100, 100)), fade_ms=150) self.advance_time_and_run(.04) @@ -238,4 +262,4 @@ def _test_lew_hardware_fade(self): # This is also tested via the config file and the expected commands self.exp_cpu.expected_commands = {'RF@88:3E8': '',} self.machine.default_platform.exp_boards_by_name["brian"].set_led_fade(1000) - self.advance_time_and_run() \ No newline at end of file + self.advance_time_and_run() diff --git a/mpf/tests/test_Shots.py b/mpf/tests/test_Shots.py index c48360421..2e78be845 100644 --- a/mpf/tests/test_Shots.py +++ b/mpf/tests/test_Shots.py @@ -222,6 +222,7 @@ def test_shot_with_multiple_switches(self): def test_shot_with_delay(self): self.mock_event("shot_delay_hit") self.mock_event("shot_delay_same_switch_hit") + self.mock_event("shot_delay_event_hit") self.start_game() # test delay at the beginning. should not count @@ -247,6 +248,14 @@ def test_shot_with_delay(self): self.assertEventCalled("shot_delay_same_switch_hit") self.mock_event("shot_delay_same_switch_hit") + # test delay with event. should not count + self.post_event("my_delay_event") + self.advance_time_and_run(.5) + self.hit_and_release_switch("switch_29") + self.advance_time_and_run(1) + self.assertEventNotCalled("shot_delay_event_hit") + self.advance_time_and_run(3) + # test that shot works without delay self.hit_and_release_switch("switch_1") self.advance_time_and_run(.5) @@ -256,6 +265,11 @@ def test_shot_with_delay(self): self.hit_and_release_switch("switch_1") self.advance_time_and_run(.5) self.assertEventCalled("shot_delay_hit") + + self.hit_and_release_switch("switch_29") + self.advance_time_and_run(.5) + self.assertEventCalled("shot_delay_event_hit") + self.mock_event("shot_delay_hit") self.hit_and_release_switch("s_delay")