From 761801b3143b6d90457b2f48efff3906ebaedc3a Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Fri, 23 May 2025 21:10:03 -0700 Subject: [PATCH 01/15] add test case for fast exp led_ports with redistributed counts per header --- mpf/tests/machine_files/fast/config/exp.yaml | 21 +++++++++++++++++--- mpf/tests/test_Fast_Exp.py | 13 ++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/mpf/tests/machine_files/fast/config/exp.yaml b/mpf/tests/machine_files/fast/config/exp.yaml index 9437cfe7b..79b2192e2 100644 --- a/mpf/tests/machine_files/fast/config/exp.yaml +++ b/mpf/tests/machine_files/fast/config/exp.yaml @@ -47,6 +47,21 @@ fast: type: apa-102 eli: model: FP-EXP-0081-1 # test including hw revision number + led_ports: + - port: 1 + leds: 16 #give up 16 + - port: 2 + leds: 50 #take 16 from 1 and 2 from 3 + # port 3 left as default to prove automatic gapping works + - port: 4 + leds: 72 #take 30 from 3 + + - port: 5 + leds: 0 #give up all + - port: 6 + leds: 64 #take 32 from 1 + - port: 7 #give up all 32 + leds: 64 #take 32 from 8 neuron: model: FP-EXP-2000 breakouts: @@ -105,13 +120,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/test_Fast_Exp.py b/mpf/tests/test_Fast_Exp.py index 3ed26ef53..c3b9b2c8a 100644 --- a/mpf/tests/test_Fast_Exp.py +++ b/mpf/tests/test_Fast_Exp.py @@ -17,7 +17,7 @@ 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': '', + self.serial_connections['exp'].expected_commands = {'RA@880:000000': '', #RA=set all on breakout to color 'RA@881:000000': '', 'RA@882:000000': '', 'RA@890:000000': '', @@ -28,13 +28,14 @@ def create_expected_commands(self): 'RA@480:000000': '', 'RA@481:000000': '', 'RA@482:000000': '', - 'RF@89:5DC': '', - 'EM@B40:0,1,7D0,1F4,9C4,5DC': '', + 'RF@89:5DC': '', #RF=set default fade rate + 'EM@B40:0,1,7D0,1F4,9C4,5DC': '', #EM=configure motor 'EM@B40:1,1,7D0,3E8,7D0,5DC': '', 'EM@882:7,1,7D0,3E8,7D0,5DC': '', - 'MP@B40:0,7F,7D0': '', + 'MP@B40:0,7F,7D0': '', #MP=set motor position 'MP@B40:1,7F,7D0': '', - 'MP@882:7,7F,7D0': '',} + 'MP@882:7,7F,7D0': '', + } def test_servo(self): # go to min position @@ -133,7 +134,7 @@ 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': ','} From 531db122f77327b4d75dcde728e6bf92bf7e9c02 Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Sat, 24 May 2025 19:34:12 -0700 Subject: [PATCH 02/15] fast exp 0081 has 8 light headers, not 4 --- mpf/platforms/fast/fast_defines.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From 416a5f76633efd8fca0aedb8b07703a70be4c97c Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Sat, 24 May 2025 19:39:58 -0700 Subject: [PATCH 03/15] remove type field from FAST EXP light port configuration - apa-102 is not yet supported and rgb and rgbw (ws2812 and sk6812) can be mixed in on a single port without any ordering issue (if configured correctly) so there is no need to distinguish at the header level for them --- mpf/config_spec.yaml | 1 - mpf/tests/machine_files/fast/config/exp.yaml | 6 ------ 2 files changed, 7 deletions(-) diff --git a/mpf/config_spec.yaml b/mpf/config_spec.yaml index 66fa47500..a01961248 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 diff --git a/mpf/tests/machine_files/fast/config/exp.yaml b/mpf/tests/machine_files/fast/config/exp.yaml index 79b2192e2..4d0864aca 100644 --- a/mpf/tests/machine_files/fast/config/exp.yaml +++ b/mpf/tests/machine_files/fast/config/exp.yaml @@ -17,10 +17,8 @@ fast: led_ports: - port: 1 leds: 32 - type: ws2812 - port: 2 leds: 32 - type: apa-102 led_hz: 30 aaron: model: FP-EXP-0091-2 # test hw revision number included @@ -32,19 +30,15 @@ fast: led_ports: - port: 1 leds: 32 - type: ws2812 - port: 2 leds: 32 - type: apa-102 dave: model: FP-EXP-0071 led_ports: - port: 1 leds: 32 - type: ws2812 - port: 2 leds: 32 - type: apa-102 eli: model: FP-EXP-0081-1 # test including hw revision number led_ports: From 6b39cfbb5619b203ba983208e4a781fa08db3ea8 Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Sat, 24 May 2025 19:43:28 -0700 Subject: [PATCH 04/15] reformat fast exp test --- mpf/tests/test_Fast_Exp.py | 61 +++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/mpf/tests/test_Fast_Exp.py b/mpf/tests/test_Fast_Exp.py index c3b9b2c8a..cf7f9faa1 100644 --- a/mpf/tests/test_Fast_Exp.py +++ b/mpf/tests/test_Fast_Exp.py @@ -17,30 +17,31 @@ 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=set all on breakout to color - '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': '', #RF=set default fade rate - 'EM@B40:0,1,7D0,1F4,9C4,5DC': '', #EM=configure motor - 'EM@B40:1,1,7D0,3E8,7D0,5DC': '', - 'EM@882:7,1,7D0,3E8,7D0,5DC': '', - 'MP@B40:0,7F,7D0': '', #MP=set motor position - 'MP@B40:1,7F,7D0': '', - 'MP@882:7,7F,7D0': '', - } + self.serial_connections['exp'].expected_commands = { + 'RA@880:000000': '', #RA=set all on breakout to color + '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': '', #RF=set default fade rate + 'EM@B40:0,1,7D0,1F4,9C4,5DC': '', #EM=configure motor + 'EM@B40:1,1,7D0,3E8,7D0,5DC': '', + 'EM@882:7,1,7D0,3E8,7D0,5DC': '', + 'MP@B40:0,7F,7D0': '', #MP=set motor position + '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) @@ -136,7 +137,8 @@ def _test_led_colors(self): self.exp_cpu.expected_commands = { 'RD@880:0201ff123402121212': '', #RD=set individual leds by binary 'RD@881:0100ffffff': '', - 'RD@841:0160ffffff': ','} + 'RD@841:0160ffffff': ',' + } self.led1.on() self.led2.color("ff1234") @@ -173,7 +175,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") @@ -219,11 +222,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) @@ -239,4 +244,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() From 137915cbcffc5969c32c5ab7b5b1b15620a88737 Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Mon, 26 May 2025 03:44:35 -0700 Subject: [PATCH 05/15] fast exp led_ports option can be used to set nonstandard chain settings --- mpf/platforms/fast/fast_exp_board.py | 72 +++++++++++++++++++- mpf/tests/machine_files/fast/config/exp.yaml | 38 +++++------ mpf/tests/test_Fast_Exp.py | 26 +++++-- 3 files changed, 111 insertions(+), 25 deletions(-) diff --git a/mpf/platforms/fast/fast_exp_board.py b/mpf/platforms/fast/fast_exp_board.py index b821cf683..a89c4c0db 100644 --- a/mpf/platforms/fast/fast_exp_board.py +++ b/mpf/platforms/fast/fast_exp_board.py @@ -4,6 +4,7 @@ from base64 import b16decode from binascii import Error as binasciiError from importlib import import_module +from mpf.core.utility_functions import Util from packaging import version @@ -17,7 +18,7 @@ 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"] def __init__(self, name: str, communicator, address: str, config: dict) -> None: """Initializes a FAST Expansion Board. @@ -67,6 +68,75 @@ def __init__(self, name: str, communicator, address: str, config: dict) -> None: self.create_breakout(brk) + self.create_led_ports() + + def create_led_ports(self): + # create the led ports + 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 + 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 : {total_leds} total assigned but only 128 allowed per block of 4 ports") + return + + 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: x['normalized_port'] == address, 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: + usable_leds = max(min(32, 128 - leds_claimed), 0) #claim 32 or whatever is left, never less than 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'] + type = '0' + start = led_offset + count = port_configuration['led_count'] + led_offset += count + message = f'ER@{self.address}:{number},{type},{Util.int_to_hex_string(start)},{Util.int_to_hex_string(count)}' + if start < 128: # start at 128 for 0 lights is a possible case that we skip + self.log.info(message) + self.communicator.send_with_confirmation(message, 'ER:P') + msg2 = f'RA@{self.address}:ffffff' + self.log.info(msg2) + self.communicator.send_and_forget(msg2) + def create_breakout(self, config: dict) -> None: """Define a breakout board within an EXP board.""" if BREAKOUT_FEATURES[config['model']].get('device_class'): diff --git a/mpf/tests/machine_files/fast/config/exp.yaml b/mpf/tests/machine_files/fast/config/exp.yaml index 4d0864aca..79e890a67 100644 --- a/mpf/tests/machine_files/fast/config/exp.yaml +++ b/mpf/tests/machine_files/fast/config/exp.yaml @@ -14,11 +14,11 @@ fast: model: FP-BRK-0001 - port: 2 model: FP-DRV-0800 - led_ports: - - port: 1 - leds: 32 - - port: 2 - leds: 32 + # led_ports: + # - port: 1 + # leds: 32 + # - port: 2 + # leds: 32 led_hz: 30 aaron: model: FP-EXP-0091-2 # test hw revision number included @@ -27,18 +27,18 @@ fast: breakouts: - port: 2 model: FP-BRK-0001 - led_ports: - - port: 1 - leds: 32 - - port: 2 - leds: 32 + # 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 + # led_ports: + # - port: 1 + # leds: 32 + # - port: 2 + # leds: 32 eli: model: FP-EXP-0081-1 # test including hw revision number led_ports: @@ -46,15 +46,13 @@ fast: leds: 16 #give up 16 - port: 2 leds: 50 #take 16 from 1 and 2 from 3 - # port 3 left as default to prove automatic gapping works + # port 3 left as default to prove automatic gapping works, should have 8 - port: 4 - leds: 72 #take 30 from 3 + leds: 54 #take 22 from 3 - - port: 5 - leds: 0 #give up all - port: 6 leds: 64 #take 32 from 1 - - port: 7 #give up all 32 + - port: 7 leds: 64 #take 32 from 8 neuron: model: FP-EXP-2000 diff --git a/mpf/tests/test_Fast_Exp.py b/mpf/tests/test_Fast_Exp.py index cf7f9faa1..1716075f3 100644 --- a/mpf/tests/test_Fast_Exp.py +++ b/mpf/tests/test_Fast_Exp.py @@ -18,7 +18,19 @@ def create_expected_commands(self): # Individual tests can override / add as needed self.serial_connections['exp'].expected_commands = { - 'RA@880:000000': '', #RA=set all on breakout to color + # ER=configure non-default headers + # ER:,,, + 'ER@84:0,0,00,10': '', #16 on port 1 + 'ER@84:1,0,10,32': '', #50 on port 2 + 'ER@84:2,0,42,08': '', # 8 on port 3 + 'ER@84:3,0,4A,36': '', #54 on port 4 + 'ER@84:4,0,00,00': '', # 0 on port 5 + 'ER@84:5,0,00,40': '', #64 on port 6 + 'ER@84:6,0,40,40': '', #64 on port 7 + 'ER@84:7,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': '', @@ -29,11 +41,17 @@ def create_expected_commands(self): 'RA@480:000000': '', 'RA@481:000000': '', 'RA@482:000000': '', - 'RF@89:5DC': '', #RF=set default fade rate - 'EM@B40:0,1,7D0,1F4,9C4,5DC': '', #EM=configure motor + + #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@B40:0,7F,7D0': '', #MP=set motor position + + #MP=set motor position + 'MP@B40:0,7F,7D0': '', 'MP@B40:1,7F,7D0': '', 'MP@882:7,7F,7D0': '', } From dbf70f96b3f5b03455fb3cd02217770bb3c76c53 Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Mon, 26 May 2025 19:42:46 -0700 Subject: [PATCH 06/15] scratch solution --- mpf/platforms/fast/fast_exp_board.py | 49 ++++++++++++++++------------ mpf/tests/test_Fast_Exp.py | 16 ++++----- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/mpf/platforms/fast/fast_exp_board.py b/mpf/platforms/fast/fast_exp_board.py index a89c4c0db..64f897977 100644 --- a/mpf/platforms/fast/fast_exp_board.py +++ b/mpf/platforms/fast/fast_exp_board.py @@ -4,10 +4,10 @@ from base64 import b16decode from binascii import Error as binasciiError from importlib import import_module -from mpf.core.utility_functions import Util from packaging import version +from mpf.core.utility_functions import Util from mpf.platforms.fast.fast_defines import (BREAKOUT_FEATURES, EXPANSION_BOARD_FEATURES) @@ -68,11 +68,11 @@ def __init__(self, name: str, communicator, address: str, config: dict) -> None: self.create_breakout(brk) - self.create_led_ports() - + # pylint: disable-msg=too-many-locals + # pylint: disable-msg=line-too-long def create_led_ports(self): - # create the led ports - led_port_configurations = [[], []] #grouped into breakout 0 and 1 + """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 = { @@ -88,14 +88,14 @@ def create_led_ports(self): 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 + 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 : {total_leds} total assigned but only 128 allowed per block of 4 ports") - return + 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, @@ -109,33 +109,39 @@ def create_led_ports(self): while len(addresses) > 0: attempts += 1 address = addresses.pop(0) - config_data = next(filter(lambda x: x['normalized_port'] == address, port_group_configurations), None) + 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 + addresses.append(address) # put at end for retry after defined set else: - usable_leds = max(min(32, 128 - leds_claimed), 0) #claim 32 or whatever is left, never less than 0 + # 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']) + sorted_configs = sorted(prepared_sets, key=lambda x: x['normalized_port']) for port_configuration in sorted_configs: - number = port_configuration['normalized_port'] - type = '0' + number = port_configuration['normalized_port'] % 4 + chain_type = '0' start = led_offset count = port_configuration['led_count'] led_offset += count - message = f'ER@{self.address}:{number},{type},{Util.int_to_hex_string(start)},{Util.int_to_hex_string(count)}' - if start < 128: # start at 128 for 0 lights is a possible case that we skip + if start < 129: # 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') - msg2 = f'RA@{self.address}:ffffff' - self.log.info(msg2) - self.communicator.send_and_forget(msg2) + # msg2 = f'RA@{self.address}:ffffff' + # self.log.info(msg2) + # self.communicator.send_and_forget(msg2) def create_breakout(self, config: dict) -> None: """Define a breakout board within an EXP board.""" @@ -210,6 +216,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']) @@ -241,6 +249,7 @@ def update_leds(self): log_msg = f'RD@{breakout_address}:{msg}' # pretty version of the message for the log + self.log.warning(log_msg) try: self.communicator.send_bytes(b16decode(f'{msg_header}{msg}'), log_msg) except binasciiError as e: diff --git a/mpf/tests/test_Fast_Exp.py b/mpf/tests/test_Fast_Exp.py index 1716075f3..e444c0a8e 100644 --- a/mpf/tests/test_Fast_Exp.py +++ b/mpf/tests/test_Fast_Exp.py @@ -20,14 +20,14 @@ def create_expected_commands(self): self.serial_connections['exp'].expected_commands = { # ER=configure non-default headers # ER:,,, - 'ER@84:0,0,00,10': '', #16 on port 1 - 'ER@84:1,0,10,32': '', #50 on port 2 - 'ER@84:2,0,42,08': '', # 8 on port 3 - 'ER@84:3,0,4A,36': '', #54 on port 4 - 'ER@84:4,0,00,00': '', # 0 on port 5 - 'ER@84:5,0,00,40': '', #64 on port 6 - 'ER@84:6,0,40,40': '', #64 on port 7 - 'ER@84:7,0,80,00': '', # 0 on port 8 + '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': '', From c91603881e16aff136dea9775d5ba388d92208fd Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Tue, 27 May 2025 02:06:43 -0700 Subject: [PATCH 07/15] attempt to obey led port configurations when available to handle fast led device addressing --- mpf/platforms/fast/fast.py | 14 ++++++++++++-- mpf/platforms/fast/fast_exp_board.py | 5 ++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/mpf/platforms/fast/fast.py b/mpf/platforms/fast/fast.py index 9bc02bcc6..03b404837 100644 --- a/mpf/platforms/fast/fast.py +++ b/mpf/platforms/fast/fast.py @@ -626,7 +626,7 @@ 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) + index = self.port_idx_to_hex(port, led, 32, config.name, port_configurations=exp_board.led_port_configurations) 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,7 +676,7 @@ 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(self, port, device_num, devices_per_port, name=None, port_configurations=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. @@ -696,6 +696,16 @@ 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 port_configurations: + # port is 1-indexed, configs are 0-indexed + + filtered_sum = sum([x['led_count'] for x in port_configurations if x['normalized_port'] < p]) + + port_offset = sum(map(filter(lambda x, p=port-1: x['normalized_port'] < p, port_configurations))) + device_num = device_num - 1 + actual_position = port_offset + device_num + return f'{(actual_position):02X}' + 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_exp_board.py b/mpf/platforms/fast/fast_exp_board.py index 64f897977..d39f67cb4 100644 --- a/mpf/platforms/fast/fast_exp_board.py +++ b/mpf/platforms/fast/fast_exp_board.py @@ -18,7 +18,7 @@ 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", "led_ports"] + "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. @@ -85,6 +85,7 @@ def create_led_ports(self): 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: @@ -142,6 +143,8 @@ def create_led_ports(self): # msg2 = f'RA@{self.address}:ffffff' # self.log.info(msg2) # self.communicator.send_and_forget(msg2) + final_configurations.append(prepared_sets) + self.led_port_configurations = final_configurations def create_breakout(self, config: dict) -> None: """Define a breakout board within an EXP board.""" From 764f355d6d478a6732adb980dd1b4b74acbdbad9 Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Tue, 27 May 2025 17:47:20 -0700 Subject: [PATCH 08/15] wip - support led numbering matching custom led count configuration --- mpf/platforms/fast/fast.py | 51 +++++++++++++++----- mpf/platforms/fast/fast_exp_board.py | 12 ++--- mpf/tests/machine_files/fast/config/exp.yaml | 2 +- mpf/tests/test_Fast_Exp.py | 2 +- 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/mpf/platforms/fast/fast.py b/mpf/platforms/fast/fast.py index 03b404837..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) + 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, port_configurations=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,15 +732,8 @@ def port_idx_to_hex(self, port, device_num, devices_per_port, name=None, port_co if port < 1: raise AssertionError(f"Port {port} is not valid for device {device_num}") - if port_configurations: - # port is 1-indexed, configs are 0-indexed - - filtered_sum = sum([x['led_count'] for x in port_configurations if x['normalized_port'] < p]) - - port_offset = sum(map(filter(lambda x, p=port-1: x['normalized_port'] < p, port_configurations))) - device_num = device_num - 1 - actual_position = port_offset + device_num - return f'{(actual_position):02X}' + 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: diff --git a/mpf/platforms/fast/fast_exp_board.py b/mpf/platforms/fast/fast_exp_board.py index d39f67cb4..22a97c2fd 100644 --- a/mpf/platforms/fast/fast_exp_board.py +++ b/mpf/platforms/fast/fast_exp_board.py @@ -18,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", "led_ports" , "led_port_configurations"] + "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. @@ -52,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 @@ -69,7 +71,6 @@ def __init__(self, name: str, communicator, address: str, config: dict) -> None: self.create_breakout(brk) # pylint: disable-msg=too-many-locals - # pylint: disable-msg=line-too-long def create_led_ports(self): """Parse the LED port overrides and create port configurations.""" led_port_configurations = [[], []] # grouped into breakout 0 and 1 @@ -133,16 +134,13 @@ def create_led_ports(self): start = led_offset count = port_configuration['led_count'] led_offset += count - if start < 129: # start at 128 for 0 lights is a possible case + 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') - # msg2 = f'RA@{self.address}:ffffff' - # self.log.info(msg2) - # self.communicator.send_and_forget(msg2) final_configurations.append(prepared_sets) self.led_port_configurations = final_configurations @@ -251,8 +249,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 - - self.log.warning(log_msg) 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 79e890a67..3def74c53 100644 --- a/mpf/tests/machine_files/fast/config/exp.yaml +++ b/mpf/tests/machine_files/fast/config/exp.yaml @@ -101,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: diff --git a/mpf/tests/test_Fast_Exp.py b/mpf/tests/test_Fast_Exp.py index e444c0a8e..23d690099 100644 --- a/mpf/tests/test_Fast_Exp.py +++ b/mpf/tests/test_Fast_Exp.py @@ -155,7 +155,7 @@ def _test_led_colors(self): self.exp_cpu.expected_commands = { 'RD@880:0201ff123402121212': '', #RD=set individual leds by binary 'RD@881:0100ffffff': '', - 'RD@841:0160ffffff': ',' + 'RD@841:017fffffff': ',' } self.led1.on() From af349e5804b10b2d39ca47de5ceea1e1381e6880 Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Mon, 8 Sep 2025 19:22:20 -0700 Subject: [PATCH 09/15] add shot:delay_events to allow more options for temporarily blocking shot handling previously only delay_switch was allowed. Note that the format of these delay events is event_handler:ms, but unlike the usual where ms means the delay before acting, this ms represents the duration of the delay (which is applied immediately) --- mpf/config_spec.yaml | 1 + mpf/devices/shot.py | 32 ++++++++++++------- .../shots/config/test_shots.yaml | 2 ++ .../shots/modes/base2/config/base2.yaml | 4 +++ mpf/tests/test_Shots.py | 14 ++++++++ 5 files changed, 41 insertions(+), 12 deletions(-) diff --git a/mpf/config_spec.yaml b/mpf/config_spec.yaml index a01961248..607e23c3e 100644 --- a/mpf/config_spec.yaml +++ b/mpf/config_spec.yaml @@ -1542,6 +1542,7 @@ shots: switches: list|machine(switches)|None start_enabled: single|bool|None delay_switch: dict|machine(switches):ms|None + delay_events: 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..320f2df58 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_events'].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/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..467e4adca 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_events: + my_delay_event: 2s default_show_light: switch: switch_5 show_tokens: 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") From a01c9a8c24771afee2e47c30cd07ffd80fa11d22 Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Fri, 26 Sep 2025 16:54:09 -0700 Subject: [PATCH 10/15] restore original naming to shot delay event list due to unanticipated behavior around device properties named _events --- mpf/config_spec.yaml | 2 +- mpf/devices/shot.py | 2 +- mpf/tests/machine_files/shots/modes/base2/config/base2.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mpf/config_spec.yaml b/mpf/config_spec.yaml index 607e23c3e..d5d7ef04c 100644 --- a/mpf/config_spec.yaml +++ b/mpf/config_spec.yaml @@ -1542,7 +1542,7 @@ shots: switches: list|machine(switches)|None start_enabled: single|bool|None delay_switch: dict|machine(switches):ms|None - delay_events: dict|event_handler: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 320f2df58..52dbe7031 100644 --- a/mpf/devices/shot.py +++ b/mpf/devices/shot.py @@ -99,7 +99,7 @@ def _register_switch_handlers(self): priority=priority, blocking_facility="shot")) - for event, ms in list(self.config['delay_events'].items()): + 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, 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 467e4adca..604ccce38 100644 --- a/mpf/tests/machine_files/shots/modes/base2/config/base2.yaml +++ b/mpf/tests/machine_files/shots/modes/base2/config/base2.yaml @@ -38,7 +38,7 @@ shots: switch_15: 2s shot_delay_event: switch: switch_29 - delay_events: + delay_event_list: my_delay_event: 2s default_show_light: switch: switch_5 From 123a661c8560c0fcf9cf58299586c465b7d4645d Mon Sep 17 00:00:00 2001 From: Alex Lobascio Date: Mon, 29 Sep 2025 17:00:48 -0700 Subject: [PATCH 11/15] hacked FAST max switches and drivers :) --- mpf/platforms/fast/communicators/net_neuron.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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' From 25f13fbe9ccf6a2ddf1d205a123f8b0bb2961ede Mon Sep 17 00:00:00 2001 From: kertrumpet88 <51866887+kertrumpet88@users.noreply.github.com> Date: Wed, 8 Oct 2025 21:56:32 -0400 Subject: [PATCH 12/15] Fix an error where send_and_wait_for_response would hang if sending ID: goes to a board and for some reason it is unresponsive. Added logging to see the current iteration of board from config, and what serial commands are being sent, as well as logging the response received. Fix an issue where send_and_wait_for_response_processed wasn't properly timing out and retry. When board initially doesn't respond to ID: and timeout/retry is succesful, a valid response is received but with bad data preceeding the expected ID: and would cause a crash. Improve the parser to search for potential headers in returned data. --- mpf/platforms/fast/communicators/base.py | 310 ++++++++++++++++++----- mpf/platforms/fast/communicators/exp.py | 89 ++++++- mpf/platforms/fast/fast_exp_board.py | 141 +++++++++-- 3 files changed, 448 insertions(+), 92 deletions(-) diff --git a/mpf/platforms/fast/communicators/base.py b/mpf/platforms/fast/communicators/base.py index 743f5eefe..89fae5bd6 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,111 @@ 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).""" + # Accumulate until we see CR-delimited frames self.received_msg += msg while True: pos = self.received_msg.find(b'\r') - - # no more complete messages - if pos == -1: + if pos == -1: # no complete frame yet break - msg = self.received_msg[:pos] - self.received_msg = self.received_msg[pos + 1:] + raw = self.received_msg[:pos] + self.received_msg = self.received_msg[pos + 1:] # drop the CR - if not msg: + if not raw: continue + # --- Safe ASCII decode with noise handling --- 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 + # Log exact noisy bytes, then salvage ASCII payload + 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 non-printable control chars (keep TAB); CR is already removed + line = "".join(ch for ch in line if ch == "\t" or 32 <= ord(ch) <= 126) + if not line: + continue + + # If the header isn't at the beginning, search for the first valid header + # (common headers on FAST buses) + HEADERS = ("ID:", "ER:", "BR:", "MS:", "XX:") + if not (len(line) >= 3 and line[2] == ":" and line[:2].isalpha() and line[:2].isupper()): + idxs = [line.find(h) for h in HEADERS] + idxs = [i for i in idxs if i != -1] + 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: + # No recognizable frame in this line—drop it quietly + self.log.debug("Dropping non-frame line: %r", line) + continue + + # Drop explicitly ignored whole-line messages + 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: + # Don’t let a bad line kill 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 +638,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/fast_exp_board.py b/mpf/platforms/fast/fast_exp_board.py index 22a97c2fd..3160a4a7a 100644 --- a/mpf/platforms/fast/fast_exp_board.py +++ b/mpf/platforms/fast/fast_exp_board.py @@ -70,12 +70,86 @@ def __init__(self, name: str, communicator, address: str, config: dict) -> None: self.create_breakout(brk) - # pylint: disable-msg=too-many-locals + # # 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 the LED port overrides and create port configurations.""" - led_port_configurations = [[], []] # grouped into breakout 0 and 1 + """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 + 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']) @@ -87,18 +161,24 @@ def create_led_ports(self): 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)) + # 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: # 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") + 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, @@ -108,42 +188,55 @@ def create_led_ports(self): prepared_sets = [] attempts = 0 leds_claimed = 0 - while len(addresses) > 0: + + while addresses: attempts += 1 address = addresses.pop(0) - config_data = next(filter( - lambda x, a=address: x['normalized_port'] == a, - port_group_configurations), None) + 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: - addresses.append(address) # put at end for retry after defined set + # rotate once to let defined ports land first + addresses.append(address) else: - # claim 32 or whatever is left, never less than 0 + # 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 - chain_type = '0' + 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 - if start <= 128: # start at 128 for 0 lights is a possible case + + # 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}' - 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) + 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'): From 92a511a7d815ce802620a6b3ecb7552864a2c7fe Mon Sep 17 00:00:00 2001 From: kertrumpet88 <51866887+kertrumpet88@users.noreply.github.com> Date: Wed, 8 Oct 2025 21:56:32 -0400 Subject: [PATCH 13/15] FAST EXP serial timeout, logging, improvements: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix an error where send_and_wait_for_response would hang if sending "ID:"" goes to a board and for some reason it is unresponsive. Added logging to see the current iteration of board from config, and what serial commands are being sent, as well as logging the response received. Fix an issue where send_and_wait_for_response_processed wasn't properly timing out and retry. When board initially doesn't respond to ID: and timeout/retry is succesful, a valid response is received but with bad data preceeding the expected ID: and would cause a crash. Improve the parser to search for potential headers in returned data. mpf/platforms/fast/communicators/base.py Implemented real request/response timeouts & retries: Rewrote send_and_wait_for_response() to actually await the arrival event. Added send_and_wait_for_response_processed(...) with bounded waits for arrival and processing complete, proper retry loop (supports max_retries = -1), and safe gate release. Ensured done_processing_msg_response() releases both gates to avoid deadlocks. Hardened serial parsing: Replaced naïve msg.decode() with a CR-framed loop that tolerates noise, logs raw bytes, decodes with errors="ignore", strips control chars, and finds valid headers inside a line (so leading junk won’t drop frames). Keeps parsing even if a line handler raises (no reader crash). Added targeted logging for TX attempts, timeouts, retries, and processing phases. mpf/platforms/fast/communicators/exp.py Discovery & verification: Normalized model strings, chose explicit or default addresses, and logged board_name, model, and board_address for each board. Logged registration steps and active_board transitions. After each registration, issued ID@: for the EXP and all breakouts with the new processed wait (bounded). Message processors: _process_id() and _process_br() now signal arrival immediately and call done_processing_msg_response() when done, preventing hangs. Added optional debug dump of self.exp_boards_by_address to console for quick inspection. mpf/platforms/fast/fast_exp_board.py LED config quality-of-life: Reworked create_led_ports() to build grouped configurations (per 4-header block), auto-fill unassigned ports up to 128 LEDs per group, and emit ER@ programming messages deterministically. Added detailed logs per group: overrides, prepared sets, offsets/counts, and ER:P acks. Hardware verification: Clear logs for EXP vs. breakout verification, min-fw checks, and hw_verified flags. Reset flow: Explicit BR@ reset, then LED programming, then optional fade setup; logs include correlation IDs. --- mpf/platforms/fast/communicators/base.py | 310 ++++++++++++++++++----- mpf/platforms/fast/communicators/exp.py | 89 ++++++- mpf/platforms/fast/fast_exp_board.py | 141 +++++++++-- 3 files changed, 448 insertions(+), 92 deletions(-) diff --git a/mpf/platforms/fast/communicators/base.py b/mpf/platforms/fast/communicators/base.py index 743f5eefe..89fae5bd6 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,111 @@ 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).""" + # Accumulate until we see CR-delimited frames self.received_msg += msg while True: pos = self.received_msg.find(b'\r') - - # no more complete messages - if pos == -1: + if pos == -1: # no complete frame yet break - msg = self.received_msg[:pos] - self.received_msg = self.received_msg[pos + 1:] + raw = self.received_msg[:pos] + self.received_msg = self.received_msg[pos + 1:] # drop the CR - if not msg: + if not raw: continue + # --- Safe ASCII decode with noise handling --- 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 + # Log exact noisy bytes, then salvage ASCII payload + 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 non-printable control chars (keep TAB); CR is already removed + line = "".join(ch for ch in line if ch == "\t" or 32 <= ord(ch) <= 126) + if not line: + continue + + # If the header isn't at the beginning, search for the first valid header + # (common headers on FAST buses) + HEADERS = ("ID:", "ER:", "BR:", "MS:", "XX:") + if not (len(line) >= 3 and line[2] == ":" and line[:2].isalpha() and line[:2].isupper()): + idxs = [line.find(h) for h in HEADERS] + idxs = [i for i in idxs if i != -1] + 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: + # No recognizable frame in this line—drop it quietly + self.log.debug("Dropping non-frame line: %r", line) + continue + + # Drop explicitly ignored whole-line messages + 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: + # Don’t let a bad line kill 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 +638,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/fast_exp_board.py b/mpf/platforms/fast/fast_exp_board.py index 22a97c2fd..3160a4a7a 100644 --- a/mpf/platforms/fast/fast_exp_board.py +++ b/mpf/platforms/fast/fast_exp_board.py @@ -70,12 +70,86 @@ def __init__(self, name: str, communicator, address: str, config: dict) -> None: self.create_breakout(brk) - # pylint: disable-msg=too-many-locals + # # 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 the LED port overrides and create port configurations.""" - led_port_configurations = [[], []] # grouped into breakout 0 and 1 + """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 + 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']) @@ -87,18 +161,24 @@ def create_led_ports(self): 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)) + # 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: # 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") + 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, @@ -108,42 +188,55 @@ def create_led_ports(self): prepared_sets = [] attempts = 0 leds_claimed = 0 - while len(addresses) > 0: + + while addresses: attempts += 1 address = addresses.pop(0) - config_data = next(filter( - lambda x, a=address: x['normalized_port'] == a, - port_group_configurations), None) + 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: - addresses.append(address) # put at end for retry after defined set + # rotate once to let defined ports land first + addresses.append(address) else: - # claim 32 or whatever is left, never less than 0 + # 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 - chain_type = '0' + 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 - if start <= 128: # start at 128 for 0 lights is a possible case + + # 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}' - 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) + 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'): From cc86da266fbe12247c7c1e3917a203e7ceb93802 Mon Sep 17 00:00:00 2001 From: kertrumpet88 <51866887+kertrumpet88@users.noreply.github.com> Date: Wed, 8 Oct 2025 23:28:22 -0400 Subject: [PATCH 14/15] Fix parse_incoming_raw_bytes to use dynamic list of message_prcoessor keys. SA, SL, /L, -L were being filtered out because they were not in the static list --- mpf/platforms/fast/communicators/base.py | 50 +++++++++++++++--------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/mpf/platforms/fast/communicators/base.py b/mpf/platforms/fast/communicators/base.py index 89fae5bd6..3ef2336eb 100644 --- a/mpf/platforms/fast/communicators/base.py +++ b/mpf/platforms/fast/communicators/base.py @@ -483,41 +483,47 @@ def send_bytes(self, msg, log_msg): def parse_incoming_raw_bytes(self, msg: bytes): """Parse a bytestring from the serial communicator (robust to noise and leading junk).""" - # Accumulate until we see CR-delimited frames 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') - if pos == -1: # no complete frame yet + if pos == -1: break raw = self.received_msg[:pos] - self.received_msg = self.received_msg[pos + 1:] # drop the CR + self.received_msg = self.received_msg[pos + 1:] if not raw: continue - # --- Safe ASCII decode with noise handling --- + # Safe decode; keep going on junk try: line = raw.decode("ascii") except UnicodeDecodeError: if getattr(self.machine, "is_shutting_down", False): return - # Log exact noisy bytes, then salvage ASCII payload self.log.warning("Interference / bad data received: %r", raw) line = raw.decode("ascii", "ignore") - # Strip non-printable control chars (keep TAB); CR is already removed + # 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 - # If the header isn't at the beginning, search for the first valid header - # (common headers on FAST buses) - HEADERS = ("ID:", "ER:", "BR:", "MS:", "XX:") - if not (len(line) >= 3 and line[2] == ":" and line[:2].isalpha() and line[:2].isupper()): - idxs = [line.find(h) for h in HEADERS] - idxs = [i for i in idxs if i != -1] + # 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: @@ -525,11 +531,18 @@ def parse_incoming_raw_bytes(self, msg: bytes): hdr_idx, line[:hdr_idx]) line = line[hdr_idx:] else: - # No recognizable frame in this line—drop it quietly - self.log.debug("Dropping non-frame line: %r", line) - continue - - # Drop explicitly ignored whole-line messages + # 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 @@ -539,11 +552,10 @@ def parse_incoming_raw_bytes(self, msg: bytes): try: self._dispatch_incoming_msg(line) except Exception as e: - # Don’t let a bad line kill the reader loop + # Never let a bad frame crash the reader loop self.log.exception("Error dispatching line %r: %s", line, e) - def _dispatch_incoming_msg(self, msg): # Figures out what to do with incoming messages From acb3a17572bf806687f44fe820720a0ca98df6c2 Mon Sep 17 00:00:00 2001 From: kertrumpet88 <51866887+kertrumpet88@users.noreply.github.com> Date: Thu, 9 Oct 2025 01:26:20 -0400 Subject: [PATCH 15/15] Parse corrections for other serial commands The parse_incoming_raw_bytes function was rigid in the type of serial headers that it was looking for. Alter code to make dynamic. --- mpf/platforms/fast/communicators/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mpf/platforms/fast/communicators/base.py b/mpf/platforms/fast/communicators/base.py index 3ef2336eb..d09810f6a 100644 --- a/mpf/platforms/fast/communicators/base.py +++ b/mpf/platforms/fast/communicators/base.py @@ -556,6 +556,7 @@ def parse_incoming_raw_bytes(self, msg: bytes): self.log.exception("Error dispatching line %r: %s", line, e) + def _dispatch_incoming_msg(self, msg): # Figures out what to do with incoming messages