From e13b323e45be28db720e457662703ab8d7846a34 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Mon, 26 Jan 2026 23:50:12 -0800 Subject: [PATCH 01/28] refactor: Rename laser constants from wavelength-based to port-based names Add ILLUMINATION_D1 through ILLUMINATION_D5 constants as the preferred naming convention for laser ports. The new names reflect the physical controller port (D1-D5) rather than a specific wavelength, since actual wavelength is configured in illumination_channel_config.yaml. Changes: - Add ILLUMINATION_D1-D5 constants in firmware and software - Keep ILLUMINATION_SOURCE_405NM etc. as deprecated aliases - Update default mappings in lighting.py and illumination_config.py to reference _def.py constants instead of magic numbers - Add test for alias consistency between firmware and software Full backward compatibility maintained - existing code using wavelength names continues to work unchanged. Protocol values (11-15) are identical. Co-Authored-By: Claude Opus 4.5 --- firmware/controller/src/constants_protocol.h | 22 +++++-- software/control/_def.py | 34 ++++++++-- software/control/lighting.py | 29 +++++---- .../control/models/illumination_config.py | 30 +++++---- .../tests/control/test_firmware_protocol.py | 65 +++++++++++++++++-- 5 files changed, 139 insertions(+), 41 deletions(-) diff --git a/firmware/controller/src/constants_protocol.h b/firmware/controller/src/constants_protocol.h index f89b01a3d..89041bfa0 100644 --- a/firmware/controller/src/constants_protocol.h +++ b/firmware/controller/src/constants_protocol.h @@ -111,6 +111,7 @@ static const int DISABLED = 2; /***************************************************************************************************/ /***************************************** Illumination ********************************************/ /***************************************************************************************************/ +// LED matrix patterns (USB ports) static const int ILLUMINATION_SOURCE_LED_ARRAY_FULL = 0; static const int ILLUMINATION_SOURCE_LED_ARRAY_LEFT_HALF = 1; static const int ILLUMINATION_SOURCE_LED_ARRAY_RIGHT_HALF = 2; @@ -121,10 +122,21 @@ static const int ILLUMINATION_SOURCE_LED_ARRAY_RIGHT_DOT = 6; static const int ILLUMINATION_SOURCE_LED_ARRAY_TOP_HALF = 7; static const int ILLUMINATION_SOURCE_LED_ARRAY_BOTTOM_HALF = 8; static const int ILLUMINATION_SOURCE_LED_EXTERNAL_FET = 20; -static const int ILLUMINATION_SOURCE_405NM = 11; -static const int ILLUMINATION_SOURCE_488NM = 12; -static const int ILLUMINATION_SOURCE_638NM = 13; -static const int ILLUMINATION_SOURCE_561NM = 14; -static const int ILLUMINATION_SOURCE_730NM = 15; + +// Laser ports - port-based names (preferred) +// These correspond to controller_port D1-D5 in software configuration +static const int ILLUMINATION_D1 = 11; +static const int ILLUMINATION_D2 = 12; +static const int ILLUMINATION_D3 = 13; +static const int ILLUMINATION_D4 = 14; +static const int ILLUMINATION_D5 = 15; + +// Laser ports - legacy wavelength-based names (deprecated, kept for compatibility) +// Use ILLUMINATION_D1-D5 for new code; wavelength is configured in software YAML +static const int ILLUMINATION_SOURCE_405NM = 11; // Alias for ILLUMINATION_D1 +static const int ILLUMINATION_SOURCE_488NM = 12; // Alias for ILLUMINATION_D2 +static const int ILLUMINATION_SOURCE_638NM = 13; // Alias for ILLUMINATION_D3 +static const int ILLUMINATION_SOURCE_561NM = 14; // Alias for ILLUMINATION_D4 +static const int ILLUMINATION_SOURCE_730NM = 15; // Alias for ILLUMINATION_D5 #endif // CONSTANTS_PROTOCOL_H diff --git a/software/control/_def.py b/software/control/_def.py index 5ffe94f0b..1ba004676 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -246,6 +246,19 @@ class LIMIT_SWITCH_POLARITY: class ILLUMINATION_CODE: + """Illumination source codes for MCU communication. + + LED Matrix Patterns (USB ports): + USB1-USB8 map to pattern codes 0-8+. + + Laser Ports (D1-D5): + Port-based names (ILLUMINATION_D1-D5) are preferred for new code. + Wavelength-based names (ILLUMINATION_SOURCE_405NM, etc.) are kept as + aliases for backward compatibility. The actual wavelength is configured + in the illumination_channel_config.yaml file, not hardcoded here. + """ + + # LED matrix patterns (USB ports) ILLUMINATION_SOURCE_LED_ARRAY_FULL = 0 ILLUMINATION_SOURCE_LED_ARRAY_LEFT_HALF = 1 ILLUMINATION_SOURCE_LED_ARRAY_RIGHT_HALF = 2 @@ -256,11 +269,22 @@ class ILLUMINATION_CODE: ILLUMINATION_SOURCE_LED_ARRAY_TOP_HALF = 7 ILLUMINATION_SOURCE_LED_ARRAY_BOTTOM_HALF = 8 ILLUMINATION_SOURCE_LED_EXTERNAL_FET = 20 - ILLUMINATION_SOURCE_405NM = 11 - ILLUMINATION_SOURCE_488NM = 12 - ILLUMINATION_SOURCE_638NM = 13 - ILLUMINATION_SOURCE_561NM = 14 - ILLUMINATION_SOURCE_730NM = 15 + + # Laser ports - port-based names (preferred) + # These correspond to controller_port D1-D5 in illumination_channel_config.yaml + ILLUMINATION_D1 = 11 + ILLUMINATION_D2 = 12 + ILLUMINATION_D3 = 13 + ILLUMINATION_D4 = 14 + ILLUMINATION_D5 = 15 + + # Laser ports - legacy wavelength-based names (deprecated, kept for compatibility) + # Use ILLUMINATION_D1-D5 for new code; wavelength is configured in YAML + ILLUMINATION_SOURCE_405NM = ILLUMINATION_D1 # Alias for ILLUMINATION_D1 + ILLUMINATION_SOURCE_488NM = ILLUMINATION_D2 # Alias for ILLUMINATION_D2 + ILLUMINATION_SOURCE_638NM = ILLUMINATION_D3 # Alias for ILLUMINATION_D3 + ILLUMINATION_SOURCE_561NM = ILLUMINATION_D4 # Alias for ILLUMINATION_D4 + ILLUMINATION_SOURCE_730NM = ILLUMINATION_D5 # Alias for ILLUMINATION_D5 class VOLUMETRIC_IMAGING: diff --git a/software/control/lighting.py b/software/control/lighting.py index 597f8fedc..4e998db94 100644 --- a/software/control/lighting.py +++ b/software/control/lighting.py @@ -6,6 +6,7 @@ from control.microcontroller import Microcontroller from control.core.config import ConfigRepository +from control._def import ILLUMINATION_CODE class LightSourceType(Enum): @@ -47,20 +48,22 @@ def __init__( self.light_source_type = light_source_type self.light_source = light_source self.disable_intensity_calibration = disable_intensity_calibration - # Default channel mappings + # Default wavelength -> source code mappings + # Maps common wavelengths to their corresponding laser port source codes + # Multiple wavelengths can map to the same port (e.g., 470nm and 488nm both to D2) default_mappings = { - 405: 11, - 470: 12, - 488: 12, - 545: 14, - 550: 14, - 555: 14, - 561: 14, - 638: 13, - 640: 13, - 730: 15, - 735: 15, - 750: 15, + 405: ILLUMINATION_CODE.ILLUMINATION_D1, + 470: ILLUMINATION_CODE.ILLUMINATION_D2, + 488: ILLUMINATION_CODE.ILLUMINATION_D2, + 545: ILLUMINATION_CODE.ILLUMINATION_D4, + 550: ILLUMINATION_CODE.ILLUMINATION_D4, + 555: ILLUMINATION_CODE.ILLUMINATION_D4, + 561: ILLUMINATION_CODE.ILLUMINATION_D4, + 638: ILLUMINATION_CODE.ILLUMINATION_D3, + 640: ILLUMINATION_CODE.ILLUMINATION_D3, + 730: ILLUMINATION_CODE.ILLUMINATION_D5, + 735: ILLUMINATION_CODE.ILLUMINATION_D5, + 750: ILLUMINATION_CODE.ILLUMINATION_D5, } # Try to load mappings from file diff --git a/software/control/models/illumination_config.py b/software/control/models/illumination_config.py index 496159dd2..408234961 100644 --- a/software/control/models/illumination_config.py +++ b/software/control/models/illumination_config.py @@ -12,6 +12,8 @@ from pydantic import BaseModel, Field +from control._def import ILLUMINATION_CODE + logger = logging.getLogger(__name__) @@ -100,20 +102,20 @@ class IlluminationChannelConfig(BaseModel): version: float = Field(1.0, description="Configuration format version") controller_port_mapping: Dict[str, int] = Field( default_factory=lambda: { - # Laser ports - "D1": 11, # 405nm laser - "D2": 12, # 488nm laser - "D3": 13, # 638nm laser - "D4": 14, # 561nm laser - "D5": 15, # 730nm laser - # LED matrix patterns (USB port number encodes the pattern) - "USB1": 0, # full - "USB2": 1, # left_half - "USB3": 2, # right_half - "USB4": 3, # dark_field - "USB5": 4, # low_na - "USB7": 7, # top_half - "USB8": 8, # bottom_half + # Laser ports - reference constants from _def.py + "D1": ILLUMINATION_CODE.ILLUMINATION_D1, + "D2": ILLUMINATION_CODE.ILLUMINATION_D2, + "D3": ILLUMINATION_CODE.ILLUMINATION_D3, + "D4": ILLUMINATION_CODE.ILLUMINATION_D4, + "D5": ILLUMINATION_CODE.ILLUMINATION_D5, + # LED matrix patterns - reference constants from _def.py + "USB1": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_FULL, + "USB2": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_LEFT_HALF, + "USB3": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_RIGHT_HALF, + "USB4": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_LEFTB_RIGHTR, + "USB5": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_LOW_NA, + "USB7": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_TOP_HALF, + "USB8": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_BOTTOM_HALF, }, description="Mapping from controller port to source code", ) diff --git a/software/tests/control/test_firmware_protocol.py b/software/tests/control/test_firmware_protocol.py index c8c2807a9..1042ac87f 100644 --- a/software/tests/control/test_firmware_protocol.py +++ b/software/tests/control/test_firmware_protocol.py @@ -246,7 +246,8 @@ def test_limit_switch_polarity_values_match(self, firmware_constants): def test_illumination_source_codes_match(self, firmware_constants): """Verify illumination source codes match.""" - illumination_mapping = { + # LED matrix patterns + led_mapping = { "ILLUMINATION_SOURCE_LED_ARRAY_FULL": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_FULL, "ILLUMINATION_SOURCE_LED_ARRAY_LEFT_HALF": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_LEFT_HALF, "ILLUMINATION_SOURCE_LED_ARRAY_RIGHT_HALF": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_RIGHT_HALF, @@ -257,6 +258,19 @@ def test_illumination_source_codes_match(self, firmware_constants): "ILLUMINATION_SOURCE_LED_ARRAY_TOP_HALF": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_TOP_HALF, "ILLUMINATION_SOURCE_LED_ARRAY_BOTTOM_HALF": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_ARRAY_BOTTOM_HALF, "ILLUMINATION_SOURCE_LED_EXTERNAL_FET": ILLUMINATION_CODE.ILLUMINATION_SOURCE_LED_EXTERNAL_FET, + } + + # Laser ports - new port-based names + laser_port_mapping = { + "ILLUMINATION_D1": ILLUMINATION_CODE.ILLUMINATION_D1, + "ILLUMINATION_D2": ILLUMINATION_CODE.ILLUMINATION_D2, + "ILLUMINATION_D3": ILLUMINATION_CODE.ILLUMINATION_D3, + "ILLUMINATION_D4": ILLUMINATION_CODE.ILLUMINATION_D4, + "ILLUMINATION_D5": ILLUMINATION_CODE.ILLUMINATION_D5, + } + + # Laser ports - legacy wavelength-based names (deprecated but still supported) + laser_legacy_mapping = { "ILLUMINATION_SOURCE_405NM": ILLUMINATION_CODE.ILLUMINATION_SOURCE_405NM, "ILLUMINATION_SOURCE_488NM": ILLUMINATION_CODE.ILLUMINATION_SOURCE_488NM, "ILLUMINATION_SOURCE_638NM": ILLUMINATION_CODE.ILLUMINATION_SOURCE_638NM, @@ -264,6 +278,9 @@ def test_illumination_source_codes_match(self, firmware_constants): "ILLUMINATION_SOURCE_730NM": ILLUMINATION_CODE.ILLUMINATION_SOURCE_730NM, } + # Combine all mappings + illumination_mapping = {**led_mapping, **laser_port_mapping, **laser_legacy_mapping} + mismatches = [] for fw_name, py_value in illumination_mapping.items(): if fw_name in firmware_constants: @@ -275,18 +292,58 @@ def test_illumination_source_codes_match(self, firmware_constants): assert len(mismatches) == 0, f"Illumination source code mismatches:\n" + "\n".join(mismatches) + def test_laser_port_aliases_match(self, firmware_constants): + """Verify that new port-based names and legacy wavelength names have same values.""" + # These pairs should have identical values (they're aliases) + alias_pairs = [ + ("ILLUMINATION_D1", "ILLUMINATION_SOURCE_405NM"), + ("ILLUMINATION_D2", "ILLUMINATION_SOURCE_488NM"), + ("ILLUMINATION_D3", "ILLUMINATION_SOURCE_638NM"), + ("ILLUMINATION_D4", "ILLUMINATION_SOURCE_561NM"), + ("ILLUMINATION_D5", "ILLUMINATION_SOURCE_730NM"), + ] + + # Verify in firmware + mismatches = [] + for port_name, legacy_name in alias_pairs: + if port_name in firmware_constants and legacy_name in firmware_constants: + if firmware_constants[port_name] != firmware_constants[legacy_name]: + mismatches.append( + f"Firmware: {port_name}={firmware_constants[port_name]} != " + f"{legacy_name}={firmware_constants[legacy_name]}" + ) + elif port_name not in firmware_constants: + mismatches.append(f"Firmware missing: {port_name}") + elif legacy_name not in firmware_constants: + mismatches.append(f"Firmware missing: {legacy_name}") + + # Verify in software + for port_name, legacy_name in alias_pairs: + port_value = getattr(ILLUMINATION_CODE, port_name, None) + legacy_value = getattr(ILLUMINATION_CODE, legacy_name, None) + if port_value is None: + mismatches.append(f"Software missing: {port_name}") + elif legacy_value is None: + mismatches.append(f"Software missing: {legacy_name}") + elif port_value != legacy_value: + mismatches.append(f"Software: {port_name}={port_value} != {legacy_name}={legacy_value}") + + assert len(mismatches) == 0, f"Laser port alias mismatches:\n" + "\n".join(mismatches) + def test_illumination_codes_consistency(self, firmware_constants): """Check bidirectional consistency of illumination codes between firmware and software.""" - # Get all ILLUMINATION_SOURCE_* constants from firmware + # Get all ILLUMINATION_* constants from firmware (both old and new naming schemes) firmware_illumination = { - name: value for name, value in firmware_constants.items() if name.startswith("ILLUMINATION_SOURCE_") + name: value + for name, value in firmware_constants.items() + if name.startswith("ILLUMINATION_SOURCE_") or name.startswith("ILLUMINATION_D") } # Get all attributes from Python ILLUMINATION_CODE class python_illumination = { name: getattr(ILLUMINATION_CODE, name) for name in dir(ILLUMINATION_CODE) - if name.startswith("ILLUMINATION_SOURCE_") + if name.startswith("ILLUMINATION_SOURCE_") or name.startswith("ILLUMINATION_D") } # Find codes in firmware but not in software From 8cb1b1cabf382e007e98acfa0a3462314507e24c Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 00:11:46 -0800 Subject: [PATCH 02/28] fix: Correct D3/D4 port-to-source-code mapping Map ports to their actual physical source codes: - D3 = 14 (561nm laser) - D4 = 13 (638nm laser) Note: D3/D4 are not sequential due to physical wiring on the controller board. The port names match the hardware labels. Co-Authored-By: Claude Opus 4.5 --- firmware/controller/src/constants_protocol.h | 8 ++++---- software/control/_def.py | 9 +++++---- software/control/lighting.py | 12 ++++++------ software/tests/control/test_firmware_protocol.py | 4 ++-- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/firmware/controller/src/constants_protocol.h b/firmware/controller/src/constants_protocol.h index 89041bfa0..078a9d96c 100644 --- a/firmware/controller/src/constants_protocol.h +++ b/firmware/controller/src/constants_protocol.h @@ -127,16 +127,16 @@ static const int ILLUMINATION_SOURCE_LED_EXTERNAL_FET = 20; // These correspond to controller_port D1-D5 in software configuration static const int ILLUMINATION_D1 = 11; static const int ILLUMINATION_D2 = 12; -static const int ILLUMINATION_D3 = 13; -static const int ILLUMINATION_D4 = 14; +static const int ILLUMINATION_D3 = 14; // 561nm - note: not sequential +static const int ILLUMINATION_D4 = 13; // 638nm - note: not sequential static const int ILLUMINATION_D5 = 15; // Laser ports - legacy wavelength-based names (deprecated, kept for compatibility) // Use ILLUMINATION_D1-D5 for new code; wavelength is configured in software YAML static const int ILLUMINATION_SOURCE_405NM = 11; // Alias for ILLUMINATION_D1 static const int ILLUMINATION_SOURCE_488NM = 12; // Alias for ILLUMINATION_D2 -static const int ILLUMINATION_SOURCE_638NM = 13; // Alias for ILLUMINATION_D3 -static const int ILLUMINATION_SOURCE_561NM = 14; // Alias for ILLUMINATION_D4 +static const int ILLUMINATION_SOURCE_561NM = 14; // Alias for ILLUMINATION_D3 +static const int ILLUMINATION_SOURCE_638NM = 13; // Alias for ILLUMINATION_D4 static const int ILLUMINATION_SOURCE_730NM = 15; // Alias for ILLUMINATION_D5 #endif // CONSTANTS_PROTOCOL_H diff --git a/software/control/_def.py b/software/control/_def.py index 1ba004676..d60c59e74 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -272,18 +272,19 @@ class ILLUMINATION_CODE: # Laser ports - port-based names (preferred) # These correspond to controller_port D1-D5 in illumination_channel_config.yaml + # Note: D3/D4 are not sequential due to physical wiring ILLUMINATION_D1 = 11 ILLUMINATION_D2 = 12 - ILLUMINATION_D3 = 13 - ILLUMINATION_D4 = 14 + ILLUMINATION_D3 = 14 # 561nm + ILLUMINATION_D4 = 13 # 638nm ILLUMINATION_D5 = 15 # Laser ports - legacy wavelength-based names (deprecated, kept for compatibility) # Use ILLUMINATION_D1-D5 for new code; wavelength is configured in YAML ILLUMINATION_SOURCE_405NM = ILLUMINATION_D1 # Alias for ILLUMINATION_D1 ILLUMINATION_SOURCE_488NM = ILLUMINATION_D2 # Alias for ILLUMINATION_D2 - ILLUMINATION_SOURCE_638NM = ILLUMINATION_D3 # Alias for ILLUMINATION_D3 - ILLUMINATION_SOURCE_561NM = ILLUMINATION_D4 # Alias for ILLUMINATION_D4 + ILLUMINATION_SOURCE_561NM = ILLUMINATION_D3 # Alias for ILLUMINATION_D3 + ILLUMINATION_SOURCE_638NM = ILLUMINATION_D4 # Alias for ILLUMINATION_D4 ILLUMINATION_SOURCE_730NM = ILLUMINATION_D5 # Alias for ILLUMINATION_D5 diff --git a/software/control/lighting.py b/software/control/lighting.py index 4e998db94..b365b0e9d 100644 --- a/software/control/lighting.py +++ b/software/control/lighting.py @@ -55,12 +55,12 @@ def __init__( 405: ILLUMINATION_CODE.ILLUMINATION_D1, 470: ILLUMINATION_CODE.ILLUMINATION_D2, 488: ILLUMINATION_CODE.ILLUMINATION_D2, - 545: ILLUMINATION_CODE.ILLUMINATION_D4, - 550: ILLUMINATION_CODE.ILLUMINATION_D4, - 555: ILLUMINATION_CODE.ILLUMINATION_D4, - 561: ILLUMINATION_CODE.ILLUMINATION_D4, - 638: ILLUMINATION_CODE.ILLUMINATION_D3, - 640: ILLUMINATION_CODE.ILLUMINATION_D3, + 545: ILLUMINATION_CODE.ILLUMINATION_D3, + 550: ILLUMINATION_CODE.ILLUMINATION_D3, + 555: ILLUMINATION_CODE.ILLUMINATION_D3, + 561: ILLUMINATION_CODE.ILLUMINATION_D3, + 638: ILLUMINATION_CODE.ILLUMINATION_D4, + 640: ILLUMINATION_CODE.ILLUMINATION_D4, 730: ILLUMINATION_CODE.ILLUMINATION_D5, 735: ILLUMINATION_CODE.ILLUMINATION_D5, 750: ILLUMINATION_CODE.ILLUMINATION_D5, diff --git a/software/tests/control/test_firmware_protocol.py b/software/tests/control/test_firmware_protocol.py index 1042ac87f..b3d2f2d95 100644 --- a/software/tests/control/test_firmware_protocol.py +++ b/software/tests/control/test_firmware_protocol.py @@ -298,8 +298,8 @@ def test_laser_port_aliases_match(self, firmware_constants): alias_pairs = [ ("ILLUMINATION_D1", "ILLUMINATION_SOURCE_405NM"), ("ILLUMINATION_D2", "ILLUMINATION_SOURCE_488NM"), - ("ILLUMINATION_D3", "ILLUMINATION_SOURCE_638NM"), - ("ILLUMINATION_D4", "ILLUMINATION_SOURCE_561NM"), + ("ILLUMINATION_D3", "ILLUMINATION_SOURCE_561NM"), + ("ILLUMINATION_D4", "ILLUMINATION_SOURCE_638NM"), ("ILLUMINATION_D5", "ILLUMINATION_SOURCE_730NM"), ] From 340c6b0d1e6b6c62629656f6c16944a3e8317fcb Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 00:22:27 -0800 Subject: [PATCH 03/28] docs: Fix misleading comments in firmware constants - Remove "(USB ports)" from LED matrix comment - firmware has no concept of USB port naming (that's a software YAML concept) - Change "Alias for" to "Same value as" for wavelength constants - in C++ these are separate declarations, not true aliases Co-Authored-By: Claude Opus 4.5 --- firmware/controller/src/constants_protocol.h | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/firmware/controller/src/constants_protocol.h b/firmware/controller/src/constants_protocol.h index 078a9d96c..42e32e0ac 100644 --- a/firmware/controller/src/constants_protocol.h +++ b/firmware/controller/src/constants_protocol.h @@ -111,7 +111,7 @@ static const int DISABLED = 2; /***************************************************************************************************/ /***************************************** Illumination ********************************************/ /***************************************************************************************************/ -// LED matrix patterns (USB ports) +// LED matrix patterns static const int ILLUMINATION_SOURCE_LED_ARRAY_FULL = 0; static const int ILLUMINATION_SOURCE_LED_ARRAY_LEFT_HALF = 1; static const int ILLUMINATION_SOURCE_LED_ARRAY_RIGHT_HALF = 2; @@ -133,10 +133,10 @@ static const int ILLUMINATION_D5 = 15; // Laser ports - legacy wavelength-based names (deprecated, kept for compatibility) // Use ILLUMINATION_D1-D5 for new code; wavelength is configured in software YAML -static const int ILLUMINATION_SOURCE_405NM = 11; // Alias for ILLUMINATION_D1 -static const int ILLUMINATION_SOURCE_488NM = 12; // Alias for ILLUMINATION_D2 -static const int ILLUMINATION_SOURCE_561NM = 14; // Alias for ILLUMINATION_D3 -static const int ILLUMINATION_SOURCE_638NM = 13; // Alias for ILLUMINATION_D4 -static const int ILLUMINATION_SOURCE_730NM = 15; // Alias for ILLUMINATION_D5 +static const int ILLUMINATION_SOURCE_405NM = 11; // Same value as ILLUMINATION_D1 +static const int ILLUMINATION_SOURCE_488NM = 12; // Same value as ILLUMINATION_D2 +static const int ILLUMINATION_SOURCE_561NM = 14; // Same value as ILLUMINATION_D3 +static const int ILLUMINATION_SOURCE_638NM = 13; // Same value as ILLUMINATION_D4 +static const int ILLUMINATION_SOURCE_730NM = 15; // Same value as ILLUMINATION_D5 #endif // CONSTANTS_PROTOCOL_H From 9868e1dd34c3c72178dc2c2b08476ffb158d7875 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 00:25:37 -0800 Subject: [PATCH 04/28] docs: Rename "Laser Ports" to "Illumination Control TTL Ports" More accurate terminology - these ports can be used for any TTL-controlled illumination source, not just lasers. Co-Authored-By: Claude Opus 4.5 --- firmware/controller/src/constants_protocol.h | 4 ++-- software/control/_def.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/firmware/controller/src/constants_protocol.h b/firmware/controller/src/constants_protocol.h index 42e32e0ac..e97cabd64 100644 --- a/firmware/controller/src/constants_protocol.h +++ b/firmware/controller/src/constants_protocol.h @@ -123,7 +123,7 @@ static const int ILLUMINATION_SOURCE_LED_ARRAY_TOP_HALF = 7; static const int ILLUMINATION_SOURCE_LED_ARRAY_BOTTOM_HALF = 8; static const int ILLUMINATION_SOURCE_LED_EXTERNAL_FET = 20; -// Laser ports - port-based names (preferred) +// Illumination Control TTL Ports - port-based names (preferred) // These correspond to controller_port D1-D5 in software configuration static const int ILLUMINATION_D1 = 11; static const int ILLUMINATION_D2 = 12; @@ -131,7 +131,7 @@ static const int ILLUMINATION_D3 = 14; // 561nm - note: not sequential static const int ILLUMINATION_D4 = 13; // 638nm - note: not sequential static const int ILLUMINATION_D5 = 15; -// Laser ports - legacy wavelength-based names (deprecated, kept for compatibility) +// Illumination Control TTL Ports - legacy wavelength-based names (deprecated, kept for compatibility) // Use ILLUMINATION_D1-D5 for new code; wavelength is configured in software YAML static const int ILLUMINATION_SOURCE_405NM = 11; // Same value as ILLUMINATION_D1 static const int ILLUMINATION_SOURCE_488NM = 12; // Same value as ILLUMINATION_D2 diff --git a/software/control/_def.py b/software/control/_def.py index d60c59e74..836e40a6e 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -251,7 +251,7 @@ class ILLUMINATION_CODE: LED Matrix Patterns (USB ports): USB1-USB8 map to pattern codes 0-8+. - Laser Ports (D1-D5): + Illumination Control TTL Ports (D1-D5): Port-based names (ILLUMINATION_D1-D5) are preferred for new code. Wavelength-based names (ILLUMINATION_SOURCE_405NM, etc.) are kept as aliases for backward compatibility. The actual wavelength is configured @@ -270,7 +270,7 @@ class ILLUMINATION_CODE: ILLUMINATION_SOURCE_LED_ARRAY_BOTTOM_HALF = 8 ILLUMINATION_SOURCE_LED_EXTERNAL_FET = 20 - # Laser ports - port-based names (preferred) + # Illumination Control TTL Ports - port-based names (preferred) # These correspond to controller_port D1-D5 in illumination_channel_config.yaml # Note: D3/D4 are not sequential due to physical wiring ILLUMINATION_D1 = 11 @@ -279,7 +279,7 @@ class ILLUMINATION_CODE: ILLUMINATION_D4 = 13 # 638nm ILLUMINATION_D5 = 15 - # Laser ports - legacy wavelength-based names (deprecated, kept for compatibility) + # Illumination Control TTL Ports - legacy wavelength-based names (deprecated, kept for compatibility) # Use ILLUMINATION_D1-D5 for new code; wavelength is configured in YAML ILLUMINATION_SOURCE_405NM = ILLUMINATION_D1 # Alias for ILLUMINATION_D1 ILLUMINATION_SOURCE_488NM = ILLUMINATION_D2 # Alias for ILLUMINATION_D2 From 3fe45ef351eb35a179f1b0c213d9397e7e3717da Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 00:31:41 -0800 Subject: [PATCH 05/28] refactor: Rename firmware pin constants to port-based names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename LASER_* pin constants to PIN_ILLUMINATION_D* for consistency with the source code naming convention: - LASER_405nm → PIN_ILLUMINATION_D1 - LASER_488nm → PIN_ILLUMINATION_D2 - LASER_561nm → PIN_ILLUMINATION_D3 - LASER_638nm → PIN_ILLUMINATION_D4 - LASER_730nm → PIN_ILLUMINATION_D5 - LASER_INTERLOCK → PIN_ILLUMINATION_INTERLOCK Legacy aliases kept in constants.h for backward compatibility with code that references the old names. Co-Authored-By: Claude Opus 4.5 --- .../controller/main_controller_teensy41.ino | 12 +++--- firmware/controller/src/constants.h | 23 +++++++---- firmware/controller/src/functions.cpp | 40 +++++++++---------- firmware/controller/src/init.cpp | 22 +++++----- 4 files changed, 53 insertions(+), 44 deletions(-) diff --git a/firmware/controller/main_controller_teensy41.ino b/firmware/controller/main_controller_teensy41.ino index 5ababfc74..9299aa764 100644 --- a/firmware/controller/main_controller_teensy41.ino +++ b/firmware/controller/main_controller_teensy41.ino @@ -16,14 +16,14 @@ void setup() { void loop() { - // laser safety interlock - turn off all lasers if interlock is triggered + // Illumination safety interlock - turn off all TTL ports if interlock is triggered if (!INTERLOCK_OK()) { - digitalWrite(LASER_405nm,LOW); - digitalWrite(LASER_488nm,LOW); - digitalWrite(LASER_561nm,LOW); - digitalWrite(LASER_638nm,LOW); - digitalWrite(LASER_730nm,LOW); + digitalWrite(PIN_ILLUMINATION_D1, LOW); + digitalWrite(PIN_ILLUMINATION_D2, LOW); + digitalWrite(PIN_ILLUMINATION_D3, LOW); + digitalWrite(PIN_ILLUMINATION_D4, LOW); + digitalWrite(PIN_ILLUMINATION_D5, LOW); } joystick_packetSerial.update(); diff --git a/firmware/controller/src/constants.h b/firmware/controller/src/constants.h index 224a632a9..2ef467ed3 100644 --- a/firmware/controller/src/constants.h +++ b/firmware/controller/src/constants.h @@ -25,13 +25,22 @@ typedef struct pid_arguments { /***************************************************************************************************/ // Teensy4.1 board v1 def -// illumination -static const int LASER_405nm = 5; // to rename -static const int LASER_488nm = 4; // to rename -static const int LASER_561nm = 22; // to rename -static const int LASER_638nm = 3; // to rename -static const int LASER_730nm = 23; // to rename -static const int LASER_INTERLOCK = 2; +// Illumination Control TTL Ports - pin assignments +// Note: D3/D4 source codes are swapped (D3=14, D4=13) but pins are in order +static const int PIN_ILLUMINATION_D1 = 5; // 405nm +static const int PIN_ILLUMINATION_D2 = 4; // 488nm +static const int PIN_ILLUMINATION_D3 = 22; // 561nm +static const int PIN_ILLUMINATION_D4 = 3; // 638nm +static const int PIN_ILLUMINATION_D5 = 23; // 730nm +static const int PIN_ILLUMINATION_INTERLOCK = 2; + +// Legacy aliases (deprecated, kept for compatibility) +static const int LASER_405nm = PIN_ILLUMINATION_D1; +static const int LASER_488nm = PIN_ILLUMINATION_D2; +static const int LASER_561nm = PIN_ILLUMINATION_D3; +static const int LASER_638nm = PIN_ILLUMINATION_D4; +static const int LASER_730nm = PIN_ILLUMINATION_D5; +static const int LASER_INTERLOCK = PIN_ILLUMINATION_INTERLOCK; // Laser safety interlock check #ifdef DISABLE_LASER_INTERLOCK diff --git a/firmware/controller/src/functions.cpp b/firmware/controller/src/functions.cpp index ce52ee056..6f37bd12c 100644 --- a/firmware/controller/src/functions.cpp +++ b/firmware/controller/src/functions.cpp @@ -238,25 +238,25 @@ void turn_on_illumination() break; case ILLUMINATION_SOURCE_LED_EXTERNAL_FET: break; - case ILLUMINATION_SOURCE_405NM: + case ILLUMINATION_D1: // 405nm if(INTERLOCK_OK()) - digitalWrite(LASER_405nm, HIGH); + digitalWrite(PIN_ILLUMINATION_D1, HIGH); break; - case ILLUMINATION_SOURCE_488NM: + case ILLUMINATION_D2: // 488nm if(INTERLOCK_OK()) - digitalWrite(LASER_488nm, HIGH); + digitalWrite(PIN_ILLUMINATION_D2, HIGH); break; - case ILLUMINATION_SOURCE_638NM: + case ILLUMINATION_D3: // 561nm if(INTERLOCK_OK()) - digitalWrite(LASER_638nm, HIGH); + digitalWrite(PIN_ILLUMINATION_D3, HIGH); break; - case ILLUMINATION_SOURCE_561NM: + case ILLUMINATION_D4: // 638nm if(INTERLOCK_OK()) - digitalWrite(LASER_561nm, HIGH); + digitalWrite(PIN_ILLUMINATION_D4, HIGH); break; - case ILLUMINATION_SOURCE_730NM: + case ILLUMINATION_D5: // 730nm if(INTERLOCK_OK()) - digitalWrite(LASER_730nm, HIGH); + digitalWrite(PIN_ILLUMINATION_D5, HIGH); break; } } @@ -294,20 +294,20 @@ void turn_off_illumination() break; case ILLUMINATION_SOURCE_LED_EXTERNAL_FET: break; - case ILLUMINATION_SOURCE_405NM: - digitalWrite(LASER_405nm, LOW); + case ILLUMINATION_D1: // 405nm + digitalWrite(PIN_ILLUMINATION_D1, LOW); break; - case ILLUMINATION_SOURCE_488NM: - digitalWrite(LASER_488nm, LOW); + case ILLUMINATION_D2: // 488nm + digitalWrite(PIN_ILLUMINATION_D2, LOW); break; - case ILLUMINATION_SOURCE_638NM: - digitalWrite(LASER_638nm, LOW); + case ILLUMINATION_D3: // 561nm + digitalWrite(PIN_ILLUMINATION_D3, LOW); break; - case ILLUMINATION_SOURCE_561NM: - digitalWrite(LASER_561nm, LOW); + case ILLUMINATION_D4: // 638nm + digitalWrite(PIN_ILLUMINATION_D4, LOW); break; - case ILLUMINATION_SOURCE_730NM: - digitalWrite(LASER_730nm, LOW); + case ILLUMINATION_D5: // 730nm + digitalWrite(PIN_ILLUMINATION_D5, LOW); break; } illumination_is_on = false; diff --git a/firmware/controller/src/init.cpp b/firmware/controller/src/init.cpp index 592d1f8db..1af19febd 100644 --- a/firmware/controller/src/init.cpp +++ b/firmware/controller/src/init.cpp @@ -19,21 +19,21 @@ void init_lasers_and_led_driver() { pinMode(LASER_INTERLOCK, INPUT_PULLUP); #endif - // enable pins - pinMode(LASER_405nm, OUTPUT); - digitalWrite(LASER_405nm, LOW); + // Illumination Control TTL Ports + pinMode(PIN_ILLUMINATION_D1, OUTPUT); + digitalWrite(PIN_ILLUMINATION_D1, LOW); - pinMode(LASER_488nm, OUTPUT); - digitalWrite(LASER_488nm, LOW); + pinMode(PIN_ILLUMINATION_D2, OUTPUT); + digitalWrite(PIN_ILLUMINATION_D2, LOW); - pinMode(LASER_638nm, OUTPUT); - digitalWrite(LASER_638nm, LOW); + pinMode(PIN_ILLUMINATION_D3, OUTPUT); + digitalWrite(PIN_ILLUMINATION_D3, LOW); - pinMode(LASER_561nm, OUTPUT); - digitalWrite(LASER_561nm, LOW); + pinMode(PIN_ILLUMINATION_D4, OUTPUT); + digitalWrite(PIN_ILLUMINATION_D4, LOW); - pinMode(LASER_730nm, OUTPUT); - digitalWrite(LASER_730nm, LOW); + pinMode(PIN_ILLUMINATION_D5, OUTPUT); + digitalWrite(PIN_ILLUMINATION_D5, LOW); // LED drivers pinMode(pin_LT3932_SYNC, OUTPUT); From fc7280d1e2d642c07b45394d5959c1ef3268cc4e Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 00:48:47 -0800 Subject: [PATCH 06/28] fix: Address PR review issues - consistent naming and accurate comments - Update set_illumination() to use ILLUMINATION_D1-D5 (was using old names) - Update init.cpp to use PIN_ILLUMINATION_INTERLOCK (was LASER_INTERLOCK) - Update INTERLOCK_OK() to use PIN_ILLUMINATION_INTERLOCK - Fix misleading comment in constants.h (pins are not sequential) - Standardize D3/D4 explanation: "for historical API compatibility" - Remove wavelength comments from port constants (wavelengths are in YAML) - Remove redundant "Alias for" / "Same value as" comments on legacy names Co-Authored-By: Claude Opus 4.5 --- firmware/controller/src/constants.h | 16 ++++----- firmware/controller/src/constants_protocol.h | 15 +++++---- firmware/controller/src/functions.cpp | 34 ++++++++++---------- firmware/controller/src/init.cpp | 2 +- software/control/_def.py | 16 ++++----- 5 files changed, 42 insertions(+), 41 deletions(-) diff --git a/firmware/controller/src/constants.h b/firmware/controller/src/constants.h index 2ef467ed3..5d4000526 100644 --- a/firmware/controller/src/constants.h +++ b/firmware/controller/src/constants.h @@ -25,13 +25,13 @@ typedef struct pid_arguments { /***************************************************************************************************/ // Teensy4.1 board v1 def -// Illumination Control TTL Ports - pin assignments -// Note: D3/D4 source codes are swapped (D3=14, D4=13) but pins are in order -static const int PIN_ILLUMINATION_D1 = 5; // 405nm -static const int PIN_ILLUMINATION_D2 = 4; // 488nm -static const int PIN_ILLUMINATION_D3 = 22; // 561nm -static const int PIN_ILLUMINATION_D4 = 3; // 638nm -static const int PIN_ILLUMINATION_D5 = 23; // 730nm +// Illumination Control TTL Ports - GPIO pin assignments +// Pin numbers are based on PCB layout, not sequential +static const int PIN_ILLUMINATION_D1 = 5; +static const int PIN_ILLUMINATION_D2 = 4; +static const int PIN_ILLUMINATION_D3 = 22; +static const int PIN_ILLUMINATION_D4 = 3; +static const int PIN_ILLUMINATION_D5 = 23; static const int PIN_ILLUMINATION_INTERLOCK = 2; // Legacy aliases (deprecated, kept for compatibility) @@ -46,7 +46,7 @@ static const int LASER_INTERLOCK = PIN_ILLUMINATION_INTERLOCK; #ifdef DISABLE_LASER_INTERLOCK static inline bool INTERLOCK_OK() { return true; } #else -static inline bool INTERLOCK_OK() { return digitalRead(LASER_INTERLOCK) == LOW; } +static inline bool INTERLOCK_OK() { return digitalRead(PIN_ILLUMINATION_INTERLOCK) == LOW; } #endif // PWM6 2 diff --git a/firmware/controller/src/constants_protocol.h b/firmware/controller/src/constants_protocol.h index e97cabd64..fa6e8f0e5 100644 --- a/firmware/controller/src/constants_protocol.h +++ b/firmware/controller/src/constants_protocol.h @@ -125,18 +125,19 @@ static const int ILLUMINATION_SOURCE_LED_EXTERNAL_FET = 20; // Illumination Control TTL Ports - port-based names (preferred) // These correspond to controller_port D1-D5 in software configuration +// Note: D3/D4 source codes are non-sequential (14, 13) for historical API compatibility static const int ILLUMINATION_D1 = 11; static const int ILLUMINATION_D2 = 12; -static const int ILLUMINATION_D3 = 14; // 561nm - note: not sequential -static const int ILLUMINATION_D4 = 13; // 638nm - note: not sequential +static const int ILLUMINATION_D3 = 14; +static const int ILLUMINATION_D4 = 13; static const int ILLUMINATION_D5 = 15; // Illumination Control TTL Ports - legacy wavelength-based names (deprecated, kept for compatibility) // Use ILLUMINATION_D1-D5 for new code; wavelength is configured in software YAML -static const int ILLUMINATION_SOURCE_405NM = 11; // Same value as ILLUMINATION_D1 -static const int ILLUMINATION_SOURCE_488NM = 12; // Same value as ILLUMINATION_D2 -static const int ILLUMINATION_SOURCE_561NM = 14; // Same value as ILLUMINATION_D3 -static const int ILLUMINATION_SOURCE_638NM = 13; // Same value as ILLUMINATION_D4 -static const int ILLUMINATION_SOURCE_730NM = 15; // Same value as ILLUMINATION_D5 +static const int ILLUMINATION_SOURCE_405NM = 11; +static const int ILLUMINATION_SOURCE_488NM = 12; +static const int ILLUMINATION_SOURCE_561NM = 14; +static const int ILLUMINATION_SOURCE_638NM = 13; +static const int ILLUMINATION_SOURCE_730NM = 15; #endif // CONSTANTS_PROTOCOL_H diff --git a/firmware/controller/src/functions.cpp b/firmware/controller/src/functions.cpp index 6f37bd12c..6382af37d 100644 --- a/firmware/controller/src/functions.cpp +++ b/firmware/controller/src/functions.cpp @@ -238,23 +238,23 @@ void turn_on_illumination() break; case ILLUMINATION_SOURCE_LED_EXTERNAL_FET: break; - case ILLUMINATION_D1: // 405nm + case ILLUMINATION_D1: if(INTERLOCK_OK()) digitalWrite(PIN_ILLUMINATION_D1, HIGH); break; - case ILLUMINATION_D2: // 488nm + case ILLUMINATION_D2: if(INTERLOCK_OK()) digitalWrite(PIN_ILLUMINATION_D2, HIGH); break; - case ILLUMINATION_D3: // 561nm + case ILLUMINATION_D3: if(INTERLOCK_OK()) digitalWrite(PIN_ILLUMINATION_D3, HIGH); break; - case ILLUMINATION_D4: // 638nm + case ILLUMINATION_D4: if(INTERLOCK_OK()) digitalWrite(PIN_ILLUMINATION_D4, HIGH); break; - case ILLUMINATION_D5: // 730nm + case ILLUMINATION_D5: if(INTERLOCK_OK()) digitalWrite(PIN_ILLUMINATION_D5, HIGH); break; @@ -294,19 +294,19 @@ void turn_off_illumination() break; case ILLUMINATION_SOURCE_LED_EXTERNAL_FET: break; - case ILLUMINATION_D1: // 405nm + case ILLUMINATION_D1: digitalWrite(PIN_ILLUMINATION_D1, LOW); break; - case ILLUMINATION_D2: // 488nm + case ILLUMINATION_D2: digitalWrite(PIN_ILLUMINATION_D2, LOW); break; - case ILLUMINATION_D3: // 561nm + case ILLUMINATION_D3: digitalWrite(PIN_ILLUMINATION_D3, LOW); break; - case ILLUMINATION_D4: // 638nm + case ILLUMINATION_D4: digitalWrite(PIN_ILLUMINATION_D4, LOW); break; - case ILLUMINATION_D5: // 730nm + case ILLUMINATION_D5: digitalWrite(PIN_ILLUMINATION_D5, LOW); break; } @@ -319,19 +319,19 @@ void set_illumination(int source, uint16_t intensity) illumination_intensity = intensity * illumination_intensity_factor; switch (source) { - case ILLUMINATION_SOURCE_405NM: + case ILLUMINATION_D1: set_DAC8050x_output(0, illumination_intensity); break; - case ILLUMINATION_SOURCE_488NM: + case ILLUMINATION_D2: set_DAC8050x_output(1, illumination_intensity); break; - case ILLUMINATION_SOURCE_638NM: - set_DAC8050x_output(3, illumination_intensity); - break; - case ILLUMINATION_SOURCE_561NM: + case ILLUMINATION_D3: set_DAC8050x_output(2, illumination_intensity); break; - case ILLUMINATION_SOURCE_730NM: + case ILLUMINATION_D4: + set_DAC8050x_output(3, illumination_intensity); + break; + case ILLUMINATION_D5: set_DAC8050x_output(4, illumination_intensity); break; } diff --git a/firmware/controller/src/init.cpp b/firmware/controller/src/init.cpp index 1af19febd..3e6d6451b 100644 --- a/firmware/controller/src/init.cpp +++ b/firmware/controller/src/init.cpp @@ -16,7 +16,7 @@ void init_serial_communication() void init_lasers_and_led_driver() { #ifndef DISABLE_LASER_INTERLOCK // laser safety interlock - pinMode(LASER_INTERLOCK, INPUT_PULLUP); + pinMode(PIN_ILLUMINATION_INTERLOCK, INPUT_PULLUP); #endif // Illumination Control TTL Ports diff --git a/software/control/_def.py b/software/control/_def.py index 836e40a6e..0c765554b 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -272,20 +272,20 @@ class ILLUMINATION_CODE: # Illumination Control TTL Ports - port-based names (preferred) # These correspond to controller_port D1-D5 in illumination_channel_config.yaml - # Note: D3/D4 are not sequential due to physical wiring + # Note: D3/D4 source codes are non-sequential (14, 13) for historical API compatibility ILLUMINATION_D1 = 11 ILLUMINATION_D2 = 12 - ILLUMINATION_D3 = 14 # 561nm - ILLUMINATION_D4 = 13 # 638nm + ILLUMINATION_D3 = 14 + ILLUMINATION_D4 = 13 ILLUMINATION_D5 = 15 # Illumination Control TTL Ports - legacy wavelength-based names (deprecated, kept for compatibility) # Use ILLUMINATION_D1-D5 for new code; wavelength is configured in YAML - ILLUMINATION_SOURCE_405NM = ILLUMINATION_D1 # Alias for ILLUMINATION_D1 - ILLUMINATION_SOURCE_488NM = ILLUMINATION_D2 # Alias for ILLUMINATION_D2 - ILLUMINATION_SOURCE_561NM = ILLUMINATION_D3 # Alias for ILLUMINATION_D3 - ILLUMINATION_SOURCE_638NM = ILLUMINATION_D4 # Alias for ILLUMINATION_D4 - ILLUMINATION_SOURCE_730NM = ILLUMINATION_D5 # Alias for ILLUMINATION_D5 + ILLUMINATION_SOURCE_405NM = ILLUMINATION_D1 + ILLUMINATION_SOURCE_488NM = ILLUMINATION_D2 + ILLUMINATION_SOURCE_561NM = ILLUMINATION_D3 + ILLUMINATION_SOURCE_638NM = ILLUMINATION_D4 + ILLUMINATION_SOURCE_730NM = ILLUMINATION_D5 class VOLUMETRIC_IMAGING: From c866822f945bd9c1b4b87134395c512c78f3898c Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 02:50:27 -0800 Subject: [PATCH 07/28] fix: Update YAML config to match corrected D3/D4 port mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D3: 14 (561nm), D4: 13 (638nm) - consistent with code defaults - Update channel assignments: 561nm → D3, 638nm → D4 Co-Authored-By: Claude Opus 4.5 --- software/machine_configs/illumination_channel_config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/software/machine_configs/illumination_channel_config.yaml b/software/machine_configs/illumination_channel_config.yaml index 3c875f8a4..651926776 100644 --- a/software/machine_configs/illumination_channel_config.yaml +++ b/software/machine_configs/illumination_channel_config.yaml @@ -5,8 +5,8 @@ version: 1 controller_port_mapping: D1: 11 # 405nm laser D2: 12 # 488nm laser - D3: 13 # 638nm laser - D4: 14 # 561nm laser + D3: 14 # 561nm laser + D4: 13 # 638nm laser D5: 15 # 730nm laser USB1: 0 # LED full USB2: 1 # LED left_half @@ -47,13 +47,13 @@ channels: - name: Fluorescence 561 nm Ex type: epi_illumination - controller_port: D4 + controller_port: D3 wavelength_nm: 561 intensity_calibration_file: 561.csv - name: Fluorescence 638 nm Ex type: epi_illumination - controller_port: D3 + controller_port: D4 wavelength_nm: 638 intensity_calibration_file: 638.csv From 5cfc3c461dbe581ef821921cee9ad6e4f55f593c Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 20:45:36 -0800 Subject: [PATCH 08/28] feat(firmware): Add multi-port illumination control Add support for controlling multiple illumination ports simultaneously with independent intensities. Key changes: - Add per-port state arrays (illumination_port_is_on, illumination_port_intensity) - Add new protocol commands (34-39): SET_PORT_INTENSITY, TURN_ON_PORT, TURN_OFF_PORT, SET_PORT_ILLUMINATION, SET_MULTI_PORT_MASK, TURN_OFF_ALL_PORTS - Add firmware version reporting in response byte 22 (nibble-encoded v1.0) - Add helper functions for port control with interlock checking - Maintain backward compatibility with legacy single-source commands Co-Authored-By: Claude Opus 4.5 --- firmware/controller/src/commands/commands.cpp | 7 ++ .../src/commands/light_commands.cpp | 67 ++++++++++ .../controller/src/commands/light_commands.h | 8 ++ firmware/controller/src/constants.h | 8 ++ firmware/controller/src/constants_protocol.h | 12 ++ firmware/controller/src/functions.cpp | 119 ++++++++++++++++++ firmware/controller/src/functions.h | 11 ++ firmware/controller/src/globals.cpp | 4 + firmware/controller/src/globals.h | 5 + .../controller/src/serial_communication.cpp | 3 + 10 files changed, 244 insertions(+) diff --git a/firmware/controller/src/commands/commands.cpp b/firmware/controller/src/commands/commands.cpp index b22c259ca..193bdb1b5 100644 --- a/firmware/controller/src/commands/commands.cpp +++ b/firmware/controller/src/commands/commands.cpp @@ -26,6 +26,13 @@ void init_callbacks() cmd_map[TURN_OFF_ILLUMINATION] = &callback_turn_off_illumination; cmd_map[SET_ILLUMINATION] = &callback_set_illumination; cmd_map[SET_ILLUMINATION_LED_MATRIX] = &callback_set_illumination_led_matrix; + // Multi-port illumination commands (firmware v1.0+) + cmd_map[SET_PORT_INTENSITY] = &callback_set_port_intensity; + cmd_map[TURN_ON_PORT] = &callback_turn_on_port; + cmd_map[TURN_OFF_PORT] = &callback_turn_off_port; + cmd_map[SET_PORT_ILLUMINATION] = &callback_set_port_illumination; + cmd_map[SET_MULTI_PORT_MASK] = &callback_set_multi_port_mask; + cmd_map[TURN_OFF_ALL_PORTS] = &callback_turn_off_all_ports; cmd_map[ACK_JOYSTICK_BUTTON_PRESSED] = &callback_ack_joystick_button_pressed; cmd_map[ANALOG_WRITE_ONBOARD_DAC] = &callback_analog_write_onboard_dac; cmd_map[SET_DAC80508_REFDIV_GAIN] = &callback_set_dac80508_defdiv_gain; diff --git a/firmware/controller/src/commands/light_commands.cpp b/firmware/controller/src/commands/light_commands.cpp index cf957aeb4..58b2b1060 100644 --- a/firmware/controller/src/commands/light_commands.cpp +++ b/firmware/controller/src/commands/light_commands.cpp @@ -29,3 +29,70 @@ void callback_set_illumination_intensity_factor() if (factor > 100) factor = 100; illumination_intensity_factor = float(factor) / 100; } + +/***************************************************************************************************/ +/*************************** Multi-port illumination commands (v1.0+) ******************************/ +/***************************************************************************************************/ + +// Command byte layout: [cmd_id, 34, port, intensity_hi, intensity_lo, 0, 0, crc] +void callback_set_port_intensity() +{ + int port_index = buffer_rx[2]; + uint16_t intensity = (uint16_t(buffer_rx[3]) << 8) | uint16_t(buffer_rx[4]); + set_port_intensity(port_index, intensity); +} + +// Command byte layout: [cmd_id, 35, port, 0, 0, 0, 0, crc] +void callback_turn_on_port() +{ + int port_index = buffer_rx[2]; + turn_on_port(port_index); +} + +// Command byte layout: [cmd_id, 36, port, 0, 0, 0, 0, crc] +void callback_turn_off_port() +{ + int port_index = buffer_rx[2]; + turn_off_port(port_index); +} + +// Command byte layout: [cmd_id, 37, port, intensity_hi, intensity_lo, on_flag, 0, crc] +void callback_set_port_illumination() +{ + int port_index = buffer_rx[2]; + uint16_t intensity = (uint16_t(buffer_rx[3]) << 8) | uint16_t(buffer_rx[4]); + bool turn_on = buffer_rx[5] != 0; + + set_port_intensity(port_index, intensity); + if (turn_on) + turn_on_port(port_index); + else + turn_off_port(port_index); +} + +// Command byte layout: [cmd_id, 38, mask_hi, mask_lo, on_hi, on_lo, 0, crc] +// port_mask: which ports to update (bit 0 = D1, bit 15 = D16) +// on_mask: for selected ports, which to turn ON (1) vs OFF (0) +void callback_set_multi_port_mask() +{ + uint16_t port_mask = (uint16_t(buffer_rx[2]) << 8) | uint16_t(buffer_rx[3]); + uint16_t on_mask = (uint16_t(buffer_rx[4]) << 8) | uint16_t(buffer_rx[5]); + + for (int i = 0; i < NUM_ILLUMINATION_PORTS; i++) + { + if (port_mask & (1 << i)) // If this port is selected + { + if (on_mask & (1 << i)) + turn_on_port(i); + else + turn_off_port(i); + } + // Ports not in port_mask are left unchanged + } +} + +// Command byte layout: [cmd_id, 39, 0, 0, 0, 0, 0, crc] +void callback_turn_off_all_ports() +{ + turn_off_all_ports(); +} diff --git a/firmware/controller/src/commands/light_commands.h b/firmware/controller/src/commands/light_commands.h index 372a13734..fd21027ce 100644 --- a/firmware/controller/src/commands/light_commands.h +++ b/firmware/controller/src/commands/light_commands.h @@ -9,4 +9,12 @@ void callback_set_illumination(); void callback_set_illumination_led_matrix(); void callback_set_illumination_intensity_factor(); +// Multi-port illumination commands (firmware v1.0+) +void callback_set_port_intensity(); +void callback_turn_on_port(); +void callback_turn_off_port(); +void callback_set_port_illumination(); +void callback_set_multi_port_mask(); +void callback_turn_off_all_ports(); + #endif // LIGHT_COMMANDS_H diff --git a/firmware/controller/src/constants.h b/firmware/controller/src/constants.h index 5d4000526..52c01469d 100644 --- a/firmware/controller/src/constants.h +++ b/firmware/controller/src/constants.h @@ -3,6 +3,14 @@ #include "constants_protocol.h" +/***************************************************************************************************/ +/**************************************** Firmware Version *****************************************/ +/***************************************************************************************************/ +// Version is sent in response byte 22 as nibble-encoded: high nibble = major, low nibble = minor +// Version 1.0 = first version with multi-port illumination support +#define FIRMWARE_VERSION_MAJOR 1 +#define FIRMWARE_VERSION_MINOR 0 + #include "def/def_v1.h" #include "tmc/TMC4361A_TMC2660_Utils.h" diff --git a/firmware/controller/src/constants_protocol.h b/firmware/controller/src/constants_protocol.h index fa6e8f0e5..464c3d695 100644 --- a/firmware/controller/src/constants_protocol.h +++ b/firmware/controller/src/constants_protocol.h @@ -60,6 +60,18 @@ static const int SEND_HARDWARE_TRIGGER = 30; static const int SET_STROBE_DELAY = 31; static const int SET_AXIS_DISABLE_ENABLE = 32; static const int SET_TRIGGER_MODE = 33; + +// Multi-port illumination commands (firmware v1.0+) +// Separate commands (matches existing SET_ILLUMINATION pattern) +static const int SET_PORT_INTENSITY = 34; // Set DAC intensity for specific port only +static const int TURN_ON_PORT = 35; // Turn on GPIO for specific port +static const int TURN_OFF_PORT = 36; // Turn off GPIO for specific port +// Combined command (convenience) +static const int SET_PORT_ILLUMINATION = 37; // Set intensity + on/off in one command +// Multi-port commands +static const int SET_MULTI_PORT_MASK = 38; // Set on/off for multiple ports (partial update) +static const int TURN_OFF_ALL_PORTS = 39; // Turn off all illumination ports + static const int SET_PIN_LEVEL = 41; static const int INITFILTERWHEEL_W2 = 252; static const int INITFILTERWHEEL = 253; diff --git a/firmware/controller/src/functions.cpp b/firmware/controller/src/functions.cpp index 6382af37d..5920ef0e8 100644 --- a/firmware/controller/src/functions.cpp +++ b/firmware/controller/src/functions.cpp @@ -207,6 +207,12 @@ CRGB matrix[NUM_LEDS] = {0}; void turn_on_illumination() { illumination_is_on = true; + + // Update per-port state for D1-D5 sources (backward compatibility with new multi-port tracking) + int port_index = illumination_source_to_port_index(illumination_source); + if (port_index >= 0) + illumination_port_is_on[port_index] = true; + switch (illumination_source) { case ILLUMINATION_SOURCE_LED_ARRAY_FULL: @@ -263,6 +269,11 @@ void turn_on_illumination() void turn_off_illumination() { + // Update per-port state for D1-D5 sources (backward compatibility with new multi-port tracking) + int port_index = illumination_source_to_port_index(illumination_source); + if (port_index >= 0) + illumination_port_is_on[port_index] = false; + switch(illumination_source) { case ILLUMINATION_SOURCE_LED_ARRAY_FULL: @@ -317,6 +328,12 @@ void set_illumination(int source, uint16_t intensity) { illumination_source = source; illumination_intensity = intensity * illumination_intensity_factor; + + // Update per-port intensity for D1-D5 sources (backward compatibility with new multi-port tracking) + int port_index = illumination_source_to_port_index(source); + if (port_index >= 0) + illumination_port_intensity[port_index] = intensity; + switch (source) { case ILLUMINATION_D1: @@ -349,6 +366,108 @@ void set_illumination_led_matrix(int source, uint8_t r, uint8_t g, uint8_t b) turn_on_illumination(); //update the illumination } +/***************************************************************************************************/ +/********************************** Multi-port illumination control ********************************/ +/***************************************************************************************************/ + +// Maps illumination source code (e.g., ILLUMINATION_D1=11) to port index (0-4) +// Returns -1 for invalid source codes +int illumination_source_to_port_index(int source) +{ + switch (source) + { + case ILLUMINATION_D1: return 0; + case ILLUMINATION_D2: return 1; + case ILLUMINATION_D3: return 2; + case ILLUMINATION_D4: return 3; + case ILLUMINATION_D5: return 4; + default: return -1; + } +} + +// Gets GPIO pin for port index (0-15), returns -1 for unsupported ports +int port_index_to_pin(int port_index) +{ + switch (port_index) + { + case 0: return PIN_ILLUMINATION_D1; + case 1: return PIN_ILLUMINATION_D2; + case 2: return PIN_ILLUMINATION_D3; + case 3: return PIN_ILLUMINATION_D4; + case 4: return PIN_ILLUMINATION_D5; + // Ports 5-15 can be added here as hardware expands + default: return -1; + } +} + +// Gets DAC channel for port index (0-15), returns -1 for unsupported ports +static int port_index_to_dac_channel(int port_index) +{ + // Currently ports 0-4 map directly to DAC channels 0-4 + if (port_index >= 0 && port_index < 5) + return port_index; + return -1; +} + +void turn_on_port(int port_index) +{ + if (port_index < 0 || port_index >= NUM_ILLUMINATION_PORTS) + return; + + int pin = port_index_to_pin(port_index); + if (pin < 0) + return; + + if (INTERLOCK_OK()) + { + digitalWrite(pin, HIGH); + illumination_port_is_on[port_index] = true; + } +} + +void turn_off_port(int port_index) +{ + if (port_index < 0 || port_index >= NUM_ILLUMINATION_PORTS) + return; + + int pin = port_index_to_pin(port_index); + if (pin < 0) + return; + + digitalWrite(pin, LOW); + illumination_port_is_on[port_index] = false; +} + +void set_port_intensity(int port_index, uint16_t intensity) +{ + if (port_index < 0 || port_index >= NUM_ILLUMINATION_PORTS) + return; + + int dac_channel = port_index_to_dac_channel(port_index); + if (dac_channel < 0) + return; + + uint16_t scaled_intensity = intensity * illumination_intensity_factor; + set_DAC8050x_output(dac_channel, scaled_intensity); + illumination_port_intensity[port_index] = intensity; +} + +void turn_off_all_ports() +{ + for (int i = 0; i < NUM_ILLUMINATION_PORTS; i++) + { + int pin = port_index_to_pin(i); + if (pin >= 0) + { + digitalWrite(pin, LOW); + illumination_port_is_on[i] = false; + } + } + // Also turn off LED matrix if it was on + clear_matrix(matrix); + illumination_is_on = false; +} + void ISR_strobeTimer() { for (int camera_channel = 0; camera_channel < 4; camera_channel++) diff --git a/firmware/controller/src/functions.h b/firmware/controller/src/functions.h index bf914f6c0..f61597726 100644 --- a/firmware/controller/src/functions.h +++ b/firmware/controller/src/functions.h @@ -54,6 +54,17 @@ void set_illumination(int source, uint16_t intensity); void set_illumination_led_matrix(int source, uint8_t r, uint8_t g, uint8_t b); void ISR_strobeTimer(); +// Multi-port illumination control +// Maps source code (11-15) to port index (0-4), returns -1 for invalid source +int illumination_source_to_port_index(int source); +// Gets GPIO pin for port index, returns -1 for invalid port +int port_index_to_pin(int port_index); +// Per-port control functions (interlock checked for turn_on) +void turn_on_port(int port_index); +void turn_off_port(int port_index); +void set_port_intensity(int port_index, uint16_t intensity); +void turn_off_all_ports(); + /***************************************************************************************************/ /******************************************* joystick **********************************************/ /***************************************************************************************************/ diff --git a/firmware/controller/src/globals.cpp b/firmware/controller/src/globals.cpp index 22bbb9a89..7b58e6e10 100644 --- a/firmware/controller/src/globals.cpp +++ b/firmware/controller/src/globals.cpp @@ -136,3 +136,7 @@ uint8_t led_matrix_r = 0; uint8_t led_matrix_g = 0; uint8_t led_matrix_b = 0; bool illumination_is_on = false; + +// Multi-port illumination control (all ports off and at zero intensity by default) +bool illumination_port_is_on[NUM_ILLUMINATION_PORTS] = {false}; +uint16_t illumination_port_intensity[NUM_ILLUMINATION_PORTS] = {0}; diff --git a/firmware/controller/src/globals.h b/firmware/controller/src/globals.h index 93cc4aa7a..f6d9fe492 100644 --- a/firmware/controller/src/globals.h +++ b/firmware/controller/src/globals.h @@ -140,4 +140,9 @@ extern uint8_t led_matrix_g; extern uint8_t led_matrix_b; extern bool illumination_is_on; +// Multi-port illumination control (supports up to 16 ports D1-D16) +#define NUM_ILLUMINATION_PORTS 16 +extern bool illumination_port_is_on[NUM_ILLUMINATION_PORTS]; +extern uint16_t illumination_port_intensity[NUM_ILLUMINATION_PORTS]; + #endif // GLOBALS_H diff --git a/firmware/controller/src/serial_communication.cpp b/firmware/controller/src/serial_communication.cpp index 54fdbf8cd..15beedcf4 100644 --- a/firmware/controller/src/serial_communication.cpp +++ b/firmware/controller/src/serial_communication.cpp @@ -72,6 +72,9 @@ void send_position_update() buffer_tx[18] &= ~ (1 << BIT_POS_JOYSTICK_BUTTON); // clear the joystick button bit buffer_tx[18] = buffer_tx[18] | joystick_button_pressed << BIT_POS_JOYSTICK_BUTTON; + // Firmware version in byte 22: high nibble = major, low nibble = minor + buffer_tx[22] = (FIRMWARE_VERSION_MAJOR << 4) | (FIRMWARE_VERSION_MINOR & 0x0F); + // Calculate and fill out the checksum. NOTE: This must be after all other buffer_tx modifications are done! uint8_t checksum = crc8ccitt(buffer_tx, MSG_LENGTH - 1); buffer_tx[MSG_LENGTH - 1] = checksum; From b4d5b0418644272899e8596ccb6ead6d0937d4f9 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 20:45:43 -0800 Subject: [PATCH 09/28] feat(firmware): Add illumination utility headers Add pure C++ header-only utilities for illumination control: - illumination_mapping.h: Source code <-> port index mapping functions with proper handling of the non-sequential D3/D4 codes (D3=14, D4=13) - illumination_utils.h: Intensity conversion, firmware version encoding, and port mask utilities These are designed for easy unit testing without hardware dependencies. Co-Authored-By: Claude Opus 4.5 --- .../src/utils/illumination_mapping.h | 59 ++++++++ .../controller/src/utils/illumination_utils.h | 131 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 firmware/controller/src/utils/illumination_mapping.h create mode 100644 firmware/controller/src/utils/illumination_utils.h diff --git a/firmware/controller/src/utils/illumination_mapping.h b/firmware/controller/src/utils/illumination_mapping.h new file mode 100644 index 000000000..60d8f85b4 --- /dev/null +++ b/firmware/controller/src/utils/illumination_mapping.h @@ -0,0 +1,59 @@ +/** + * Illumination port mapping utilities. + * + * Pure C++ utility functions for mapping between legacy illumination source + * codes and port indices. No Arduino/hardware dependencies. + */ + +#ifndef ILLUMINATION_MAPPING_H +#define ILLUMINATION_MAPPING_H + +#include "../constants_protocol.h" + +// Number of illumination ports supported +#define NUM_ILLUMINATION_PORTS 16 + +/** + * Map legacy illumination source code to port index. + * + * Legacy source codes are non-sequential for historical API compatibility: + * D1 = 11, D2 = 12, D3 = 14, D4 = 13, D5 = 15 + * + * Port indices are sequential: 0=D1, 1=D2, 2=D3, 3=D4, 4=D5 + * + * @param source Legacy illumination source code (11-15) + * @return Port index (0-4), or -1 for unknown source codes + */ +inline int illumination_source_to_port_index(int source) +{ + switch (source) + { + case ILLUMINATION_D1: return 0; // 11 -> 0 + case ILLUMINATION_D2: return 1; // 12 -> 1 + case ILLUMINATION_D3: return 2; // 14 -> 2 (non-sequential!) + case ILLUMINATION_D4: return 3; // 13 -> 3 (non-sequential!) + case ILLUMINATION_D5: return 4; // 15 -> 4 + default: return -1; // Unknown source + } +} + +/** + * Map port index to legacy illumination source code. + * + * @param port_index Port index (0-4) + * @return Legacy source code (11-15), or -1 for invalid port index + */ +inline int port_index_to_illumination_source(int port_index) +{ + switch (port_index) + { + case 0: return ILLUMINATION_D1; // 0 -> 11 + case 1: return ILLUMINATION_D2; // 1 -> 12 + case 2: return ILLUMINATION_D3; // 2 -> 14 + case 3: return ILLUMINATION_D4; // 3 -> 13 + case 4: return ILLUMINATION_D5; // 4 -> 15 + default: return -1; // Invalid port + } +} + +#endif // ILLUMINATION_MAPPING_H diff --git a/firmware/controller/src/utils/illumination_utils.h b/firmware/controller/src/utils/illumination_utils.h new file mode 100644 index 000000000..f4a1f4adb --- /dev/null +++ b/firmware/controller/src/utils/illumination_utils.h @@ -0,0 +1,131 @@ +/** + * Illumination utility functions. + * + * Pure C++ utility functions for illumination calculations. + * No Arduino/hardware dependencies - suitable for host testing. + */ + +#ifndef ILLUMINATION_UTILS_H +#define ILLUMINATION_UTILS_H + +#include + +/** + * Convert intensity percentage (0-100) to 16-bit DAC value (0-65535). + * + * @param intensity_percent Intensity as percentage (0.0 to 100.0) + * @return 16-bit DAC value (0-65535) + */ +inline uint16_t intensity_percent_to_dac(float intensity_percent) +{ + if (intensity_percent <= 0.0f) return 0; + if (intensity_percent >= 100.0f) return 65535; + return (uint16_t)((intensity_percent / 100.0f) * 65535.0f); +} + +/** + * Convert 16-bit DAC value (0-65535) to intensity percentage (0-100). + * + * @param dac_value 16-bit DAC value (0-65535) + * @return Intensity as percentage (0.0 to 100.0) + */ +inline float dac_to_intensity_percent(uint16_t dac_value) +{ + return (dac_value / 65535.0f) * 100.0f; +} + +/** + * Encode firmware version as single byte (nibble-encoded). + * High nibble = major version (0-15), low nibble = minor version (0-15) + * + * @param major Major version (0-15) + * @param minor Minor version (0-15) + * @return Encoded version byte + */ +inline uint8_t encode_firmware_version(uint8_t major, uint8_t minor) +{ + return ((major & 0x0F) << 4) | (minor & 0x0F); +} + +/** + * Decode firmware version byte to major version. + * + * @param version_byte Encoded version byte + * @return Major version (0-15) + */ +inline uint8_t decode_version_major(uint8_t version_byte) +{ + return (version_byte >> 4) & 0x0F; +} + +/** + * Decode firmware version byte to minor version. + * + * @param version_byte Encoded version byte + * @return Minor version (0-15) + */ +inline uint8_t decode_version_minor(uint8_t version_byte) +{ + return version_byte & 0x0F; +} + +/** + * Check if a port is selected in a port mask. + * + * @param port_mask 16-bit port selection mask + * @param port_index Port index (0-15) + * @return true if port is selected, false otherwise + */ +inline bool is_port_selected(uint16_t port_mask, int port_index) +{ + if (port_index < 0 || port_index > 15) return false; + return (port_mask & (1 << port_index)) != 0; +} + +/** + * Check if a port should be turned on based on on_mask. + * + * @param on_mask 16-bit on/off mask + * @param port_index Port index (0-15) + * @return true if port should be on, false if off + */ +inline bool should_port_be_on(uint16_t on_mask, int port_index) +{ + if (port_index < 0 || port_index > 15) return false; + return (on_mask & (1 << port_index)) != 0; +} + +/** + * Create a port mask with specified ports selected. + * + * @param ports Array of port indices to select + * @param num_ports Number of ports in array + * @return 16-bit port mask + */ +inline uint16_t create_port_mask(const int* ports, int num_ports) +{ + uint16_t mask = 0; + for (int i = 0; i < num_ports; i++) { + if (ports[i] >= 0 && ports[i] <= 15) { + mask |= (1 << ports[i]); + } + } + return mask; +} + +/** + * Count number of ports selected in a mask. + * + * @param port_mask 16-bit port mask + * @return Number of ports selected (0-16) + */ +inline int count_selected_ports(uint16_t port_mask) +{ + int count = 0; + for (int i = 0; i < 16; i++) { + if (port_mask & (1 << i)) count++; + } + return count; +} + +#endif // ILLUMINATION_UTILS_H From aa530694f2c154492994b50f163f98ca0f5c3917 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 20:45:49 -0800 Subject: [PATCH 10/28] test(firmware): Add comprehensive illumination control tests Add PlatformIO/Unity tests for multi-port illumination: - test_illumination_mapping: 15 tests for source code <-> port index mapping - test_illumination_utils: 35 tests for intensity, version, and mask utilities - test_command_layout: 17 tests for protocol byte layout verification - test_protocol: Updated with new multi-port command codes Total: 80 firmware tests covering edge cases and the D3/D4 swap quirk. Co-Authored-By: Claude Opus 4.5 --- .../test_command_layout.cpp | 303 ++++++++++++++++++ .../test_illumination_mapping.cpp | 134 ++++++++ .../test_illumination_utils.cpp | 298 +++++++++++++++++ .../test/test_protocol/test_protocol.cpp | 48 ++- 4 files changed, 779 insertions(+), 4 deletions(-) create mode 100644 firmware/controller/test/test_command_layout/test_command_layout.cpp create mode 100644 firmware/controller/test/test_illumination_mapping/test_illumination_mapping.cpp create mode 100644 firmware/controller/test/test_illumination_utils/test_illumination_utils.cpp diff --git a/firmware/controller/test/test_command_layout/test_command_layout.cpp b/firmware/controller/test/test_command_layout/test_command_layout.cpp new file mode 100644 index 000000000..9f4b30824 --- /dev/null +++ b/firmware/controller/test/test_command_layout/test_command_layout.cpp @@ -0,0 +1,303 @@ +#include +#include +#include + +#include "constants_protocol.h" + +void setUp(void) {} +void tearDown(void) {} + +/** + * These tests verify that command byte layouts match the protocol specification. + * + * Command packet format (8 bytes): + * byte[0]: command ID (sequence number) + * byte[1]: command code + * byte[2-6]: parameters (varies by command) + * byte[7]: CRC-8 + * + * This test file creates mock command packets and verifies the byte positions. + */ + +// Helper to create a command packet +void create_command(uint8_t* buffer, uint8_t cmd_id, uint8_t cmd_code) { + memset(buffer, 0, CMD_LENGTH); + buffer[0] = cmd_id; + buffer[1] = cmd_code; + // CRC would be at buffer[7], but we don't compute it in these tests +} + +/***************************************************************************************************/ +/******************************** SET_PORT_INTENSITY Layout ****************************************/ +/***************************************************************************************************/ +// Byte layout: [cmd_id, 34, port, intensity_hi, intensity_lo, 0, 0, crc] + +void test_set_port_intensity_command_code(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, SET_PORT_INTENSITY); + TEST_ASSERT_EQUAL_UINT8(SET_PORT_INTENSITY, buffer[1]); + TEST_ASSERT_EQUAL_UINT8(34, buffer[1]); +} + +void test_set_port_intensity_port_byte(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, SET_PORT_INTENSITY); + + // Port index goes in byte[2] + buffer[2] = 3; // Port D4 + TEST_ASSERT_EQUAL_UINT8(3, buffer[2]); +} + +void test_set_port_intensity_value_bytes(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, SET_PORT_INTENSITY); + + // Intensity value is 16-bit big-endian in bytes[3:4] + uint16_t intensity = 32768; // 50% + buffer[3] = (intensity >> 8) & 0xFF; // High byte + buffer[4] = intensity & 0xFF; // Low byte + + // Verify we can reconstruct the value + uint16_t reconstructed = (buffer[3] << 8) | buffer[4]; + TEST_ASSERT_EQUAL_UINT16(32768, reconstructed); +} + +/***************************************************************************************************/ +/******************************** TURN_ON_PORT Layout **********************************************/ +/***************************************************************************************************/ +// Byte layout: [cmd_id, 35, port, 0, 0, 0, 0, crc] + +void test_turn_on_port_command_code(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, TURN_ON_PORT); + TEST_ASSERT_EQUAL_UINT8(TURN_ON_PORT, buffer[1]); + TEST_ASSERT_EQUAL_UINT8(35, buffer[1]); +} + +void test_turn_on_port_port_byte(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, TURN_ON_PORT); + + buffer[2] = 0; // Port D1 + TEST_ASSERT_EQUAL_UINT8(0, buffer[2]); + + buffer[2] = 4; // Port D5 + TEST_ASSERT_EQUAL_UINT8(4, buffer[2]); +} + +/***************************************************************************************************/ +/******************************** TURN_OFF_PORT Layout *********************************************/ +/***************************************************************************************************/ +// Byte layout: [cmd_id, 36, port, 0, 0, 0, 0, crc] + +void test_turn_off_port_command_code(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, TURN_OFF_PORT); + TEST_ASSERT_EQUAL_UINT8(TURN_OFF_PORT, buffer[1]); + TEST_ASSERT_EQUAL_UINT8(36, buffer[1]); +} + +/***************************************************************************************************/ +/******************************** SET_PORT_ILLUMINATION Layout *************************************/ +/***************************************************************************************************/ +// Byte layout: [cmd_id, 37, port, intensity_hi, intensity_lo, on_flag, 0, crc] + +void test_set_port_illumination_command_code(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, SET_PORT_ILLUMINATION); + TEST_ASSERT_EQUAL_UINT8(SET_PORT_ILLUMINATION, buffer[1]); + TEST_ASSERT_EQUAL_UINT8(37, buffer[1]); +} + +void test_set_port_illumination_on_flag_byte(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, SET_PORT_ILLUMINATION); + + // on_flag is in byte[5] + buffer[5] = 1; // Turn on + TEST_ASSERT_EQUAL_UINT8(1, buffer[5]); + + buffer[5] = 0; // Turn off + TEST_ASSERT_EQUAL_UINT8(0, buffer[5]); +} + +void test_set_port_illumination_full_packet(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 42, SET_PORT_ILLUMINATION); + + buffer[2] = 2; // Port D3 + uint16_t intensity = 65535; // 100% + buffer[3] = (intensity >> 8) & 0xFF; + buffer[4] = intensity & 0xFF; + buffer[5] = 1; // Turn on + + TEST_ASSERT_EQUAL_UINT8(42, buffer[0]); // cmd_id + TEST_ASSERT_EQUAL_UINT8(37, buffer[1]); // cmd_code + TEST_ASSERT_EQUAL_UINT8(2, buffer[2]); // port + TEST_ASSERT_EQUAL_UINT8(0xFF, buffer[3]); // intensity_hi + TEST_ASSERT_EQUAL_UINT8(0xFF, buffer[4]); // intensity_lo + TEST_ASSERT_EQUAL_UINT8(1, buffer[5]); // on_flag +} + +/***************************************************************************************************/ +/******************************** SET_MULTI_PORT_MASK Layout ***************************************/ +/***************************************************************************************************/ +// Byte layout: [cmd_id, 38, mask_hi, mask_lo, on_hi, on_lo, 0, crc] + +void test_set_multi_port_mask_command_code(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, SET_MULTI_PORT_MASK); + TEST_ASSERT_EQUAL_UINT8(SET_MULTI_PORT_MASK, buffer[1]); + TEST_ASSERT_EQUAL_UINT8(38, buffer[1]); +} + +void test_set_multi_port_mask_16bit_masks(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, SET_MULTI_PORT_MASK); + + // port_mask = 0x001F (D1-D5) + uint16_t port_mask = 0x001F; + buffer[2] = (port_mask >> 8) & 0xFF; // mask_hi + buffer[3] = port_mask & 0xFF; // mask_lo + + // on_mask = 0x0015 (D1, D3, D5 on; D2, D4 off) + uint16_t on_mask = 0x0015; + buffer[4] = (on_mask >> 8) & 0xFF; // on_hi + buffer[5] = on_mask & 0xFF; // on_lo + + // Verify reconstruction + uint16_t reconstructed_port = (buffer[2] << 8) | buffer[3]; + uint16_t reconstructed_on = (buffer[4] << 8) | buffer[5]; + + TEST_ASSERT_EQUAL_HEX16(0x001F, reconstructed_port); + TEST_ASSERT_EQUAL_HEX16(0x0015, reconstructed_on); +} + +void test_set_multi_port_mask_high_ports(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, SET_MULTI_PORT_MASK); + + // Test with ports 8-15 (requires high byte) + uint16_t port_mask = 0xFF00; // Ports 8-15 + buffer[2] = (port_mask >> 8) & 0xFF; + buffer[3] = port_mask & 0xFF; + + uint16_t reconstructed = (buffer[2] << 8) | buffer[3]; + TEST_ASSERT_EQUAL_HEX16(0xFF00, reconstructed); + TEST_ASSERT_EQUAL_UINT8(0xFF, buffer[2]); // High byte should be 0xFF + TEST_ASSERT_EQUAL_UINT8(0x00, buffer[3]); // Low byte should be 0x00 +} + +/***************************************************************************************************/ +/******************************** TURN_OFF_ALL_PORTS Layout ****************************************/ +/***************************************************************************************************/ +// Byte layout: [cmd_id, 39, 0, 0, 0, 0, 0, crc] + +void test_turn_off_all_ports_command_code(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, TURN_OFF_ALL_PORTS); + TEST_ASSERT_EQUAL_UINT8(TURN_OFF_ALL_PORTS, buffer[1]); + TEST_ASSERT_EQUAL_UINT8(39, buffer[1]); +} + +void test_turn_off_all_ports_no_params(void) { + uint8_t buffer[CMD_LENGTH]; + create_command(buffer, 1, TURN_OFF_ALL_PORTS); + + // Bytes 2-6 should all be 0 (no parameters) + TEST_ASSERT_EQUAL_UINT8(0, buffer[2]); + TEST_ASSERT_EQUAL_UINT8(0, buffer[3]); + TEST_ASSERT_EQUAL_UINT8(0, buffer[4]); + TEST_ASSERT_EQUAL_UINT8(0, buffer[5]); + TEST_ASSERT_EQUAL_UINT8(0, buffer[6]); +} + +/***************************************************************************************************/ +/******************************** Response Byte Layout *********************************************/ +/***************************************************************************************************/ +// Response packet format (24 bytes): +// byte[0]: command ID +// byte[1]: execution status +// byte[2-5]: X position +// byte[6-9]: Y position +// byte[10-13]: Z position +// byte[14-17]: Theta position +// byte[18]: buttons and switches +// byte[19-21]: reserved +// byte[22]: firmware version (nibble-encoded) +// byte[23]: CRC-8 + +void test_response_layout_constants(void) { + TEST_ASSERT_EQUAL_INT(24, MSG_LENGTH); + TEST_ASSERT_TRUE(MSG_LENGTH > CMD_LENGTH); +} + +void test_response_version_byte_position(void) { + // Firmware version is at byte 22 + uint8_t response[MSG_LENGTH]; + memset(response, 0, MSG_LENGTH); + + // Set version 1.0 (0x10) + response[22] = 0x10; + + // Verify position + TEST_ASSERT_EQUAL_UINT8(0x10, response[22]); + + // Verify decoding + uint8_t major = (response[22] >> 4) & 0x0F; + uint8_t minor = response[22] & 0x0F; + TEST_ASSERT_EQUAL_UINT8(1, major); + TEST_ASSERT_EQUAL_UINT8(0, minor); +} + +void test_response_execution_status_byte(void) { + uint8_t response[MSG_LENGTH]; + memset(response, 0, MSG_LENGTH); + + // Execution status is at byte 1 + response[1] = COMPLETED_WITHOUT_ERRORS; + TEST_ASSERT_EQUAL_UINT8(0, response[1]); + + response[1] = IN_PROGRESS; + TEST_ASSERT_EQUAL_UINT8(1, response[1]); + + response[1] = CMD_CHECKSUM_ERROR; + TEST_ASSERT_EQUAL_UINT8(2, response[1]); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + + // SET_PORT_INTENSITY tests + RUN_TEST(test_set_port_intensity_command_code); + RUN_TEST(test_set_port_intensity_port_byte); + RUN_TEST(test_set_port_intensity_value_bytes); + + // TURN_ON_PORT tests + RUN_TEST(test_turn_on_port_command_code); + RUN_TEST(test_turn_on_port_port_byte); + + // TURN_OFF_PORT tests + RUN_TEST(test_turn_off_port_command_code); + + // SET_PORT_ILLUMINATION tests + RUN_TEST(test_set_port_illumination_command_code); + RUN_TEST(test_set_port_illumination_on_flag_byte); + RUN_TEST(test_set_port_illumination_full_packet); + + // SET_MULTI_PORT_MASK tests + RUN_TEST(test_set_multi_port_mask_command_code); + RUN_TEST(test_set_multi_port_mask_16bit_masks); + RUN_TEST(test_set_multi_port_mask_high_ports); + + // TURN_OFF_ALL_PORTS tests + RUN_TEST(test_turn_off_all_ports_command_code); + RUN_TEST(test_turn_off_all_ports_no_params); + + // Response layout tests + RUN_TEST(test_response_layout_constants); + RUN_TEST(test_response_version_byte_position); + RUN_TEST(test_response_execution_status_byte); + + return UNITY_END(); +} diff --git a/firmware/controller/test/test_illumination_mapping/test_illumination_mapping.cpp b/firmware/controller/test/test_illumination_mapping/test_illumination_mapping.cpp new file mode 100644 index 000000000..55a93a391 --- /dev/null +++ b/firmware/controller/test/test_illumination_mapping/test_illumination_mapping.cpp @@ -0,0 +1,134 @@ +#include +#include + +// Include protocol constants and mapping utilities +#include "constants_protocol.h" +#include "utils/illumination_mapping.h" + +void setUp(void) {} +void tearDown(void) {} + +// Test illumination_source_to_port_index mapping + +void test_source_d1_maps_to_port_0(void) { + TEST_ASSERT_EQUAL_INT(0, illumination_source_to_port_index(ILLUMINATION_D1)); +} + +void test_source_d2_maps_to_port_1(void) { + TEST_ASSERT_EQUAL_INT(1, illumination_source_to_port_index(ILLUMINATION_D2)); +} + +void test_source_d3_maps_to_port_2(void) { + // Note: D3 has source code 14 (non-sequential!) + TEST_ASSERT_EQUAL_INT(2, illumination_source_to_port_index(ILLUMINATION_D3)); + TEST_ASSERT_EQUAL_INT(2, illumination_source_to_port_index(14)); +} + +void test_source_d4_maps_to_port_3(void) { + // Note: D4 has source code 13 (non-sequential!) + TEST_ASSERT_EQUAL_INT(3, illumination_source_to_port_index(ILLUMINATION_D4)); + TEST_ASSERT_EQUAL_INT(3, illumination_source_to_port_index(13)); +} + +void test_source_d5_maps_to_port_4(void) { + TEST_ASSERT_EQUAL_INT(4, illumination_source_to_port_index(ILLUMINATION_D5)); +} + +void test_unknown_source_returns_negative_one(void) { + TEST_ASSERT_EQUAL_INT(-1, illumination_source_to_port_index(0)); + TEST_ASSERT_EQUAL_INT(-1, illumination_source_to_port_index(10)); + TEST_ASSERT_EQUAL_INT(-1, illumination_source_to_port_index(16)); + TEST_ASSERT_EQUAL_INT(-1, illumination_source_to_port_index(100)); +} + +// Test port_index_to_illumination_source mapping (reverse) + +void test_port_0_maps_to_source_d1(void) { + TEST_ASSERT_EQUAL_INT(ILLUMINATION_D1, port_index_to_illumination_source(0)); +} + +void test_port_1_maps_to_source_d2(void) { + TEST_ASSERT_EQUAL_INT(ILLUMINATION_D2, port_index_to_illumination_source(1)); +} + +void test_port_2_maps_to_source_d3(void) { + TEST_ASSERT_EQUAL_INT(ILLUMINATION_D3, port_index_to_illumination_source(2)); + TEST_ASSERT_EQUAL_INT(14, port_index_to_illumination_source(2)); +} + +void test_port_3_maps_to_source_d4(void) { + TEST_ASSERT_EQUAL_INT(ILLUMINATION_D4, port_index_to_illumination_source(3)); + TEST_ASSERT_EQUAL_INT(13, port_index_to_illumination_source(3)); +} + +void test_port_4_maps_to_source_d5(void) { + TEST_ASSERT_EQUAL_INT(ILLUMINATION_D5, port_index_to_illumination_source(4)); +} + +void test_invalid_port_returns_negative_one(void) { + TEST_ASSERT_EQUAL_INT(-1, port_index_to_illumination_source(-1)); + TEST_ASSERT_EQUAL_INT(-1, port_index_to_illumination_source(5)); + TEST_ASSERT_EQUAL_INT(-1, port_index_to_illumination_source(100)); +} + +// Test round-trip mapping consistency + +void test_round_trip_source_to_port_to_source(void) { + // For each valid source, map to port and back to source + int sources[] = {ILLUMINATION_D1, ILLUMINATION_D2, ILLUMINATION_D3, ILLUMINATION_D4, ILLUMINATION_D5}; + for (int i = 0; i < 5; i++) { + int port = illumination_source_to_port_index(sources[i]); + int source_back = port_index_to_illumination_source(port); + TEST_ASSERT_EQUAL_INT_MESSAGE(sources[i], source_back, "Round trip failed"); + } +} + +void test_round_trip_port_to_source_to_port(void) { + // For each valid port, map to source and back to port + for (int port = 0; port < 5; port++) { + int source = port_index_to_illumination_source(port); + int port_back = illumination_source_to_port_index(source); + TEST_ASSERT_EQUAL_INT_MESSAGE(port, port_back, "Round trip failed"); + } +} + +// Test non-sequential D3/D4 mapping specifically +void test_d3_d4_non_sequential_mapping(void) { + // This is a critical test: D3 and D4 have swapped source codes + // D3 = 14, D4 = 13 (not 13, 14 as you might expect) + TEST_ASSERT_EQUAL_INT(2, illumination_source_to_port_index(14)); // D3 + TEST_ASSERT_EQUAL_INT(3, illumination_source_to_port_index(13)); // D4 + + // Verify the constants match + TEST_ASSERT_EQUAL_INT(14, ILLUMINATION_D3); + TEST_ASSERT_EQUAL_INT(13, ILLUMINATION_D4); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + + // Source to port mapping + RUN_TEST(test_source_d1_maps_to_port_0); + RUN_TEST(test_source_d2_maps_to_port_1); + RUN_TEST(test_source_d3_maps_to_port_2); + RUN_TEST(test_source_d4_maps_to_port_3); + RUN_TEST(test_source_d5_maps_to_port_4); + RUN_TEST(test_unknown_source_returns_negative_one); + + // Port to source mapping + RUN_TEST(test_port_0_maps_to_source_d1); + RUN_TEST(test_port_1_maps_to_source_d2); + RUN_TEST(test_port_2_maps_to_source_d3); + RUN_TEST(test_port_3_maps_to_source_d4); + RUN_TEST(test_port_4_maps_to_source_d5); + RUN_TEST(test_invalid_port_returns_negative_one); + + // Round-trip consistency + RUN_TEST(test_round_trip_source_to_port_to_source); + RUN_TEST(test_round_trip_port_to_source_to_port); + + // Critical D3/D4 non-sequential mapping + RUN_TEST(test_d3_d4_non_sequential_mapping); + + return UNITY_END(); +} diff --git a/firmware/controller/test/test_illumination_utils/test_illumination_utils.cpp b/firmware/controller/test/test_illumination_utils/test_illumination_utils.cpp new file mode 100644 index 000000000..08147ca31 --- /dev/null +++ b/firmware/controller/test/test_illumination_utils/test_illumination_utils.cpp @@ -0,0 +1,298 @@ +#include +#include +#include + +#include "utils/illumination_utils.h" + +void setUp(void) {} +void tearDown(void) {} + +/***************************************************************************************************/ +/********************************** Intensity Conversion Tests *************************************/ +/***************************************************************************************************/ + +void test_intensity_zero_percent(void) { + TEST_ASSERT_EQUAL_UINT16(0, intensity_percent_to_dac(0.0f)); +} + +void test_intensity_100_percent(void) { + TEST_ASSERT_EQUAL_UINT16(65535, intensity_percent_to_dac(100.0f)); +} + +void test_intensity_50_percent(void) { + uint16_t result = intensity_percent_to_dac(50.0f); + // 50% should be approximately 32767-32768 + TEST_ASSERT_TRUE(result >= 32767 && result <= 32768); +} + +void test_intensity_negative_clamps_to_zero(void) { + TEST_ASSERT_EQUAL_UINT16(0, intensity_percent_to_dac(-10.0f)); + TEST_ASSERT_EQUAL_UINT16(0, intensity_percent_to_dac(-100.0f)); +} + +void test_intensity_over_100_clamps_to_max(void) { + TEST_ASSERT_EQUAL_UINT16(65535, intensity_percent_to_dac(150.0f)); + TEST_ASSERT_EQUAL_UINT16(65535, intensity_percent_to_dac(200.0f)); +} + +void test_intensity_1_percent(void) { + uint16_t result = intensity_percent_to_dac(1.0f); + // 1% should be approximately 655 + TEST_ASSERT_TRUE(result >= 654 && result <= 656); +} + +void test_intensity_round_trip(void) { + // Test that converting to DAC and back gives approximately the same value + float original = 75.0f; + uint16_t dac = intensity_percent_to_dac(original); + float recovered = dac_to_intensity_percent(dac); + // Should be within 0.01% due to quantization + TEST_ASSERT_FLOAT_WITHIN(0.01f, original, recovered); +} + +void test_dac_to_percent_zero(void) { + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, dac_to_intensity_percent(0)); +} + +void test_dac_to_percent_max(void) { + TEST_ASSERT_FLOAT_WITHIN(0.001f, 100.0f, dac_to_intensity_percent(65535)); +} + +/***************************************************************************************************/ +/********************************** Firmware Version Tests *****************************************/ +/***************************************************************************************************/ + +void test_version_encode_1_0(void) { + TEST_ASSERT_EQUAL_HEX8(0x10, encode_firmware_version(1, 0)); +} + +void test_version_encode_2_3(void) { + TEST_ASSERT_EQUAL_HEX8(0x23, encode_firmware_version(2, 3)); +} + +void test_version_encode_15_15(void) { + TEST_ASSERT_EQUAL_HEX8(0xFF, encode_firmware_version(15, 15)); +} + +void test_version_encode_0_0(void) { + TEST_ASSERT_EQUAL_HEX8(0x00, encode_firmware_version(0, 0)); +} + +void test_version_decode_major(void) { + TEST_ASSERT_EQUAL_UINT8(1, decode_version_major(0x10)); + TEST_ASSERT_EQUAL_UINT8(2, decode_version_major(0x23)); + TEST_ASSERT_EQUAL_UINT8(15, decode_version_major(0xFF)); + TEST_ASSERT_EQUAL_UINT8(0, decode_version_major(0x00)); +} + +void test_version_decode_minor(void) { + TEST_ASSERT_EQUAL_UINT8(0, decode_version_minor(0x10)); + TEST_ASSERT_EQUAL_UINT8(3, decode_version_minor(0x23)); + TEST_ASSERT_EQUAL_UINT8(15, decode_version_minor(0xFF)); + TEST_ASSERT_EQUAL_UINT8(0, decode_version_minor(0x00)); +} + +void test_version_round_trip(void) { + for (uint8_t major = 0; major <= 15; major++) { + for (uint8_t minor = 0; minor <= 15; minor++) { + uint8_t encoded = encode_firmware_version(major, minor); + TEST_ASSERT_EQUAL_UINT8(major, decode_version_major(encoded)); + TEST_ASSERT_EQUAL_UINT8(minor, decode_version_minor(encoded)); + } + } +} + +void test_version_truncates_overflow(void) { + // Values > 15 should be truncated to 4 bits + TEST_ASSERT_EQUAL_HEX8(0x00, encode_firmware_version(16, 0)); // 16 & 0x0F = 0, result = 0x00 + TEST_ASSERT_EQUAL_HEX8(0x11, encode_firmware_version(17, 1)); // 17 & 0x0F = 1, 1 & 0x0F = 1, result = 0x11 +} + +/***************************************************************************************************/ +/************************************ Port Mask Tests **********************************************/ +/***************************************************************************************************/ + +void test_is_port_selected_single_port(void) { + uint16_t mask = 0x0001; // Only port 0 selected + TEST_ASSERT_TRUE(is_port_selected(mask, 0)); + TEST_ASSERT_FALSE(is_port_selected(mask, 1)); + TEST_ASSERT_FALSE(is_port_selected(mask, 15)); +} + +void test_is_port_selected_multiple_ports(void) { + uint16_t mask = 0x001F; // Ports 0-4 selected (D1-D5) + TEST_ASSERT_TRUE(is_port_selected(mask, 0)); + TEST_ASSERT_TRUE(is_port_selected(mask, 1)); + TEST_ASSERT_TRUE(is_port_selected(mask, 2)); + TEST_ASSERT_TRUE(is_port_selected(mask, 3)); + TEST_ASSERT_TRUE(is_port_selected(mask, 4)); + TEST_ASSERT_FALSE(is_port_selected(mask, 5)); +} + +void test_is_port_selected_invalid_index(void) { + uint16_t mask = 0xFFFF; // All ports selected + TEST_ASSERT_FALSE(is_port_selected(mask, -1)); + TEST_ASSERT_FALSE(is_port_selected(mask, 16)); + TEST_ASSERT_FALSE(is_port_selected(mask, 100)); +} + +void test_should_port_be_on(void) { + uint16_t on_mask = 0x0005; // Ports 0 and 2 should be on + TEST_ASSERT_TRUE(should_port_be_on(on_mask, 0)); + TEST_ASSERT_FALSE(should_port_be_on(on_mask, 1)); + TEST_ASSERT_TRUE(should_port_be_on(on_mask, 2)); + TEST_ASSERT_FALSE(should_port_be_on(on_mask, 3)); +} + +void test_create_port_mask_empty(void) { + int ports[] = {}; + TEST_ASSERT_EQUAL_HEX16(0x0000, create_port_mask(ports, 0)); +} + +void test_create_port_mask_single(void) { + int ports[] = {3}; + TEST_ASSERT_EQUAL_HEX16(0x0008, create_port_mask(ports, 1)); +} + +void test_create_port_mask_multiple(void) { + int ports[] = {0, 2, 4}; // D1, D3, D5 + TEST_ASSERT_EQUAL_HEX16(0x0015, create_port_mask(ports, 3)); +} + +void test_create_port_mask_all_five(void) { + int ports[] = {0, 1, 2, 3, 4}; // D1-D5 + TEST_ASSERT_EQUAL_HEX16(0x001F, create_port_mask(ports, 5)); +} + +void test_create_port_mask_ignores_invalid(void) { + int ports[] = {0, -1, 16, 100, 4}; // Invalid indices ignored + TEST_ASSERT_EQUAL_HEX16(0x0011, create_port_mask(ports, 5)); // Only 0 and 4 +} + +void test_count_selected_ports_none(void) { + TEST_ASSERT_EQUAL_INT(0, count_selected_ports(0x0000)); +} + +void test_count_selected_ports_one(void) { + TEST_ASSERT_EQUAL_INT(1, count_selected_ports(0x0001)); + TEST_ASSERT_EQUAL_INT(1, count_selected_ports(0x8000)); +} + +void test_count_selected_ports_five(void) { + TEST_ASSERT_EQUAL_INT(5, count_selected_ports(0x001F)); // D1-D5 +} + +void test_count_selected_ports_all(void) { + TEST_ASSERT_EQUAL_INT(16, count_selected_ports(0xFFFF)); +} + +/***************************************************************************************************/ +/******************************** Multi-port Mask Scenario Tests ***********************************/ +/***************************************************************************************************/ + +void test_scenario_turn_on_d1_d2(void) { + // Scenario: Turn on D1 and D2 only + uint16_t port_mask = 0x0003; // Select D1 (bit 0) and D2 (bit 1) + uint16_t on_mask = 0x0003; // Both should be on + + TEST_ASSERT_TRUE(is_port_selected(port_mask, 0)); + TEST_ASSERT_TRUE(is_port_selected(port_mask, 1)); + TEST_ASSERT_FALSE(is_port_selected(port_mask, 2)); + + TEST_ASSERT_TRUE(should_port_be_on(on_mask, 0)); + TEST_ASSERT_TRUE(should_port_be_on(on_mask, 1)); +} + +void test_scenario_turn_off_d3_leave_others(void) { + // Scenario: Turn off D3 only, leave others unchanged + uint16_t port_mask = 0x0004; // Select only D3 (bit 2) + uint16_t on_mask = 0x0000; // Turn it off + + TEST_ASSERT_TRUE(is_port_selected(port_mask, 2)); + TEST_ASSERT_FALSE(is_port_selected(port_mask, 0)); + TEST_ASSERT_FALSE(is_port_selected(port_mask, 1)); + + TEST_ASSERT_FALSE(should_port_be_on(on_mask, 2)); +} + +void test_scenario_turn_on_d1_off_d2(void) { + // Scenario: Turn on D1, turn off D2 in one command + uint16_t port_mask = 0x0003; // Select D1 and D2 + uint16_t on_mask = 0x0001; // D1 on, D2 off + + TEST_ASSERT_TRUE(should_port_be_on(on_mask, 0)); // D1 on + TEST_ASSERT_FALSE(should_port_be_on(on_mask, 1)); // D2 off +} + +void test_scenario_all_five_on(void) { + // Scenario: Turn on all five ports (D1-D5) + uint16_t port_mask = 0x001F; + uint16_t on_mask = 0x001F; + + TEST_ASSERT_EQUAL_INT(5, count_selected_ports(port_mask)); + for (int i = 0; i < 5; i++) { + TEST_ASSERT_TRUE(is_port_selected(port_mask, i)); + TEST_ASSERT_TRUE(should_port_be_on(on_mask, i)); + } +} + +void test_scenario_all_five_off(void) { + // Scenario: Turn off all five ports (D1-D5) + uint16_t port_mask = 0x001F; + uint16_t on_mask = 0x0000; + + TEST_ASSERT_EQUAL_INT(5, count_selected_ports(port_mask)); + for (int i = 0; i < 5; i++) { + TEST_ASSERT_TRUE(is_port_selected(port_mask, i)); + TEST_ASSERT_FALSE(should_port_be_on(on_mask, i)); + } +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + + // Intensity conversion tests + RUN_TEST(test_intensity_zero_percent); + RUN_TEST(test_intensity_100_percent); + RUN_TEST(test_intensity_50_percent); + RUN_TEST(test_intensity_negative_clamps_to_zero); + RUN_TEST(test_intensity_over_100_clamps_to_max); + RUN_TEST(test_intensity_1_percent); + RUN_TEST(test_intensity_round_trip); + RUN_TEST(test_dac_to_percent_zero); + RUN_TEST(test_dac_to_percent_max); + + // Firmware version tests + RUN_TEST(test_version_encode_1_0); + RUN_TEST(test_version_encode_2_3); + RUN_TEST(test_version_encode_15_15); + RUN_TEST(test_version_encode_0_0); + RUN_TEST(test_version_decode_major); + RUN_TEST(test_version_decode_minor); + RUN_TEST(test_version_round_trip); + RUN_TEST(test_version_truncates_overflow); + + // Port mask tests + RUN_TEST(test_is_port_selected_single_port); + RUN_TEST(test_is_port_selected_multiple_ports); + RUN_TEST(test_is_port_selected_invalid_index); + RUN_TEST(test_should_port_be_on); + RUN_TEST(test_create_port_mask_empty); + RUN_TEST(test_create_port_mask_single); + RUN_TEST(test_create_port_mask_multiple); + RUN_TEST(test_create_port_mask_all_five); + RUN_TEST(test_create_port_mask_ignores_invalid); + RUN_TEST(test_count_selected_ports_none); + RUN_TEST(test_count_selected_ports_one); + RUN_TEST(test_count_selected_ports_five); + RUN_TEST(test_count_selected_ports_all); + + // Scenario tests + RUN_TEST(test_scenario_turn_on_d1_d2); + RUN_TEST(test_scenario_turn_off_d3_leave_others); + RUN_TEST(test_scenario_turn_on_d1_off_d2); + RUN_TEST(test_scenario_all_five_on); + RUN_TEST(test_scenario_all_five_off); + + return UNITY_END(); +} diff --git a/firmware/controller/test/test_protocol/test_protocol.cpp b/firmware/controller/test/test_protocol/test_protocol.cpp index d02d121fa..17894651a 100644 --- a/firmware/controller/test/test_protocol/test_protocol.cpp +++ b/firmware/controller/test/test_protocol/test_protocol.cpp @@ -11,7 +11,7 @@ void tearDown(void) {} void test_command_ids_are_unique(void) { std::set ids; int commands[] = { - MOVE_X, MOVE_Y, MOVE_Z, MOVE_THETA, MOVE_W, + MOVE_X, MOVE_Y, MOVE_Z, MOVE_THETA, MOVE_W, MOVE_W2, HOME_OR_ZERO, MOVETO_X, MOVETO_Y, MOVETO_Z, SET_LIM, TURN_ON_ILLUMINATION, TURN_OFF_ILLUMINATION, SET_ILLUMINATION, SET_ILLUMINATION_LED_MATRIX, @@ -22,7 +22,10 @@ void test_command_ids_are_unique(void) { SET_OFFSET_VELOCITY, CONFIGURE_STAGE_PID, ENABLE_STAGE_PID, DISABLE_STAGE_PID, SET_HOME_SAFETY_MERGIN, SET_PID_ARGUMENTS, SEND_HARDWARE_TRIGGER, SET_STROBE_DELAY, SET_AXIS_DISABLE_ENABLE, - SET_PIN_LEVEL, INITFILTERWHEEL, INITIALIZE, RESET + SET_PIN_LEVEL, INITFILTERWHEEL, INITFILTERWHEEL_W2, INITIALIZE, RESET, + // Multi-port illumination commands (firmware v1.0+) + SET_PORT_INTENSITY, TURN_ON_PORT, TURN_OFF_PORT, + SET_PORT_ILLUMINATION, SET_MULTI_PORT_MASK, TURN_OFF_ALL_PORTS }; int num_commands = sizeof(commands) / sizeof(commands[0]); @@ -38,7 +41,7 @@ void test_command_ids_are_unique(void) { void test_command_ids_fit_in_byte(void) { int commands[] = { - MOVE_X, MOVE_Y, MOVE_Z, MOVE_THETA, MOVE_W, + MOVE_X, MOVE_Y, MOVE_Z, MOVE_THETA, MOVE_W, MOVE_W2, HOME_OR_ZERO, MOVETO_X, MOVETO_Y, MOVETO_Z, SET_LIM, TURN_ON_ILLUMINATION, TURN_OFF_ILLUMINATION, SET_ILLUMINATION, SET_ILLUMINATION_LED_MATRIX, @@ -49,7 +52,10 @@ void test_command_ids_fit_in_byte(void) { SET_OFFSET_VELOCITY, CONFIGURE_STAGE_PID, ENABLE_STAGE_PID, DISABLE_STAGE_PID, SET_HOME_SAFETY_MERGIN, SET_PID_ARGUMENTS, SEND_HARDWARE_TRIGGER, SET_STROBE_DELAY, SET_AXIS_DISABLE_ENABLE, - SET_PIN_LEVEL, INITFILTERWHEEL, INITIALIZE, RESET + SET_PIN_LEVEL, INITFILTERWHEEL, INITFILTERWHEEL_W2, INITIALIZE, RESET, + // Multi-port illumination commands (firmware v1.0+) + SET_PORT_INTENSITY, TURN_ON_PORT, TURN_OFF_PORT, + SET_PORT_ILLUMINATION, SET_MULTI_PORT_MASK, TURN_OFF_ALL_PORTS }; int num_commands = sizeof(commands) / sizeof(commands[0]); @@ -74,6 +80,38 @@ void test_axis_ids_are_sequential(void) { TEST_ASSERT_EQUAL_INT(3, AXIS_THETA); TEST_ASSERT_EQUAL_INT(4, AXES_XY); TEST_ASSERT_EQUAL_INT(5, AXIS_W); + TEST_ASSERT_EQUAL_INT(6, AXIS_W2); +} + +void test_multiport_illumination_commands(void) { + // Verify multi-port illumination command IDs are in expected range + TEST_ASSERT_EQUAL_INT(34, SET_PORT_INTENSITY); + TEST_ASSERT_EQUAL_INT(35, TURN_ON_PORT); + TEST_ASSERT_EQUAL_INT(36, TURN_OFF_PORT); + TEST_ASSERT_EQUAL_INT(37, SET_PORT_ILLUMINATION); + TEST_ASSERT_EQUAL_INT(38, SET_MULTI_PORT_MASK); + TEST_ASSERT_EQUAL_INT(39, TURN_OFF_ALL_PORTS); + + // Verify they don't conflict with legacy illumination commands + TEST_ASSERT_NOT_EQUAL(TURN_ON_ILLUMINATION, TURN_ON_PORT); + TEST_ASSERT_NOT_EQUAL(TURN_OFF_ILLUMINATION, TURN_OFF_PORT); + TEST_ASSERT_NOT_EQUAL(SET_ILLUMINATION, SET_PORT_INTENSITY); +} + +void test_illumination_source_codes(void) { + // Legacy illumination source codes (non-sequential D3/D4!) + TEST_ASSERT_EQUAL_INT(11, ILLUMINATION_D1); + TEST_ASSERT_EQUAL_INT(12, ILLUMINATION_D2); + TEST_ASSERT_EQUAL_INT(14, ILLUMINATION_D3); // Note: 14, not 13! + TEST_ASSERT_EQUAL_INT(13, ILLUMINATION_D4); // Note: 13, not 14! + TEST_ASSERT_EQUAL_INT(15, ILLUMINATION_D5); + + // Verify legacy wavelength aliases match port codes + TEST_ASSERT_EQUAL_INT(ILLUMINATION_D1, ILLUMINATION_SOURCE_405NM); + TEST_ASSERT_EQUAL_INT(ILLUMINATION_D2, ILLUMINATION_SOURCE_488NM); + TEST_ASSERT_EQUAL_INT(ILLUMINATION_D3, ILLUMINATION_SOURCE_561NM); + TEST_ASSERT_EQUAL_INT(ILLUMINATION_D4, ILLUMINATION_SOURCE_638NM); + TEST_ASSERT_EQUAL_INT(ILLUMINATION_D5, ILLUMINATION_SOURCE_730NM); } int main(int argc, char **argv) { @@ -83,6 +121,8 @@ int main(int argc, char **argv) { RUN_TEST(test_command_ids_fit_in_byte); RUN_TEST(test_message_lengths); RUN_TEST(test_axis_ids_are_sequential); + RUN_TEST(test_multiport_illumination_commands); + RUN_TEST(test_illumination_source_codes); return UNITY_END(); } From 1da03a52b7f6edab9812d97a5f7b8954fc4bc104 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 20:46:03 -0800 Subject: [PATCH 11/28] feat(python): Add multi-port illumination control Add Python support for multi-port illumination: Microcontroller: - Add new MCU methods: set_port_intensity, turn_on_port, turn_off_port, set_port_illumination, set_multi_port_mask, turn_off_all_ports - Add firmware version detection from response byte 22 - Add supports_multi_port() check for version >= 1.0 - Update SimSerial with multi-port state simulation and legacy command sync IlluminationController: - Add per-port state tracking (port_is_on, port_intensity) - Add high-level methods matching MCU interface - Add turn_on_multiple_ports() and get_active_ports() helpers _def.py: - Add ILLUMINATION_PORT constants (D1-D5 = 0-4) - Add CMD_SET constants for new commands (34-39) - Add source_code_to_port_index() and port_index_to_source_code() helpers Co-Authored-By: Claude Opus 4.5 --- software/control/_def.py | 68 ++++++++++ software/control/lighting.py | 104 ++++++++++++++- software/control/microcontroller.py | 199 +++++++++++++++++++++++++++- 3 files changed, 367 insertions(+), 4 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 0c765554b..007cebe76 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -193,6 +193,13 @@ class CMD_SET: SET_STROBE_DELAY = 31 SET_AXIS_DISABLE_ENABLE = 32 SET_TRIGGER_MODE = 33 + # Multi-port illumination commands (firmware v1.0+) + SET_PORT_INTENSITY = 34 # Set DAC intensity for specific port only + TURN_ON_PORT = 35 # Turn on GPIO for specific port + TURN_OFF_PORT = 36 # Turn off GPIO for specific port + SET_PORT_ILLUMINATION = 37 # Set intensity + on/off in one command + SET_MULTI_PORT_MASK = 38 # Set on/off for multiple ports (partial update) + TURN_OFF_ALL_PORTS = 39 # Turn off all illumination ports SET_PIN_LEVEL = 41 INITFILTERWHEEL_W2 = 252 INITFILTERWHEEL = 253 @@ -288,6 +295,67 @@ class ILLUMINATION_CODE: ILLUMINATION_SOURCE_730NM = ILLUMINATION_D5 +class ILLUMINATION_PORT: + """Port indices for multi-port illumination control (firmware v1.0+). + + Port indices (0-15) for up to 16 illumination ports. + These are used with the new multi-port illumination commands + (SET_PORT_INTENSITY, TURN_ON_PORT, etc.). + + Note: These are port indices, NOT the legacy source codes (11-15). + For legacy source codes, use ILLUMINATION_CODE.ILLUMINATION_D1, etc. + """ + + D1 = 0 + D2 = 1 + D3 = 2 + D4 = 3 + D5 = 4 + # D6-D16 can be added as hardware expands + + +def source_code_to_port_index(source_code: int) -> int: + """Map legacy illumination source code to port index. + + Legacy source codes are non-sequential for historical API compatibility: + D1 = 11, D2 = 12, D3 = 14, D4 = 13, D5 = 15 + Note: D3 and D4 are swapped! + + Args: + source_code: Legacy source code (11-15 for D1-D5) + + Returns: + Port index (0-4), or -1 for unknown source codes + """ + mapping = { + ILLUMINATION_CODE.ILLUMINATION_D1: 0, # 11 -> 0 + ILLUMINATION_CODE.ILLUMINATION_D2: 1, # 12 -> 1 + ILLUMINATION_CODE.ILLUMINATION_D3: 2, # 14 -> 2 (non-sequential!) + ILLUMINATION_CODE.ILLUMINATION_D4: 3, # 13 -> 3 (non-sequential!) + ILLUMINATION_CODE.ILLUMINATION_D5: 4, # 15 -> 4 + } + return mapping.get(source_code, -1) + + +def port_index_to_source_code(port_index: int) -> int: + """Map port index to legacy illumination source code. + + Args: + port_index: Port index (0-4 for D1-D5) + + Returns: + Legacy source code (11-15), or -1 for invalid port index + """ + mapping = { + 0: ILLUMINATION_CODE.ILLUMINATION_D1, # 0 -> 11 + 1: ILLUMINATION_CODE.ILLUMINATION_D2, # 1 -> 12 + 2: ILLUMINATION_CODE.ILLUMINATION_D3, # 2 -> 14 + 3: ILLUMINATION_CODE.ILLUMINATION_D4, # 3 -> 13 + 4: ILLUMINATION_CODE.ILLUMINATION_D5, # 4 -> 15 + } + return mapping.get(port_index, -1) + + class VOLUMETRIC_IMAGING: NUM_PLANES_PER_VOLUME = 20 diff --git a/software/control/lighting.py b/software/control/lighting.py index b365b0e9d..d7a2f633c 100644 --- a/software/control/lighting.py +++ b/software/control/lighting.py @@ -2,12 +2,18 @@ import numpy as np import pandas as pd from pathlib import Path -from typing import Dict +from typing import Dict, List from control.microcontroller import Microcontroller from control.core.config import ConfigRepository from control._def import ILLUMINATION_CODE +# Import and re-export mapping functions (canonical location is _def.py) +from control._def import source_code_to_port_index, port_index_to_source_code + +# Number of illumination ports supported (matches firmware) +NUM_ILLUMINATION_PORTS = 16 + class LightSourceType(Enum): SquidLED = 0 @@ -76,6 +82,10 @@ def __init__( self.intensity_luts = {} # Store LUTs for each wavelength self.max_power = {} # Store max power for each wavelength + # Multi-port illumination state tracking (16 ports max) + self.port_is_on = {i: False for i in range(NUM_ILLUMINATION_PORTS)} + self.port_intensity = {i: 0.0 for i in range(NUM_ILLUMINATION_PORTS)} + if self.light_source_type is not None: self._configure_light_source() @@ -213,6 +223,98 @@ def set_intensity(self, channel, intensity): def get_shutter_state(self): return self.is_on + # Multi-port illumination methods (firmware v1.0+) + # These allow multiple ports to be ON simultaneously with independent intensities + + def set_port_intensity(self, port_index: int, intensity: float): + """Set intensity for a specific port without changing on/off state. + + Args: + port_index: Port index (0=D1, 1=D2, etc.) + intensity: Intensity percentage (0-100) + """ + if port_index < 0 or port_index >= NUM_ILLUMINATION_PORTS: + raise ValueError(f"Invalid port index: {port_index}") + self.microcontroller.set_port_intensity(port_index, intensity) + self.port_intensity[port_index] = intensity + + def turn_on_port(self, port_index: int): + """Turn on a specific illumination port. + + Args: + port_index: Port index (0=D1, 1=D2, etc.) + """ + if port_index < 0 or port_index >= NUM_ILLUMINATION_PORTS: + raise ValueError(f"Invalid port index: {port_index}") + self.microcontroller.turn_on_port(port_index) + self.microcontroller.wait_till_operation_is_completed() + self.port_is_on[port_index] = True + + def turn_off_port(self, port_index: int): + """Turn off a specific illumination port. + + Args: + port_index: Port index (0=D1, 1=D2, etc.) + """ + if port_index < 0 or port_index >= NUM_ILLUMINATION_PORTS: + raise ValueError(f"Invalid port index: {port_index}") + self.microcontroller.turn_off_port(port_index) + self.microcontroller.wait_till_operation_is_completed() + self.port_is_on[port_index] = False + + def set_port_illumination(self, port_index: int, intensity: float, turn_on: bool): + """Set intensity and on/off state for a specific port in one command. + + Args: + port_index: Port index (0=D1, 1=D2, etc.) + intensity: Intensity percentage (0-100) + turn_on: Whether to turn the port on + """ + if port_index < 0 or port_index >= NUM_ILLUMINATION_PORTS: + raise ValueError(f"Invalid port index: {port_index}") + self.microcontroller.set_port_illumination(port_index, intensity, turn_on) + self.microcontroller.wait_till_operation_is_completed() + self.port_intensity[port_index] = intensity + self.port_is_on[port_index] = turn_on + + def turn_on_multiple_ports(self, port_indices: List[int]): + """Turn on multiple ports simultaneously. + + Args: + port_indices: List of port indices to turn on (0=D1, 1=D2, etc.) + """ + if not port_indices: + return + + # Build port mask and on mask + port_mask = 0 + on_mask = 0 + for port_index in port_indices: + if port_index < 0 or port_index >= NUM_ILLUMINATION_PORTS: + raise ValueError(f"Invalid port index: {port_index}") + port_mask |= 1 << port_index + on_mask |= 1 << port_index + + self.microcontroller.set_multi_port_mask(port_mask, on_mask) + self.microcontroller.wait_till_operation_is_completed() + for port_index in port_indices: + self.port_is_on[port_index] = True + + def turn_off_all_ports(self): + """Turn off all illumination ports.""" + self.microcontroller.turn_off_all_ports() + self.microcontroller.wait_till_operation_is_completed() + for i in range(NUM_ILLUMINATION_PORTS): + self.port_is_on[i] = False + + def get_active_ports(self) -> List[int]: + """Get list of currently active (on) port indices. + + Returns: + List of port indices that are currently on. + """ + return [i for i in range(NUM_ILLUMINATION_PORTS) if self.port_is_on[i]] + def close(self): if self.light_source is not None: self.light_source.shut_down() diff --git a/software/control/microcontroller.py b/software/control/microcontroller.py index 7c3e7de1d..25816d06a 100644 --- a/software/control/microcontroller.py +++ b/software/control/microcontroller.py @@ -60,6 +60,13 @@ CMD_SET.SET_STROBE_DELAY: "SET_STROBE_DELAY", CMD_SET.SET_AXIS_DISABLE_ENABLE: "SET_AXIS_DISABLE_ENABLE", CMD_SET.SET_PIN_LEVEL: "SET_PIN_LEVEL", + # Multi-port illumination commands (firmware v1.0+) + CMD_SET.SET_PORT_INTENSITY: "SET_PORT_INTENSITY", + CMD_SET.TURN_ON_PORT: "TURN_ON_PORT", + CMD_SET.TURN_OFF_PORT: "TURN_OFF_PORT", + CMD_SET.SET_PORT_ILLUMINATION: "SET_PORT_ILLUMINATION", + CMD_SET.SET_MULTI_PORT_MASK: "SET_MULTI_PORT_MASK", + CMD_SET.TURN_OFF_ALL_PORTS: "TURN_OFF_ALL_PORTS", CMD_SET.INITFILTERWHEEL: "INITFILTERWHEEL", CMD_SET.INITFILTERWHEEL_W2: "INITFILTERWHEEL_W2", CMD_SET.INITIALIZE: "INITIALIZE", @@ -171,8 +178,16 @@ def reconnect(self, attempts: int) -> bool: class SimSerial(AbstractCephlaMicroSerial): + # Number of illumination ports for simulation + NUM_ILLUMINATION_PORTS = 16 + # Simulated firmware version (1.0 supports multi-port illumination) + FIRMWARE_VERSION_MAJOR = 1 + FIRMWARE_VERSION_MINOR = 0 + @staticmethod - def response_bytes_for(command_id, execution_status, x, y, z, theta, joystick_button, switch) -> bytes: + def response_bytes_for( + command_id, execution_status, x, y, z, theta, joystick_button, switch, firmware_version=(1, 0) + ) -> bytes: """ - command ID (1 byte) - execution status (1 byte) @@ -181,13 +196,16 @@ def response_bytes_for(command_id, execution_status, x, y, z, theta, joystick_bu - Z pos (4 bytes) - Theta (4 bytes) - buttons and switches (1 byte) - - reserved (4 bytes) + - reserved (4 bytes) - byte 22 contains firmware version - CRC (1 byte) """ crc_calculator = CrcCalculator(Crc8.CCITT, table_based=True) button_state = joystick_button << BIT_POS_JOYSTICK_BUTTON | switch << BIT_POS_SWITCH - reserved_state = 0 # This is just filler for the 4 reserved bytes. + # Firmware version is nibble-encoded in byte 22 (last byte of reserved section) + # High nibble = major version, low nibble = minor version + version_byte = (firmware_version[0] << 4) | (firmware_version[1] & 0x0F) + reserved_state = version_byte # Byte 22 = version_byte when packed as big-endian int response = bytearray( struct.pack(">BBiiiiBi", command_id, execution_status, x, y, z, theta, button_state, reserved_state) ) @@ -211,6 +229,15 @@ def __init__(self): self.joystick_button = False self.switch = False + # Multi-port illumination state for simulation + self.port_is_on = [False] * SimSerial.NUM_ILLUMINATION_PORTS + self.port_intensity = [0] * SimSerial.NUM_ILLUMINATION_PORTS + + # Legacy illumination state tracking (for backward compatibility) + # Maps legacy source codes (11-15) to port indices (0-4) + self._illumination_source = None # Currently selected legacy source + self._illumination_is_on = False # Legacy global on/off state + self._closed = False @staticmethod @@ -263,6 +290,57 @@ def _respond_to(self, write_bytes): self.w = 0 elif axis == AXIS.W2: self.w2 = 0 + # Multi-port illumination commands + elif command_byte == CMD_SET.SET_PORT_INTENSITY: + port_index = write_bytes[2] + if 0 <= port_index < SimSerial.NUM_ILLUMINATION_PORTS: + intensity = (write_bytes[3] << 8) | write_bytes[4] + self.port_intensity[port_index] = intensity + elif command_byte == CMD_SET.TURN_ON_PORT: + port_index = write_bytes[2] + if 0 <= port_index < SimSerial.NUM_ILLUMINATION_PORTS: + self.port_is_on[port_index] = True + elif command_byte == CMD_SET.TURN_OFF_PORT: + port_index = write_bytes[2] + if 0 <= port_index < SimSerial.NUM_ILLUMINATION_PORTS: + self.port_is_on[port_index] = False + elif command_byte == CMD_SET.SET_PORT_ILLUMINATION: + port_index = write_bytes[2] + if 0 <= port_index < SimSerial.NUM_ILLUMINATION_PORTS: + intensity = (write_bytes[3] << 8) | write_bytes[4] + turn_on = write_bytes[5] != 0 + self.port_intensity[port_index] = intensity + self.port_is_on[port_index] = turn_on + elif command_byte == CMD_SET.SET_MULTI_PORT_MASK: + port_mask = (write_bytes[2] << 8) | write_bytes[3] + on_mask = (write_bytes[4] << 8) | write_bytes[5] + for i in range(SimSerial.NUM_ILLUMINATION_PORTS): + if port_mask & (1 << i): + self.port_is_on[i] = bool(on_mask & (1 << i)) + elif command_byte == CMD_SET.TURN_OFF_ALL_PORTS: + for i in range(SimSerial.NUM_ILLUMINATION_PORTS): + self.port_is_on[i] = False + # Legacy illumination commands - sync with multi-port state + elif command_byte == CMD_SET.SET_ILLUMINATION: + source = write_bytes[2] + intensity = (write_bytes[3] << 8) | write_bytes[4] + self._illumination_source = source + # Update port intensity for the corresponding port + port_index = source_code_to_port_index(source) + if 0 <= port_index < SimSerial.NUM_ILLUMINATION_PORTS: + self.port_intensity[port_index] = intensity + elif command_byte == CMD_SET.TURN_ON_ILLUMINATION: + self._illumination_is_on = True + # Turn on the currently selected source's port + if self._illumination_source is not None: + port_index = source_code_to_port_index(self._illumination_source) + if 0 <= port_index < SimSerial.NUM_ILLUMINATION_PORTS: + self.port_is_on[port_index] = True + elif command_byte == CMD_SET.TURN_OFF_ILLUMINATION: + self._illumination_is_on = False + # Turn off all ports (matches firmware behavior) + for i in range(SimSerial.NUM_ILLUMINATION_PORTS): + self.port_is_on[i] = False self.response_buffer.extend( SimSerial.response_bytes_for( @@ -544,6 +622,10 @@ def __init__(self, serial_device: AbstractCephlaMicroSerial, reset_and_initializ self.joystick_event_listeners = [] self.switch_state = 0 + # Firmware version (major, minor) - detected from response byte 22 + # (0, 0) indicates legacy firmware without version reporting + self.firmware_version = (0, 0) + self.last_command = None self.last_command_send_timestamp = time.time() self.last_command_aborted_error = None @@ -666,6 +748,101 @@ def set_illumination_led_matrix(self, illumination_source, r, g, b): cmd[5] = min(int(b * 255), 255) self.send_command(cmd) + # Multi-port illumination commands (firmware v1.0+) + # These allow multiple ports to be ON simultaneously with independent intensities + + def set_port_intensity(self, port_index: int, intensity: float): + """Set DAC intensity for a specific port without changing on/off state. + + Args: + port_index: Port index (0=D1, 1=D2, etc.) + intensity: Intensity percentage (0-100), clamped to valid range + """ + self.log.debug(f"[MCU] set_port_intensity: port={port_index}, intensity={intensity}") + # Clamp intensity to valid range + intensity = max(0, min(100, intensity)) + cmd = bytearray(self.tx_buffer_length) + cmd[1] = CMD_SET.SET_PORT_INTENSITY + cmd[2] = port_index + intensity_value = int((intensity / 100) * 65535) + cmd[3] = intensity_value >> 8 + cmd[4] = intensity_value & 0xFF + self.send_command(cmd) + + def turn_on_port(self, port_index: int): + """Turn on a specific illumination port. + + Args: + port_index: Port index (0=D1, 1=D2, etc.) + """ + self.log.debug(f"[MCU] turn_on_port: port={port_index}") + cmd = bytearray(self.tx_buffer_length) + cmd[1] = CMD_SET.TURN_ON_PORT + cmd[2] = port_index + self.send_command(cmd) + + def turn_off_port(self, port_index: int): + """Turn off a specific illumination port. + + Args: + port_index: Port index (0=D1, 1=D2, etc.) + """ + self.log.debug(f"[MCU] turn_off_port: port={port_index}") + cmd = bytearray(self.tx_buffer_length) + cmd[1] = CMD_SET.TURN_OFF_PORT + cmd[2] = port_index + self.send_command(cmd) + + def set_port_illumination(self, port_index: int, intensity: float, turn_on: bool): + """Set intensity and on/off state for a specific port in one command. + + Args: + port_index: Port index (0=D1, 1=D2, etc.) + intensity: Intensity percentage (0-100), clamped to valid range + turn_on: Whether to turn the port on + """ + self.log.debug(f"[MCU] set_port_illumination: port={port_index}, intensity={intensity}, on={turn_on}") + # Clamp intensity to valid range + intensity = max(0, min(100, intensity)) + cmd = bytearray(self.tx_buffer_length) + cmd[1] = CMD_SET.SET_PORT_ILLUMINATION + cmd[2] = port_index + intensity_value = int((intensity / 100) * 65535) + cmd[3] = intensity_value >> 8 + cmd[4] = intensity_value & 0xFF + cmd[5] = 1 if turn_on else 0 + self.send_command(cmd) + + def set_multi_port_mask(self, port_mask: int, on_mask: int): + """Set on/off state for multiple ports using masks. + + This allows turning multiple ports on/off in a single command while + leaving other ports unchanged. + + Args: + port_mask: 16-bit mask of which ports to update (bit 0=D1, bit 15=D16) + on_mask: 16-bit mask of on/off state for selected ports (1=on, 0=off) + + Example: + # Turn on D1 and D2, turn off D3, leave others unchanged + set_multi_port_mask(0x0007, 0x0003) # port_mask=D1|D2|D3, on_mask=D1|D2 + """ + self.log.debug(f"[MCU] set_multi_port_mask: port_mask=0x{port_mask:04X}, on_mask=0x{on_mask:04X}") + cmd = bytearray(self.tx_buffer_length) + cmd[1] = CMD_SET.SET_MULTI_PORT_MASK + cmd[2] = (port_mask >> 8) & 0xFF + cmd[3] = port_mask & 0xFF + cmd[4] = (on_mask >> 8) & 0xFF + cmd[5] = on_mask & 0xFF + self.send_command(cmd) + + def turn_off_all_ports(self): + """Turn off all illumination ports.""" + self.log.debug("[MCU] turn_off_all_ports") + cmd = bytearray(self.tx_buffer_length) + cmd[1] = CMD_SET.TURN_OFF_ALL_PORTS + self.send_command(cmd) + def send_hardware_trigger(self, control_illumination=False, illumination_on_time_us=0, trigger_output_ch=0): illumination_on_time_us = int(illumination_on_time_us) cmd = bytearray(self.tx_buffer_length) @@ -1292,6 +1469,11 @@ def get_msg_with_good_checksum(): tmp = self.button_and_switch_state & (1 << BIT_POS_SWITCH) self.switch_state = tmp > 0 + # Firmware version from byte 22: high nibble = major, low nibble = minor + # Legacy firmware (pre-v1.0) sends 0x00, which gives version (0, 0) + version_byte = msg[22] + self.firmware_version = (version_byte >> 4, version_byte & 0x0F) + with self._received_packet_cv: self._received_packet_cv.notify_all() @@ -1303,6 +1485,17 @@ def get_msg_with_good_checksum(): def get_pos(self): return self.x_pos, self.y_pos, self.z_pos, self.theta_pos + def supports_multi_port(self) -> bool: + """Check if firmware supports multi-port illumination commands. + + Multi-port illumination was added in firmware version 1.0. + Legacy firmware (version 0.0) only supports single-source illumination. + + Returns: + True if firmware version >= 1.0, False otherwise. + """ + return self.firmware_version >= (1, 0) + def get_button_and_switch_state(self): return self.button_and_switch_state From fdbca363fac2cfdeb744106060291e878bdd528f Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 20:46:18 -0800 Subject: [PATCH 12/28] test(python): Add comprehensive multi-port illumination tests Add pytest tests for multi-port illumination (111 tests total): - test_multiport_illumination.py: Core functionality tests - test_multiport_illumination_bugs.py: Bug-hunting tests targeting D3/D4 mapping, validation, edge cases, and command sequencing - test_multiport_illumination_edge_cases.py: Boundary conditions, byte layout verification, legacy command interaction - test_multiport_illumination_protocol.py: Protocol agreement tests for firmware/Python consistency Tests cover the critical D3/D4 non-sequential source code mapping (D3=14, D4=13) which is a common source of bugs. Co-Authored-By: Claude Opus 4.5 --- software/tests/test_multiport_illumination.py | 292 +++++++++++ .../tests/test_multiport_illumination_bugs.py | 414 +++++++++++++++ .../test_multiport_illumination_edge_cases.py | 310 ++++++++++++ .../test_multiport_illumination_protocol.py | 472 ++++++++++++++++++ 4 files changed, 1488 insertions(+) create mode 100644 software/tests/test_multiport_illumination.py create mode 100644 software/tests/test_multiport_illumination_bugs.py create mode 100644 software/tests/test_multiport_illumination_edge_cases.py create mode 100644 software/tests/test_multiport_illumination_protocol.py diff --git a/software/tests/test_multiport_illumination.py b/software/tests/test_multiport_illumination.py new file mode 100644 index 000000000..ca9d69c51 --- /dev/null +++ b/software/tests/test_multiport_illumination.py @@ -0,0 +1,292 @@ +"""Tests for multi-port illumination control (firmware v1.0+). + +These tests verify the new multi-port illumination commands that allow +multiple illumination ports to be ON simultaneously with independent intensities. +""" + +import pytest +from control._def import CMD_SET, ILLUMINATION_PORT +from control.microcontroller import Microcontroller, SimSerial +from control.lighting import IlluminationController, NUM_ILLUMINATION_PORTS + + +class TestIlluminationPortConstants: + """Test ILLUMINATION_PORT constant definitions.""" + + def test_port_indices_are_sequential(self): + """Port indices should start at 0 and be sequential.""" + assert ILLUMINATION_PORT.D1 == 0 + assert ILLUMINATION_PORT.D2 == 1 + assert ILLUMINATION_PORT.D3 == 2 + assert ILLUMINATION_PORT.D4 == 3 + assert ILLUMINATION_PORT.D5 == 4 + + def test_cmd_set_constants(self): + """Command codes should match the protocol specification.""" + assert CMD_SET.SET_PORT_INTENSITY == 34 + assert CMD_SET.TURN_ON_PORT == 35 + assert CMD_SET.TURN_OFF_PORT == 36 + assert CMD_SET.SET_PORT_ILLUMINATION == 37 + assert CMD_SET.SET_MULTI_PORT_MASK == 38 + assert CMD_SET.TURN_OFF_ALL_PORTS == 39 + + +class TestSimSerialMultiPort: + """Test SimSerial multi-port illumination simulation.""" + + @pytest.fixture + def sim_serial(self): + """Create a fresh SimSerial instance for each test.""" + return SimSerial() + + def test_initial_state(self, sim_serial): + """All ports should be off with zero intensity initially.""" + assert len(sim_serial.port_is_on) == SimSerial.NUM_ILLUMINATION_PORTS + assert len(sim_serial.port_intensity) == SimSerial.NUM_ILLUMINATION_PORTS + assert all(not on for on in sim_serial.port_is_on) + assert all(intensity == 0 for intensity in sim_serial.port_intensity) + + def test_turn_on_port(self, sim_serial): + """TURN_ON_PORT command should turn on a specific port.""" + # Command: [cmd_id, 35, port, 0, 0, 0, 0, crc] + sim_serial.write(bytes([1, CMD_SET.TURN_ON_PORT, 0, 0, 0, 0, 0, 0])) + assert sim_serial.port_is_on[0] is True + assert sim_serial.port_is_on[1] is False # Other ports unchanged + + def test_turn_off_port(self, sim_serial): + """TURN_OFF_PORT command should turn off a specific port.""" + # First turn on + sim_serial.write(bytes([1, CMD_SET.TURN_ON_PORT, 0, 0, 0, 0, 0, 0])) + assert sim_serial.port_is_on[0] is True + + # Then turn off + sim_serial.write(bytes([2, CMD_SET.TURN_OFF_PORT, 0, 0, 0, 0, 0, 0])) + assert sim_serial.port_is_on[0] is False + + def test_set_port_intensity(self, sim_serial): + """SET_PORT_INTENSITY command should set DAC value for a port.""" + # Command: [cmd_id, 34, port, intensity_hi, intensity_lo, 0, 0, crc] + # Set port 0 to intensity 0x8000 (50%) + sim_serial.write(bytes([1, CMD_SET.SET_PORT_INTENSITY, 0, 0x80, 0x00, 0, 0, 0])) + assert sim_serial.port_intensity[0] == 0x8000 + assert sim_serial.port_intensity[1] == 0 # Other ports unchanged + + def test_set_port_illumination(self, sim_serial): + """SET_PORT_ILLUMINATION command should set intensity and on/off state.""" + # Command: [cmd_id, 37, port, intensity_hi, intensity_lo, on_flag, 0, crc] + # Set port 2 to intensity 0xFFFF and turn on + sim_serial.write(bytes([1, CMD_SET.SET_PORT_ILLUMINATION, 2, 0xFF, 0xFF, 1, 0, 0])) + assert sim_serial.port_intensity[2] == 0xFFFF + assert sim_serial.port_is_on[2] is True + + # Set port 2 intensity and turn off + sim_serial.write(bytes([2, CMD_SET.SET_PORT_ILLUMINATION, 2, 0x40, 0x00, 0, 0, 0])) + assert sim_serial.port_intensity[2] == 0x4000 + assert sim_serial.port_is_on[2] is False + + def test_set_multi_port_mask(self, sim_serial): + """SET_MULTI_PORT_MASK command should update multiple ports.""" + # Command: [cmd_id, 38, mask_hi, mask_lo, on_hi, on_lo, 0, crc] + # Turn on ports 0 and 1, leave others unchanged + # port_mask = 0x0003 (bits 0,1), on_mask = 0x0003 (both on) + sim_serial.write(bytes([1, CMD_SET.SET_MULTI_PORT_MASK, 0x00, 0x03, 0x00, 0x03, 0, 0])) + assert sim_serial.port_is_on[0] is True + assert sim_serial.port_is_on[1] is True + assert sim_serial.port_is_on[2] is False # Unchanged + + def test_set_multi_port_mask_partial_on_off(self, sim_serial): + """SET_MULTI_PORT_MASK can turn some ports on and others off.""" + # First turn on ports 0, 1, 2 + sim_serial.write(bytes([1, CMD_SET.SET_MULTI_PORT_MASK, 0x00, 0x07, 0x00, 0x07, 0, 0])) + assert sim_serial.port_is_on[0] is True + assert sim_serial.port_is_on[1] is True + assert sim_serial.port_is_on[2] is True + + # Now turn off port 1, turn on port 3, leave 0 and 2 unchanged + # port_mask = 0x000A (bits 1,3), on_mask = 0x0008 (only bit 3) + sim_serial.write(bytes([2, CMD_SET.SET_MULTI_PORT_MASK, 0x00, 0x0A, 0x00, 0x08, 0, 0])) + assert sim_serial.port_is_on[0] is True # Unchanged (not in mask) + assert sim_serial.port_is_on[1] is False # Turned off + assert sim_serial.port_is_on[2] is True # Unchanged (not in mask) + assert sim_serial.port_is_on[3] is True # Turned on + + def test_turn_off_all_ports(self, sim_serial): + """TURN_OFF_ALL_PORTS command should turn off all ports.""" + # First turn on some ports + sim_serial.write(bytes([1, CMD_SET.SET_MULTI_PORT_MASK, 0x00, 0x1F, 0x00, 0x1F, 0, 0])) + assert all(sim_serial.port_is_on[i] for i in range(5)) + + # Turn off all + sim_serial.write(bytes([2, CMD_SET.TURN_OFF_ALL_PORTS, 0, 0, 0, 0, 0, 0])) + assert all(not on for on in sim_serial.port_is_on) + + def test_invalid_port_index_ignored(self, sim_serial): + """Commands with invalid port indices should be ignored.""" + # Try to turn on port 20 (invalid, only 16 ports) + sim_serial.write(bytes([1, CMD_SET.TURN_ON_PORT, 20, 0, 0, 0, 0, 0])) + # Should not crash, all ports should remain off + assert all(not on for on in sim_serial.port_is_on) + + +class TestMicrocontrollerMultiPort: + """Test Microcontroller multi-port illumination methods.""" + + @pytest.fixture + def mcu(self): + """Create a Microcontroller with SimSerial for testing.""" + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_set_port_intensity(self, mcu): + """set_port_intensity should send correct command bytes.""" + mcu.set_port_intensity(0, 50) # 50% + mcu.wait_till_operation_is_completed() + # Verify via SimSerial state + assert mcu._serial.port_intensity[0] == int(0.5 * 65535) + + def test_turn_on_port(self, mcu): + """turn_on_port should send correct command bytes.""" + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[0] is True + + def test_turn_off_port(self, mcu): + """turn_off_port should send correct command bytes.""" + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + mcu.turn_off_port(0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[0] is False + + def test_set_port_illumination(self, mcu): + """set_port_illumination should set intensity and on/off state.""" + mcu.set_port_illumination(2, 75, turn_on=True) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_intensity[2] == int(0.75 * 65535) + assert mcu._serial.port_is_on[2] is True + + def test_set_multi_port_mask(self, mcu): + """set_multi_port_mask should update multiple ports.""" + # Turn on D1 and D2 + mcu.set_multi_port_mask(0x0003, 0x0003) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[0] is True + assert mcu._serial.port_is_on[1] is True + + def test_turn_off_all_ports(self, mcu): + """turn_off_all_ports should turn off all ports.""" + # First turn on some ports + mcu.set_multi_port_mask(0x001F, 0x001F) + mcu.wait_till_operation_is_completed() + + mcu.turn_off_all_ports() + mcu.wait_till_operation_is_completed() + assert all(not on for on in mcu._serial.port_is_on) + + def test_multiple_ports_on_simultaneously(self, mcu): + """Multiple ports can be on at the same time.""" + # Set intensities + mcu.set_port_intensity(0, 30) + mcu.wait_till_operation_is_completed() + mcu.set_port_intensity(1, 60) + mcu.wait_till_operation_is_completed() + mcu.set_port_intensity(2, 90) + mcu.wait_till_operation_is_completed() + + # Turn on all three + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(1) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(2) + mcu.wait_till_operation_is_completed() + + # Verify all are on with different intensities + assert mcu._serial.port_is_on[0] is True + assert mcu._serial.port_is_on[1] is True + assert mcu._serial.port_is_on[2] is True + assert mcu._serial.port_intensity[0] == int(0.30 * 65535) + assert mcu._serial.port_intensity[1] == int(0.60 * 65535) + assert mcu._serial.port_intensity[2] == int(0.90 * 65535) + + +class TestIlluminationControllerMultiPort: + """Test IlluminationController multi-port methods.""" + + @pytest.fixture + def controller(self): + """Create an IlluminationController with simulated microcontroller.""" + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + controller = IlluminationController(mcu) + yield controller + mcu.close() + + def test_initial_state(self, controller): + """All ports should be off initially.""" + assert all(not on for on in controller.port_is_on.values()) + assert all(intensity == 0 for intensity in controller.port_intensity.values()) + + def test_set_port_intensity(self, controller): + """set_port_intensity should update intensity tracking.""" + controller.set_port_intensity(0, 50) + assert controller.port_intensity[0] == 50 + + def test_turn_on_port(self, controller): + """turn_on_port should update state tracking.""" + controller.turn_on_port(0) + assert controller.port_is_on[0] is True + + def test_turn_off_port(self, controller): + """turn_off_port should update state tracking.""" + controller.turn_on_port(0) + controller.turn_off_port(0) + assert controller.port_is_on[0] is False + + def test_set_port_illumination(self, controller): + """set_port_illumination should update both intensity and state.""" + controller.set_port_illumination(2, 75, turn_on=True) + assert controller.port_intensity[2] == 75 + assert controller.port_is_on[2] is True + + def test_turn_on_multiple_ports(self, controller): + """turn_on_multiple_ports should turn on all specified ports.""" + controller.turn_on_multiple_ports([0, 1, 2]) + assert controller.port_is_on[0] is True + assert controller.port_is_on[1] is True + assert controller.port_is_on[2] is True + assert controller.port_is_on[3] is False # Not in list + + def test_turn_off_all_ports(self, controller): + """turn_off_all_ports should turn off all ports.""" + controller.turn_on_multiple_ports([0, 1, 2, 3, 4]) + controller.turn_off_all_ports() + assert all(not on for on in controller.port_is_on.values()) + + def test_get_active_ports(self, controller): + """get_active_ports should return list of active port indices.""" + controller.turn_on_multiple_ports([1, 3]) + active = controller.get_active_ports() + assert active == [1, 3] + + def test_get_active_ports_empty(self, controller): + """get_active_ports should return empty list when no ports active.""" + assert controller.get_active_ports() == [] + + def test_invalid_port_index_raises(self, controller): + """Methods should raise ValueError for invalid port indices.""" + with pytest.raises(ValueError, match="Invalid port index"): + controller.set_port_intensity(-1, 50) + + with pytest.raises(ValueError, match="Invalid port index"): + controller.turn_on_port(NUM_ILLUMINATION_PORTS) + + with pytest.raises(ValueError, match="Invalid port index"): + controller.turn_on_multiple_ports([0, 100]) + + def test_turn_on_multiple_ports_empty_list(self, controller): + """turn_on_multiple_ports with empty list should be a no-op.""" + controller.turn_on_multiple_ports([]) + assert all(not on for on in controller.port_is_on.values()) diff --git a/software/tests/test_multiport_illumination_bugs.py b/software/tests/test_multiport_illumination_bugs.py new file mode 100644 index 000000000..4c4b33645 --- /dev/null +++ b/software/tests/test_multiport_illumination_bugs.py @@ -0,0 +1,414 @@ +"""Tests specifically designed to find bugs in multi-port illumination. + +These tests target potential edge cases and implementation issues. +""" + +import pytest +from control._def import CMD_SET, ILLUMINATION_CODE +from control.microcontroller import Microcontroller, SimSerial +from control.lighting import IlluminationController, NUM_ILLUMINATION_PORTS + + +class TestD3D4NonSequentialMapping: + """Test the tricky D3/D4 non-sequential source code mapping. + + Source codes: D1=11, D2=12, D3=14, D4=13, D5=15 + Note: D3 and D4 are swapped! This is a common source of bugs. + """ + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_legacy_d3_maps_to_port_2(self, mcu): + """Legacy D3 (source 14) should map to port index 2.""" + mcu.set_illumination(14, 50) # D3 = 14 + mcu.wait_till_operation_is_completed() + # Port 2 should have the intensity, not port 3 + expected = int(0.5 * 65535) + assert mcu._serial.port_intensity[2] == expected + assert mcu._serial.port_intensity[3] == 0 # D4 should be unchanged + + def test_legacy_d4_maps_to_port_3(self, mcu): + """Legacy D4 (source 13) should map to port index 3.""" + mcu.set_illumination(13, 75) # D4 = 13 + mcu.wait_till_operation_is_completed() + # Port 3 should have the intensity, not port 2 + expected = int(0.75 * 65535) + assert mcu._serial.port_intensity[3] == expected + assert mcu._serial.port_intensity[2] == 0 # D3 should be unchanged + + def test_legacy_d3_d4_different_values(self, mcu): + """Setting D3 and D4 separately should maintain separate intensities.""" + mcu.set_illumination(14, 30) # D3 + mcu.wait_till_operation_is_completed() + mcu.set_illumination(13, 70) # D4 + mcu.wait_till_operation_is_completed() + + assert mcu._serial.port_intensity[2] == int(0.3 * 65535) # D3 at port 2 + assert mcu._serial.port_intensity[3] == int(0.7 * 65535) # D4 at port 3 + + def test_legacy_constants_match_expected(self, mcu): + """Verify ILLUMINATION_CODE constants match expected values.""" + assert ILLUMINATION_CODE.ILLUMINATION_D1 == 11 + assert ILLUMINATION_CODE.ILLUMINATION_D2 == 12 + assert ILLUMINATION_CODE.ILLUMINATION_D3 == 14 # Not 13! + assert ILLUMINATION_CODE.ILLUMINATION_D4 == 13 # Not 14! + assert ILLUMINATION_CODE.ILLUMINATION_D5 == 15 + + +class TestSetPortIlluminationCombined: + """Test the combined SET_PORT_ILLUMINATION command.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_set_intensity_and_turn_on(self, mcu): + """Should set intensity AND turn on in one command.""" + mcu.set_port_illumination(0, 50, turn_on=True) + mcu.wait_till_operation_is_completed() + + assert mcu._serial.port_is_on[0] is True + assert mcu._serial.port_intensity[0] == int(0.5 * 65535) + + def test_set_intensity_and_turn_off(self, mcu): + """Should set intensity AND turn off in one command.""" + # First turn on + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[0] is True + + # Set intensity and turn off + mcu.set_port_illumination(0, 75, turn_on=False) + mcu.wait_till_operation_is_completed() + + assert mcu._serial.port_is_on[0] is False + assert mcu._serial.port_intensity[0] == int(0.75 * 65535) + + def test_intensity_zero_with_turn_on(self, mcu): + """Setting intensity to 0 with turn_on=True should work.""" + mcu.set_port_illumination(0, 0, turn_on=True) + mcu.wait_till_operation_is_completed() + + assert mcu._serial.port_is_on[0] is True + assert mcu._serial.port_intensity[0] == 0 + + def test_on_flag_byte_value(self, mcu): + """Verify on_flag is sent as 1 for True, 0 for False.""" + sent_commands = [] + original_write = mcu._serial.write + + def capture_write(data, **kwargs): + sent_commands.append(bytes(data)) + return original_write(data, **kwargs) + + mcu._serial.write = capture_write + + # Turn on + mcu.set_port_illumination(0, 50, turn_on=True) + mcu.wait_till_operation_is_completed() + cmd_on = next(c for c in sent_commands if c[1] == CMD_SET.SET_PORT_ILLUMINATION) + assert cmd_on[5] == 1, "on_flag should be 1 for turn_on=True" + + sent_commands.clear() + + # Turn off + mcu.set_port_illumination(0, 50, turn_on=False) + mcu.wait_till_operation_is_completed() + cmd_off = next(c for c in sent_commands if c[1] == CMD_SET.SET_PORT_ILLUMINATION) + assert cmd_off[5] == 0, "on_flag should be 0 for turn_on=False" + + +class TestPortValidation: + """Test port index validation gaps.""" + + @pytest.fixture + def controller(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + controller = IlluminationController(mcu) + yield controller + mcu.close() + + def test_port_16_raises_error(self, controller): + """Port 16 should raise an error (only 0-15 valid).""" + with pytest.raises(ValueError): + controller.turn_on_port(16) + + def test_port_100_raises_error(self, controller): + """Port 100 should raise an error.""" + with pytest.raises(ValueError): + controller.set_port_intensity(100, 50) + + def test_port_float_raises_error(self, controller): + """Float port index should raise TypeError.""" + with pytest.raises((TypeError, ValueError)): + controller.turn_on_port(1.5) + + def test_port_string_raises_error(self, controller): + """String port index should raise TypeError.""" + with pytest.raises((TypeError, ValueError)): + controller.turn_on_port("D1") + + +class TestIntensityEdgeCases: + """Test more intensity edge cases.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_intensity_nan_handling(self, mcu): + """NaN intensity should be handled gracefully.""" + import math + + # This might raise or clamp - either is acceptable + try: + mcu.set_port_intensity(0, float("nan")) + mcu.wait_till_operation_is_completed() + # If it doesn't raise, check the result is sensible + assert mcu._serial.port_intensity[0] >= 0 + assert mcu._serial.port_intensity[0] <= 65535 + except (ValueError, TypeError): + pass # Raising is also acceptable + + def test_intensity_inf_handling(self, mcu): + """Infinity intensity should be clamped to 100%.""" + mcu.set_port_intensity(0, float("inf")) + mcu.wait_till_operation_is_completed() + # Should clamp to max + assert mcu._serial.port_intensity[0] == 65535 + + def test_intensity_negative_inf_handling(self, mcu): + """Negative infinity should be clamped to 0%.""" + mcu.set_port_intensity(0, float("-inf")) + mcu.wait_till_operation_is_completed() + # Should clamp to 0 + assert mcu._serial.port_intensity[0] == 0 + + +class TestMaskOverlapBehavior: + """Test behavior when masks have overlapping bits.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_on_mask_superset_of_port_mask(self, mcu): + """on_mask bits outside port_mask should be ignored.""" + # port_mask = 0x0001 (only port 0) + # on_mask = 0x0003 (ports 0 and 1) + # Only port 0 should be affected + mcu.set_multi_port_mask(0x0001, 0x0003) + mcu.wait_till_operation_is_completed() + + assert mcu._serial.port_is_on[0] is True + assert mcu._serial.port_is_on[1] is False # Not in port_mask + + def test_on_mask_subset_of_port_mask(self, mcu): + """on_mask can be subset of port_mask (some off, some on).""" + # port_mask = 0x0007 (ports 0, 1, 2) + # on_mask = 0x0005 (ports 0 and 2 on, port 1 off) + mcu.set_multi_port_mask(0x0007, 0x0005) + mcu.wait_till_operation_is_completed() + + assert mcu._serial.port_is_on[0] is True + assert mcu._serial.port_is_on[1] is False + assert mcu._serial.port_is_on[2] is True + + +class TestCommandSequencing: + """Test specific command sequences that might reveal bugs.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_rapid_on_off_sequence(self, mcu): + """Rapid on/off should end in correct state.""" + for _ in range(10): + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + mcu.turn_off_port(0) + mcu.wait_till_operation_is_completed() + + # Should end up off + assert mcu._serial.port_is_on[0] is False + + def test_multiple_ports_interleaved(self, mcu): + """Interleaved operations on different ports.""" + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + mcu.set_port_intensity(1, 50) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(1) + mcu.wait_till_operation_is_completed() + mcu.set_port_intensity(0, 75) + mcu.wait_till_operation_is_completed() + mcu.turn_off_port(0) + mcu.wait_till_operation_is_completed() + + assert mcu._serial.port_is_on[0] is False + assert mcu._serial.port_is_on[1] is True + assert mcu._serial.port_intensity[0] == int(0.75 * 65535) + assert mcu._serial.port_intensity[1] == int(0.5 * 65535) + + def test_turn_off_all_then_individual_on(self, mcu): + """Individual on should work after turn_off_all.""" + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(1) + mcu.wait_till_operation_is_completed() + + mcu.turn_off_all_ports() + mcu.wait_till_operation_is_completed() + + mcu.turn_on_port(2) + mcu.wait_till_operation_is_completed() + + assert mcu._serial.port_is_on[0] is False + assert mcu._serial.port_is_on[1] is False + assert mcu._serial.port_is_on[2] is True + + +class TestLegacyTurnOnSourceTracking: + """Test that legacy turn_on_illumination tracks the correct source.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_turn_on_uses_last_set_source(self, mcu): + """turn_on_illumination should turn on the last set source.""" + # Set D1 + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D1, 50) + mcu.wait_till_operation_is_completed() + # Set D3 (this becomes the "current" source) + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D3, 75) + mcu.wait_till_operation_is_completed() + + # Turn on - should turn on D3 (the last set source) + mcu.turn_on_illumination() + mcu.wait_till_operation_is_completed() + + # D3 (port 2) should be on + assert mcu._serial.port_is_on[2] is True + # D1 (port 0) should NOT be on (we only set intensity, didn't turn on) + assert mcu._serial.port_is_on[0] is False + + def test_turn_on_without_set_first(self, mcu): + """turn_on_illumination without set_illumination first.""" + # No source set yet - behavior is undefined but shouldn't crash + try: + mcu.turn_on_illumination() + mcu.wait_till_operation_is_completed() + except Exception: + pass # Either working or raising is acceptable + + +class TestIlluminationControllerMcuSync: + """Test that IlluminationController stays in sync with MCU state.""" + + @pytest.fixture + def controller(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + controller = IlluminationController(mcu) + yield controller + mcu.close() + + def test_controller_tracks_intensity(self, controller): + """Controller should track intensity it sets.""" + controller.set_port_intensity(0, 50) + assert controller.port_intensity[0] == 50 + + controller.set_port_intensity(0, 75) + assert controller.port_intensity[0] == 75 + + def test_controller_tracks_on_off(self, controller): + """Controller should track on/off state it sets.""" + controller.turn_on_port(0) + assert controller.port_is_on[0] is True + + controller.turn_off_port(0) + assert controller.port_is_on[0] is False + + def test_turn_off_all_updates_controller_state(self, controller): + """turn_off_all_ports should update controller state.""" + controller.turn_on_port(0) + controller.turn_on_port(1) + controller.turn_on_port(2) + + controller.turn_off_all_ports() + + # All should be tracked as off + for i in range(NUM_ILLUMINATION_PORTS): + assert controller.port_is_on[i] is False + + +class TestResponseVersionParsing: + """Test firmware version parsing from responses.""" + + def test_version_0_0_indicates_legacy(self): + """Version (0, 0) indicates legacy firmware without version support.""" + sim = SimSerial() + mcu = Microcontroller(sim, reset_and_initialize=False) + + # Before any command, version might be (0, 0) + # After command, SimSerial returns 1.0 + + mcu.turn_off_all_ports() + mcu.wait_till_operation_is_completed() + + # Should now have version from SimSerial + assert mcu.firmware_version == (1, 0) + mcu.close() + + def test_supports_multi_port_false_for_v0(self): + """supports_multi_port should return False for legacy firmware.""" + sim = SimSerial() + mcu = Microcontroller(sim, reset_and_initialize=False) + + # Manually set to legacy version + mcu.firmware_version = (0, 0) + assert mcu.supports_multi_port() is False + + mcu.firmware_version = (0, 9) + assert mcu.supports_multi_port() is False + + mcu.close() + + def test_supports_multi_port_true_for_v1_plus(self): + """supports_multi_port should return True for v1.0+.""" + sim = SimSerial() + mcu = Microcontroller(sim, reset_and_initialize=False) + + mcu.firmware_version = (1, 0) + assert mcu.supports_multi_port() is True + + mcu.firmware_version = (1, 5) + assert mcu.supports_multi_port() is True + + mcu.firmware_version = (2, 0) + assert mcu.supports_multi_port() is True + + mcu.close() diff --git a/software/tests/test_multiport_illumination_edge_cases.py b/software/tests/test_multiport_illumination_edge_cases.py new file mode 100644 index 000000000..8ec47ae7e --- /dev/null +++ b/software/tests/test_multiport_illumination_edge_cases.py @@ -0,0 +1,310 @@ +"""Edge case and integration tests for multi-port illumination. + +These tests cover edge cases, boundary conditions, and integration scenarios +that may reveal bugs or missing functionality. +""" + +import pytest +from control._def import CMD_SET, ILLUMINATION_PORT, ILLUMINATION_CODE +from control.microcontroller import Microcontroller, SimSerial +from control.lighting import IlluminationController, NUM_ILLUMINATION_PORTS + + +class TestIntensityBoundaryConditions: + """Test intensity values at boundaries.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_intensity_zero(self, mcu): + """Setting intensity to 0 should result in DAC value of 0.""" + mcu.set_port_intensity(0, 0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_intensity[0] == 0 + + def test_intensity_100_percent(self, mcu): + """Setting intensity to 100% should result in DAC value of 65535.""" + mcu.set_port_intensity(0, 100) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_intensity[0] == 65535 + + def test_intensity_over_100_percent(self, mcu): + """Setting intensity over 100% should be clamped to 100%.""" + mcu.set_port_intensity(0, 150) + mcu.wait_till_operation_is_completed() + # Should clamp to 65535 (100%) + assert mcu._serial.port_intensity[0] == 65535 + + def test_intensity_negative(self, mcu): + """Setting negative intensity should be clamped to 0.""" + mcu.set_port_intensity(0, -10) + mcu.wait_till_operation_is_completed() + # Should clamp to 0 + assert mcu._serial.port_intensity[0] == 0 + + +class TestCommandByteLayout: + """Verify command byte layout matches protocol specification.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_set_port_intensity_byte_layout(self, mcu): + """SET_PORT_INTENSITY should have correct byte layout.""" + # Intercept the command before it's sent + sent_commands = [] + original_write = mcu._serial.write + + def capture_write(data, **kwargs): + sent_commands.append(bytes(data)) + return original_write(data, **kwargs) + + mcu._serial.write = capture_write + + mcu.set_port_intensity(3, 50) # Port 3, 50% + mcu.wait_till_operation_is_completed() + + # Find the SET_PORT_INTENSITY command + cmd = next(c for c in sent_commands if c[1] == CMD_SET.SET_PORT_INTENSITY) + + # Verify byte layout: [cmd_id, 34, port, intensity_hi, intensity_lo, 0, 0, crc] + assert cmd[1] == 34 # Command code + assert cmd[2] == 3 # Port index + intensity_value = (cmd[3] << 8) | cmd[4] + expected_intensity = int(0.5 * 65535) + assert intensity_value == expected_intensity, f"Expected {expected_intensity}, got {intensity_value}" + + def test_set_multi_port_mask_byte_layout(self, mcu): + """SET_MULTI_PORT_MASK should have correct byte layout for 16-bit masks.""" + sent_commands = [] + original_write = mcu._serial.write + + def capture_write(data, **kwargs): + sent_commands.append(bytes(data)) + return original_write(data, **kwargs) + + mcu._serial.write = capture_write + + # Use a mask that requires both bytes: 0x0102 + mcu.set_multi_port_mask(0x0102, 0x0100) + mcu.wait_till_operation_is_completed() + + cmd = next(c for c in sent_commands if c[1] == CMD_SET.SET_MULTI_PORT_MASK) + + # Verify byte layout: [cmd_id, 38, mask_hi, mask_lo, on_hi, on_lo, 0, crc] + assert cmd[1] == 38 + port_mask = (cmd[2] << 8) | cmd[3] + on_mask = (cmd[4] << 8) | cmd[5] + assert port_mask == 0x0102, f"Expected 0x0102, got 0x{port_mask:04X}" + assert on_mask == 0x0100, f"Expected 0x0100, got 0x{on_mask:04X}" + + +class TestLegacyCommandInteraction: + """Test interaction between legacy and new illumination commands.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_legacy_turn_off_affects_new_port_state(self, mcu): + """Legacy turn_off_illumination should update new port state tracking.""" + # Turn on port using new command + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[0] is True + + # Turn off using legacy command + mcu.turn_off_illumination() + mcu.wait_till_operation_is_completed() + + # Legacy command now updates per-port state (backward compatibility) + assert mcu._serial.port_is_on[0] is False + + def test_new_turn_off_all_affects_legacy_state(self, mcu): + """New turn_off_all_ports should affect legacy illumination_is_on state.""" + # We'd need to track illumination_is_on in SimSerial to test this + # This test documents that the interaction needs consideration + pass + + +class TestIlluminationControllerStateSync: + """Test that IlluminationController state stays in sync with hardware.""" + + @pytest.fixture + def controller(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + controller = IlluminationController(mcu) + yield controller + mcu.close() + + @pytest.mark.xfail(reason="Direct MCU calls bypass controller state tracking - by design") + def test_state_sync_after_direct_mcu_call(self, controller): + """Controller state should reflect direct MCU calls.""" + # Turn on port via direct MCU call (bypassing controller) + controller.microcontroller.turn_on_port(0) + controller.microcontroller.wait_till_operation_is_completed() + + # Controller state won't know about this - direct MCU calls bypass + # controller state tracking. This is expected behavior. + # Use controller methods to keep state in sync. + assert controller.port_is_on[0] is True + + def test_concurrent_port_operations(self, controller): + """Multiple rapid port operations should maintain consistent state.""" + # Rapidly toggle ports + for i in range(5): + controller.turn_on_port(i) + + # All should be on + assert all(controller.port_is_on[i] for i in range(5)) + + for i in range(5): + controller.turn_off_port(i) + + # All should be off + assert all(not controller.port_is_on[i] for i in range(5)) + + +class TestPortIndexMapping: + """Test mapping between port indices and source codes.""" + + def test_port_index_to_source_code_mapping(self): + """Verify port indices map to correct legacy source codes.""" + expected_mapping = { + 0: ILLUMINATION_CODE.ILLUMINATION_D1, # 11 + 1: ILLUMINATION_CODE.ILLUMINATION_D2, # 12 + 2: ILLUMINATION_CODE.ILLUMINATION_D3, # 14 (non-sequential!) + 3: ILLUMINATION_CODE.ILLUMINATION_D4, # 13 (non-sequential!) + 4: ILLUMINATION_CODE.ILLUMINATION_D5, # 15 + } + + from control.lighting import port_index_to_source_code + + for port_idx, expected_source in expected_mapping.items(): + assert port_index_to_source_code(port_idx) == expected_source + + def test_source_code_to_port_index_mapping(self): + """Verify legacy source codes map to correct port indices.""" + expected_mapping = { + ILLUMINATION_CODE.ILLUMINATION_D1: 0, + ILLUMINATION_CODE.ILLUMINATION_D2: 1, + ILLUMINATION_CODE.ILLUMINATION_D3: 2, + ILLUMINATION_CODE.ILLUMINATION_D4: 3, + ILLUMINATION_CODE.ILLUMINATION_D5: 4, + } + + from control.lighting import source_code_to_port_index + + for source_code, expected_port in expected_mapping.items(): + assert source_code_to_port_index(source_code) == expected_port + + def test_invalid_port_index_returns_negative_one(self): + """Invalid port indices should return -1.""" + from control.lighting import port_index_to_source_code + + assert port_index_to_source_code(-1) == -1 + assert port_index_to_source_code(5) == -1 + assert port_index_to_source_code(100) == -1 + + def test_invalid_source_code_returns_negative_one(self): + """Invalid source codes should return -1.""" + from control.lighting import source_code_to_port_index + + assert source_code_to_port_index(0) == -1 + assert source_code_to_port_index(10) == -1 + assert source_code_to_port_index(16) == -1 + + def test_round_trip_port_to_source_to_port(self): + """Round trip: port -> source -> port should give same value.""" + from control.lighting import port_index_to_source_code, source_code_to_port_index + + for port in range(5): + source = port_index_to_source_code(port) + port_back = source_code_to_port_index(source) + assert port_back == port, f"Round trip failed for port {port}" + + +class TestFirmwareVersionCheck: + """Test firmware version detection and compatibility checks.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_firmware_version_detected(self, mcu): + """Microcontroller should detect firmware version from response.""" + # Version is read from response byte 22 (nibble-encoded) + # SimSerial reports version 1.0 by default + assert hasattr(mcu, "firmware_version") + assert mcu.firmware_version is not None + # Need to trigger a response to get version - send any command + mcu.turn_off_all_ports() + mcu.wait_till_operation_is_completed() + assert mcu.firmware_version == (1, 0) + + def test_supports_multi_port_check(self, mcu): + """Should have method to check if firmware supports multi-port commands.""" + assert hasattr(mcu, "supports_multi_port") + assert callable(mcu.supports_multi_port) + # Trigger response to get version + mcu.turn_off_all_ports() + mcu.wait_till_operation_is_completed() + # SimSerial simulates v1.0 firmware which supports multi-port + assert mcu.supports_multi_port() is True + + def test_multi_port_command_fails_on_old_firmware(self): + """Multi-port commands should fail gracefully on old firmware.""" + # This would require simulating old firmware behavior + # For now, document that this isn't implemented + pass + + +class TestIlluminationControllerValidation: + """Test input validation in IlluminationController.""" + + @pytest.fixture + def controller(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + controller = IlluminationController(mcu) + yield controller + mcu.close() + + def test_intensity_clamping_in_set_port_intensity(self, controller): + """set_port_intensity should clamp intensity to valid range.""" + # Should clamp 150 to 100 + controller.set_port_intensity(0, 150) + assert controller.port_intensity[0] == 150 # Controller stores requested value + # MCU receives clamped value (verified via SimSerial) + assert controller.microcontroller._serial.port_intensity[0] == 65535 + + def test_intensity_clamping_in_set_port_illumination(self, controller): + """set_port_illumination should clamp intensity to valid range.""" + # Should clamp -10 to 0 + controller.set_port_illumination(0, -10, turn_on=True) + assert controller.port_intensity[0] == -10 # Controller stores requested value + # MCU receives clamped value + assert controller.microcontroller._serial.port_intensity[0] == 0 + + def test_port_type_validation(self, controller): + """Methods should reject non-integer port indices.""" + with pytest.raises((ValueError, TypeError)): + controller.turn_on_port("D1") # String instead of int + + with pytest.raises((ValueError, TypeError)): + controller.turn_on_port(1.5) # Float instead of int diff --git a/software/tests/test_multiport_illumination_protocol.py b/software/tests/test_multiport_illumination_protocol.py new file mode 100644 index 000000000..af255b8b5 --- /dev/null +++ b/software/tests/test_multiport_illumination_protocol.py @@ -0,0 +1,472 @@ +"""Protocol agreement tests for multi-port illumination. + +These tests verify that Python sends exactly the bytes the firmware expects, +and that edge cases are handled consistently on both sides. +""" + +import pytest +from control._def import CMD_SET, ILLUMINATION_PORT, ILLUMINATION_CODE +from control.microcontroller import Microcontroller, SimSerial +from control.lighting import IlluminationController, NUM_ILLUMINATION_PORTS + + +class TestProtocolByteAgreement: + """Verify Python sends bytes matching firmware protocol spec.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def capture_command(self, mcu): + """Helper to capture the command bytes sent.""" + sent_commands = [] + original_write = mcu._serial.write + + def capture_write(data, **kwargs): + sent_commands.append(bytes(data)) + return original_write(data, **kwargs) + + mcu._serial.write = capture_write + return sent_commands + + def test_turn_on_port_sends_correct_bytes(self, mcu): + """TURN_ON_PORT should send [cmd_id, 35, port, 0, 0, 0, 0, crc].""" + sent = self.capture_command(mcu) + mcu.turn_on_port(3) + mcu.wait_till_operation_is_completed() + + cmd = next(c for c in sent if c[1] == CMD_SET.TURN_ON_PORT) + assert cmd[1] == 35, "Command code should be 35" + assert cmd[2] == 3, "Port index should be 3" + # Bytes 3-6 should be 0 (no additional params) + assert cmd[3] == 0 + assert cmd[4] == 0 + assert cmd[5] == 0 + assert cmd[6] == 0 + + def test_turn_off_port_sends_correct_bytes(self, mcu): + """TURN_OFF_PORT should send [cmd_id, 36, port, 0, 0, 0, 0, crc].""" + sent = self.capture_command(mcu) + mcu.turn_off_port(4) + mcu.wait_till_operation_is_completed() + + cmd = next(c for c in sent if c[1] == CMD_SET.TURN_OFF_PORT) + assert cmd[1] == 36 + assert cmd[2] == 4 + + def test_set_port_intensity_sends_big_endian(self, mcu): + """Intensity should be sent as big-endian 16-bit value.""" + sent = self.capture_command(mcu) + # 75% = 0.75 * 65535 = 49151 = 0xBFFF + mcu.set_port_intensity(0, 75) + mcu.wait_till_operation_is_completed() + + cmd = next(c for c in sent if c[1] == CMD_SET.SET_PORT_INTENSITY) + intensity = (cmd[3] << 8) | cmd[4] + expected = int(0.75 * 65535) + assert intensity == expected, f"Expected {expected} (0x{expected:04X}), got {intensity} (0x{intensity:04X})" + + def test_set_multi_port_mask_sends_big_endian_masks(self, mcu): + """Both masks should be sent as big-endian 16-bit values.""" + sent = self.capture_command(mcu) + # port_mask = 0x8001 (ports 0 and 15) + # on_mask = 0x0001 (only port 0 on) + mcu.set_multi_port_mask(0x8001, 0x0001) + mcu.wait_till_operation_is_completed() + + cmd = next(c for c in sent if c[1] == CMD_SET.SET_MULTI_PORT_MASK) + port_mask = (cmd[2] << 8) | cmd[3] + on_mask = (cmd[4] << 8) | cmd[5] + assert port_mask == 0x8001, f"Expected 0x8001, got 0x{port_mask:04X}" + assert on_mask == 0x0001, f"Expected 0x0001, got 0x{on_mask:04X}" + + +class TestPortIndexBoundaries: + """Test port index boundary conditions.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_port_index_0_works(self, mcu): + """Port index 0 (D1) should work.""" + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[0] is True + + def test_port_index_4_works(self, mcu): + """Port index 4 (D5) should work.""" + mcu.turn_on_port(4) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[4] is True + + def test_port_index_15_accepted(self, mcu): + """Port index 15 should be accepted (for future expansion).""" + # Should not raise, even though no physical port exists + mcu.turn_on_port(15) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[15] is True + + def test_port_index_16_accepted_by_mcu(self, mcu): + """Port index 16 is accepted by MCU (no validation at MCU level). + + Note: IlluminationController validates port indices, but Microcontroller + methods do not. Use IlluminationController for validated access. + """ + # MCU methods don't validate - they just send the command + # The command will be sent, but won't affect SimSerial (out of range) + mcu.turn_on_port(16) + mcu.wait_till_operation_is_completed() + # No error raised - MCU doesn't validate + + def test_negative_port_index_fails_byte_conversion(self, mcu): + """Negative port index fails during byte conversion.""" + # Negative value can't be packed into unsigned byte + with pytest.raises(ValueError): + mcu.turn_on_port(-1) + + +class TestIntensityEdgeCases: + """Test intensity value edge cases.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_intensity_exactly_0(self, mcu): + """Intensity 0.0 should produce DAC value 0.""" + mcu.set_port_intensity(0, 0.0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_intensity[0] == 0 + + def test_intensity_exactly_100(self, mcu): + """Intensity 100.0 should produce DAC value 65535.""" + mcu.set_port_intensity(0, 100.0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_intensity[0] == 65535 + + def test_intensity_very_small(self, mcu): + """Very small intensity should not be 0.""" + mcu.set_port_intensity(0, 0.01) + mcu.wait_till_operation_is_completed() + # 0.01% = 6.5535, should round to at least 6 + assert mcu._serial.port_intensity[0] >= 6 + + def test_intensity_99_999(self, mcu): + """Intensity 99.999 should be very close to max but not equal.""" + mcu.set_port_intensity(0, 99.999) + mcu.wait_till_operation_is_completed() + # Should be close to 65535 but not necessarily equal + assert mcu._serial.port_intensity[0] >= 65528 + + def test_intensity_float_precision(self, mcu): + """Float precision should not cause unexpected results.""" + # 33.333...% should give consistent results + mcu.set_port_intensity(0, 100.0 / 3.0) + mcu.wait_till_operation_is_completed() + expected = int((100.0 / 3.0 / 100.0) * 65535) + assert abs(mcu._serial.port_intensity[0] - expected) <= 1 + + +class TestStateConsistency: + """Test that state remains consistent across operations.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_turn_on_twice_remains_on(self, mcu): + """Turning on a port twice should leave it on.""" + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[0] is True + + def test_turn_off_twice_remains_off(self, mcu): + """Turning off a port twice should leave it off.""" + mcu.turn_off_port(0) + mcu.wait_till_operation_is_completed() + mcu.turn_off_port(0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[0] is False + + def test_intensity_persists_after_off_on(self, mcu): + """Intensity should persist through off/on cycle.""" + mcu.set_port_intensity(0, 75) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + mcu.turn_off_port(0) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + # Intensity should still be 75% + expected = int(0.75 * 65535) + assert mcu._serial.port_intensity[0] == expected + + def test_set_intensity_while_off_then_turn_on(self, mcu): + """Setting intensity while off, then turning on should work.""" + mcu.turn_off_port(0) + mcu.wait_till_operation_is_completed() + mcu.set_port_intensity(0, 50) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + assert mcu._serial.port_is_on[0] is True + assert mcu._serial.port_intensity[0] == int(0.5 * 65535) + + def test_turn_off_all_clears_all_ports(self, mcu): + """turn_off_all_ports should turn off all ports.""" + # Turn on several ports + for i in range(5): + mcu.turn_on_port(i) + mcu.wait_till_operation_is_completed() + + # Verify they're on + for i in range(5): + assert mcu._serial.port_is_on[i] is True + + # Turn off all + mcu.turn_off_all_ports() + mcu.wait_till_operation_is_completed() + + # Verify all are off + for i in range(16): + assert mcu._serial.port_is_on[i] is False + + +class TestLegacyNewInteraction: + """Test interaction between legacy and new illumination commands.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_legacy_set_illumination_updates_port_intensity(self, mcu): + """Legacy set_illumination should update port intensity.""" + # Use legacy command to set D1 (source 11) to 60% + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D1, 60) + mcu.wait_till_operation_is_completed() + # Port 0 intensity should be updated + expected = int(0.6 * 65535) + assert mcu._serial.port_intensity[0] == expected + + def test_legacy_turn_on_updates_port_state(self, mcu): + """Legacy turn_on_illumination should update port state.""" + # First set a source + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D2, 50) + mcu.wait_till_operation_is_completed() + # Then turn on + mcu.turn_on_illumination() + mcu.wait_till_operation_is_completed() + # Port 1 (D2) should be on + assert mcu._serial.port_is_on[1] is True + + def test_new_command_after_legacy(self, mcu): + """New commands should work after legacy commands.""" + # Legacy: set D1 + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D1, 50) + mcu.wait_till_operation_is_completed() + mcu.turn_on_illumination() + mcu.wait_till_operation_is_completed() + + # New: turn on D2 as well + mcu.set_port_intensity(1, 75) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(1) + mcu.wait_till_operation_is_completed() + + # Both should be on + assert mcu._serial.port_is_on[0] is True # D1 from legacy + assert mcu._serial.port_is_on[1] is True # D2 from new + + def test_legacy_turn_off_after_new_commands(self, mcu): + """Legacy turn_off should turn off all ports.""" + # Turn on multiple ports with new commands + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(1) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(2) + mcu.wait_till_operation_is_completed() + + # Use legacy turn off + mcu.turn_off_illumination() + mcu.wait_till_operation_is_completed() + + # All should be off + for i in range(5): + assert mcu._serial.port_is_on[i] is False + + +class TestFirmwareVersionBehavior: + """Test firmware version detection and behavior.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_version_detected_after_any_command(self, mcu): + """Firmware version should be detected after any command.""" + # Initially might be (0, 0) until we get a response + initial = mcu.firmware_version + + # Send a command to trigger response + mcu.turn_off_all_ports() + mcu.wait_till_operation_is_completed() + + # Now version should be detected (SimSerial reports 1.0) + assert mcu.firmware_version == (1, 0) + + def test_supports_multi_port_true_for_v1(self, mcu): + """supports_multi_port() should return True for v1.0+.""" + mcu.turn_off_all_ports() + mcu.wait_till_operation_is_completed() + assert mcu.supports_multi_port() is True + + def test_version_persists_across_commands(self, mcu): + """Version should remain consistent across commands.""" + mcu.turn_off_all_ports() + mcu.wait_till_operation_is_completed() + v1 = mcu.firmware_version + + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + v2 = mcu.firmware_version + + mcu.set_port_intensity(0, 50) + mcu.wait_till_operation_is_completed() + v3 = mcu.firmware_version + + assert v1 == v2 == v3 == (1, 0) + + +class TestMultiPortMaskEdgeCases: + """Test SET_MULTI_PORT_MASK edge cases.""" + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_empty_mask_no_change(self, mcu): + """Empty port_mask should not change any ports.""" + mcu.turn_on_port(0) + mcu.wait_till_operation_is_completed() + + # Empty mask - no ports selected + mcu.set_multi_port_mask(0x0000, 0x0000) + mcu.wait_till_operation_is_completed() + + # Port 0 should still be on + assert mcu._serial.port_is_on[0] is True + + def test_partial_mask_only_affects_selected(self, mcu): + """Only ports in port_mask should be affected.""" + # Turn on ports 0, 1, 2 + for i in range(3): + mcu.turn_on_port(i) + mcu.wait_till_operation_is_completed() + + # Select only port 1, turn it off + mcu.set_multi_port_mask(0x0002, 0x0000) # port_mask=D2, on_mask=off + mcu.wait_till_operation_is_completed() + + # Port 0 and 2 should still be on, port 1 should be off + assert mcu._serial.port_is_on[0] is True + assert mcu._serial.port_is_on[1] is False + assert mcu._serial.port_is_on[2] is True + + def test_all_16_ports_mask(self, mcu): + """Mask 0xFFFF should address all 16 ports.""" + # Turn all on + mcu.set_multi_port_mask(0xFFFF, 0xFFFF) + mcu.wait_till_operation_is_completed() + + for i in range(16): + assert mcu._serial.port_is_on[i] is True + + # Turn all off + mcu.set_multi_port_mask(0xFFFF, 0x0000) + mcu.wait_till_operation_is_completed() + + for i in range(16): + assert mcu._serial.port_is_on[i] is False + + def test_alternating_on_off(self, mcu): + """Test turning alternating ports on/off.""" + # Select all, turn on even ports only + mcu.set_multi_port_mask(0xFFFF, 0x5555) # 0101 0101 0101 0101 + mcu.wait_till_operation_is_completed() + + for i in range(16): + if i % 2 == 0: + assert mcu._serial.port_is_on[i] is True, f"Port {i} should be ON" + else: + assert mcu._serial.port_is_on[i] is False, f"Port {i} should be OFF" + + +class TestIlluminationControllerEdgeCases: + """Test IlluminationController edge cases.""" + + @pytest.fixture + def controller(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + controller = IlluminationController(mcu) + yield controller + mcu.close() + + def test_get_active_ports_after_operations(self, controller): + """get_active_ports should return correct list after operations.""" + controller.turn_on_port(0) + controller.turn_on_port(2) + controller.turn_on_port(4) + + active = controller.get_active_ports() + assert sorted(active) == [0, 2, 4] + + def test_turn_on_multiple_empty_list(self, controller): + """turn_on_multiple_ports with empty list should be no-op.""" + controller.turn_on_port(0) # Turn one on first + controller.turn_on_multiple_ports([]) + + # Original state should be preserved + assert controller.port_is_on[0] is True + assert sum(controller.port_is_on.values()) == 1 + + def test_intensity_state_tracking(self, controller): + """Controller should track intensity state.""" + controller.set_port_intensity(0, 25) + controller.set_port_intensity(1, 50) + controller.set_port_intensity(2, 75) + + assert controller.port_intensity[0] == 25 + assert controller.port_intensity[1] == 50 + assert controller.port_intensity[2] == 75 + + def test_invalid_port_in_turn_on_multiple(self, controller): + """Invalid port in list should raise ValueError.""" + with pytest.raises(ValueError): + controller.turn_on_multiple_ports([0, 1, 100]) # 100 is invalid From 554a7727aa64d630c5980c211af7977c7b10da7b Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 20:46:24 -0800 Subject: [PATCH 13/28] refactor: Use centralized source code mapping in core.py Replace hardcoded illumination source code values (11, 12, 13) with source_code_to_port_index() function for display_image() widget routing. This ensures consistent handling of the D3/D4 non-sequential mapping (D3=14, D4=13) across the codebase. Co-Authored-By: Claude Opus 4.5 --- software/control/core/core.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/software/control/core/core.py b/software/control/core/core.py index c31b4f6ff..c3462e14b 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -1747,13 +1747,18 @@ def __init__(self, window_title=""): self.setFixedSize(int(width), int(height)) def display_image(self, image, illumination_source): - if illumination_source < 11: + # Map illumination source code to port index for widget selection + # Port indices: D1=0, D2=1, D3=2, D4=3, D5=4 + # Source codes: D1=11, D2=12, D3=14, D4=13, D5=15 (non-sequential D3/D4!) + port_index = source_code_to_port_index(illumination_source) + if port_index < 0: + # Not a D1-D5 source, use default widget self.graphics_widget_1.img.setImage(image, autoLevels=False) - elif illumination_source == 11: + elif port_index == 0: # D1 self.graphics_widget_2.img.setImage(image, autoLevels=False) - elif illumination_source == 12: + elif port_index == 1: # D2 self.graphics_widget_3.img.setImage(image, autoLevels=False) - elif illumination_source == 13: + elif port_index == 3: # D4 (matches original code behavior) self.graphics_widget_4.img.setImage(image, autoLevels=False) From 7f1aa68488012dfcaa95cc4824a093a84a0f4449 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 21:06:51 -0800 Subject: [PATCH 14/28] fix: Detect firmware version early during Microcontroller init Previously, firmware_version was (0, 0) until the first command was sent and a response received. This caused supports_multi_port() to return False even on v1.0+ firmware if called before any command. Now _detect_firmware_version() sends TURN_OFF_ALL_PORTS (a safe no-op) during __init__ to populate the version immediately. This ensures supports_multi_port() returns accurate results right after initialization. Co-Authored-By: Claude Opus 4.5 --- software/control/microcontroller.py | 16 ++++++++++++++++ .../test_multiport_illumination_edge_cases.py | 19 ++++++++----------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/software/control/microcontroller.py b/software/control/microcontroller.py index 25816d06a..910cc12ef 100644 --- a/software/control/microcontroller.py +++ b/software/control/microcontroller.py @@ -649,6 +649,10 @@ def __init__(self, serial_device: AbstractCephlaMicroSerial, reset_and_initializ self.set_dac80508_scaling_factor_for_illumination(ILLUMINATION_INTENSITY_FACTOR) time.sleep(0.5) + # Detect firmware version early by sending a harmless command + # This ensures supports_multi_port() returns accurate results immediately + self._detect_firmware_version() + def _warn_if_reads_stale(self): if self._is_simulated: return @@ -1485,6 +1489,18 @@ def get_msg_with_good_checksum(): def get_pos(self): return self.x_pos, self.y_pos, self.z_pos, self.theta_pos + def _detect_firmware_version(self): + """Detect firmware version by sending a harmless command. + + Sends TURN_OFF_ALL_PORTS (a safe no-op if ports are already off) + to trigger a response from which we can read the firmware version. + This ensures supports_multi_port() returns accurate results immediately + after Microcontroller initialization. + """ + self.turn_off_all_ports() + self.wait_till_operation_is_completed() + self.log.debug(f"Detected firmware version: {self.firmware_version}") + def supports_multi_port(self) -> bool: """Check if firmware supports multi-port illumination commands. diff --git a/software/tests/test_multiport_illumination_edge_cases.py b/software/tests/test_multiport_illumination_edge_cases.py index 8ec47ae7e..824ca0f87 100644 --- a/software/tests/test_multiport_illumination_edge_cases.py +++ b/software/tests/test_multiport_illumination_edge_cases.py @@ -246,25 +246,22 @@ def mcu(self): yield mcu mcu.close() - def test_firmware_version_detected(self, mcu): - """Microcontroller should detect firmware version from response.""" + def test_firmware_version_detected_at_init(self, mcu): + """Firmware version should be detected during Microcontroller init.""" # Version is read from response byte 22 (nibble-encoded) # SimSerial reports version 1.0 by default + # Early detection sends TURN_OFF_ALL_PORTS during __init__ assert hasattr(mcu, "firmware_version") assert mcu.firmware_version is not None - # Need to trigger a response to get version - send any command - mcu.turn_off_all_ports() - mcu.wait_till_operation_is_completed() + # Version should already be populated - no need to send a command first assert mcu.firmware_version == (1, 0) - def test_supports_multi_port_check(self, mcu): - """Should have method to check if firmware supports multi-port commands.""" + def test_supports_multi_port_accurate_at_init(self, mcu): + """supports_multi_port() should return accurate result immediately after init.""" assert hasattr(mcu, "supports_multi_port") assert callable(mcu.supports_multi_port) - # Trigger response to get version - mcu.turn_off_all_ports() - mcu.wait_till_operation_is_completed() - # SimSerial simulates v1.0 firmware which supports multi-port + # Should return True immediately - no need to send a command first + # (Early detection populates firmware_version during __init__) assert mcu.supports_multi_port() is True def test_multi_port_command_fails_on_old_firmware(self): From 117c04c5a12db89ffcb5970cb680fc3b8e546d83 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 21:08:21 -0800 Subject: [PATCH 15/28] fix: Add port validation and consistent wait behavior Address code review issues #1 and #2: 1. Add port index validation (0-15) to Microcontroller methods: - set_port_intensity, turn_on_port, turn_off_port, set_port_illumination - Raises ValueError for out-of-range ports, TypeError for non-integers 2. Add wait_till_operation_is_completed() to IlluminationController.set_port_intensity() - Makes behavior consistent with turn_on_port() and turn_off_port() - Prevents race conditions when setting intensity before turning on Co-Authored-By: Claude Opus 4.5 --- software/control/lighting.py | 1 + software/control/microcontroller.py | 29 +++++++++++++++++++ .../test_multiport_illumination_protocol.py | 15 ++++------ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/software/control/lighting.py b/software/control/lighting.py index d7a2f633c..52657dbf9 100644 --- a/software/control/lighting.py +++ b/software/control/lighting.py @@ -236,6 +236,7 @@ def set_port_intensity(self, port_index: int, intensity: float): if port_index < 0 or port_index >= NUM_ILLUMINATION_PORTS: raise ValueError(f"Invalid port index: {port_index}") self.microcontroller.set_port_intensity(port_index, intensity) + self.microcontroller.wait_till_operation_is_completed() self.port_intensity[port_index] = intensity def turn_on_port(self, port_index: int): diff --git a/software/control/microcontroller.py b/software/control/microcontroller.py index 910cc12ef..84bb207d8 100644 --- a/software/control/microcontroller.py +++ b/software/control/microcontroller.py @@ -754,6 +754,15 @@ def set_illumination_led_matrix(self, illumination_source, r, g, b): # Multi-port illumination commands (firmware v1.0+) # These allow multiple ports to be ON simultaneously with independent intensities + # Maximum number of ports supported by firmware + _MAX_ILLUMINATION_PORTS = 16 + + def _validate_port_index(self, port_index: int): + """Validate port index is in valid range (0-15).""" + if not isinstance(port_index, int): + raise TypeError(f"port_index must be an integer, got {type(port_index).__name__}") + if port_index < 0 or port_index >= self._MAX_ILLUMINATION_PORTS: + raise ValueError(f"Invalid port_index {port_index}, must be 0-{self._MAX_ILLUMINATION_PORTS - 1}") def set_port_intensity(self, port_index: int, intensity: float): """Set DAC intensity for a specific port without changing on/off state. @@ -761,7 +770,12 @@ def set_port_intensity(self, port_index: int, intensity: float): Args: port_index: Port index (0=D1, 1=D2, etc.) intensity: Intensity percentage (0-100), clamped to valid range + + Raises: + ValueError: If port_index is out of range (0-15) + TypeError: If port_index is not an integer """ + self._validate_port_index(port_index) self.log.debug(f"[MCU] set_port_intensity: port={port_index}, intensity={intensity}") # Clamp intensity to valid range intensity = max(0, min(100, intensity)) @@ -778,7 +792,12 @@ def turn_on_port(self, port_index: int): Args: port_index: Port index (0=D1, 1=D2, etc.) + + Raises: + ValueError: If port_index is out of range (0-15) + TypeError: If port_index is not an integer """ + self._validate_port_index(port_index) self.log.debug(f"[MCU] turn_on_port: port={port_index}") cmd = bytearray(self.tx_buffer_length) cmd[1] = CMD_SET.TURN_ON_PORT @@ -790,7 +809,12 @@ def turn_off_port(self, port_index: int): Args: port_index: Port index (0=D1, 1=D2, etc.) + + Raises: + ValueError: If port_index is out of range (0-15) + TypeError: If port_index is not an integer """ + self._validate_port_index(port_index) self.log.debug(f"[MCU] turn_off_port: port={port_index}") cmd = bytearray(self.tx_buffer_length) cmd[1] = CMD_SET.TURN_OFF_PORT @@ -804,7 +828,12 @@ def set_port_illumination(self, port_index: int, intensity: float, turn_on: bool port_index: Port index (0=D1, 1=D2, etc.) intensity: Intensity percentage (0-100), clamped to valid range turn_on: Whether to turn the port on + + Raises: + ValueError: If port_index is out of range (0-15) + TypeError: If port_index is not an integer """ + self._validate_port_index(port_index) self.log.debug(f"[MCU] set_port_illumination: port={port_index}, intensity={intensity}, on={turn_on}") # Clamp intensity to valid range intensity = max(0, min(100, intensity)) diff --git a/software/tests/test_multiport_illumination_protocol.py b/software/tests/test_multiport_illumination_protocol.py index af255b8b5..fe00904d0 100644 --- a/software/tests/test_multiport_illumination_protocol.py +++ b/software/tests/test_multiport_illumination_protocol.py @@ -113,17 +113,14 @@ def test_port_index_15_accepted(self, mcu): mcu.wait_till_operation_is_completed() assert mcu._serial.port_is_on[15] is True - def test_port_index_16_accepted_by_mcu(self, mcu): - """Port index 16 is accepted by MCU (no validation at MCU level). + def test_port_index_16_rejected_by_mcu(self, mcu): + """Port index 16 is rejected by Microcontroller validation. - Note: IlluminationController validates port indices, but Microcontroller - methods do not. Use IlluminationController for validated access. + Microcontroller validates port indices (0-15) before sending commands. """ - # MCU methods don't validate - they just send the command - # The command will be sent, but won't affect SimSerial (out of range) - mcu.turn_on_port(16) - mcu.wait_till_operation_is_completed() - # No error raised - MCU doesn't validate + with pytest.raises(ValueError) as exc_info: + mcu.turn_on_port(16) + assert "Invalid port_index 16" in str(exc_info.value) def test_negative_port_index_fails_byte_conversion(self, mcu): """Negative port index fails during byte conversion.""" From 4dd66e255399b421070efcb26c8e5dd9a5ac05a3 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 21:12:31 -0800 Subject: [PATCH 16/28] =?UTF-8?q?test:=20Add=20round-trip=20tests=20for=20?= =?UTF-8?q?D3/D4=20legacy=E2=86=94new=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TestD3D4RoundTrip class with 6 tests verifying that legacy commands (using source codes) and new commands (using port indices) control the same hardware: - test_legacy_d3_controls_port_2: D3 (source 14) → port 2 - test_legacy_d4_controls_port_3: D4 (source 13) → port 3 - test_new_intensity_legacy_turn_on_d3: Mix new intensity + legacy turn on - test_new_intensity_legacy_turn_on_d4: Mix new intensity + legacy turn on - test_d3_d4_independent_control: Both ports work independently - test_all_five_ports_round_trip: Verify all D1-D5 mappings These tests specifically target the non-sequential D3/D4 source codes (D3=14, D4=13) which are a common source of bugs. Co-Authored-By: Claude Opus 4.5 --- .../test_multiport_illumination_protocol.py | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/software/tests/test_multiport_illumination_protocol.py b/software/tests/test_multiport_illumination_protocol.py index fe00904d0..9c6e9584f 100644 --- a/software/tests/test_multiport_illumination_protocol.py +++ b/software/tests/test_multiport_illumination_protocol.py @@ -312,6 +312,158 @@ def test_legacy_turn_off_after_new_commands(self, mcu): assert mcu._serial.port_is_on[i] is False +class TestD3D4RoundTrip: + """Round-trip tests verifying legacy and new commands control the same hardware. + + This is critical for the D3/D4 non-sequential mapping: + - D3 has source code 14, maps to port index 2 + - D4 has source code 13, maps to port index 3 + """ + + @pytest.fixture + def mcu(self): + sim_serial = SimSerial() + mcu = Microcontroller(sim_serial, reset_and_initialize=False) + yield mcu + mcu.close() + + def test_legacy_d3_controls_port_2(self, mcu): + """Legacy D3 (source 14) and new port_index 2 control the same hardware.""" + # Set intensity via legacy D3 + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D3, 60) + mcu.wait_till_operation_is_completed() + + # Verify port 2 received the intensity (not port 3!) + expected = int(0.6 * 65535) + assert mcu._serial.port_intensity[2] == expected + assert mcu._serial.port_intensity[3] == 0 # D4 should be unchanged + + # Turn on via new API using port 2 + mcu.turn_on_port(2) + mcu.wait_till_operation_is_completed() + + # Port 2 should be on + assert mcu._serial.port_is_on[2] is True + assert mcu._serial.port_is_on[3] is False + + def test_legacy_d4_controls_port_3(self, mcu): + """Legacy D4 (source 13) and new port_index 3 control the same hardware.""" + # Set intensity via legacy D4 + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D4, 80) + mcu.wait_till_operation_is_completed() + + # Verify port 3 received the intensity (not port 2!) + expected = int(0.8 * 65535) + assert mcu._serial.port_intensity[3] == expected + assert mcu._serial.port_intensity[2] == 0 # D3 should be unchanged + + # Turn on via new API using port 3 + mcu.turn_on_port(3) + mcu.wait_till_operation_is_completed() + + # Port 3 should be on + assert mcu._serial.port_is_on[3] is True + assert mcu._serial.port_is_on[2] is False + + def test_new_intensity_legacy_turn_on_d3(self, mcu): + """Set intensity via new API, turn on via legacy - D3 case.""" + # Set intensity on port 2 via new API + mcu.set_port_intensity(2, 70) + mcu.wait_till_operation_is_completed() + + # Select D3 via legacy (source 14) + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D3, 70) + mcu.wait_till_operation_is_completed() + + # Turn on via legacy + mcu.turn_on_illumination() + mcu.wait_till_operation_is_completed() + + # Port 2 (D3) should be on + assert mcu._serial.port_is_on[2] is True + + def test_new_intensity_legacy_turn_on_d4(self, mcu): + """Set intensity via new API, turn on via legacy - D4 case.""" + # Set intensity on port 3 via new API + mcu.set_port_intensity(3, 55) + mcu.wait_till_operation_is_completed() + + # Select D4 via legacy (source 13) + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D4, 55) + mcu.wait_till_operation_is_completed() + + # Turn on via legacy + mcu.turn_on_illumination() + mcu.wait_till_operation_is_completed() + + # Port 3 (D4) should be on + assert mcu._serial.port_is_on[3] is True + + def test_d3_d4_independent_control(self, mcu): + """D3 and D4 should be independently controllable despite adjacent source codes.""" + # Set different intensities for D3 (source 14 -> port 2) and D4 (source 13 -> port 3) + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D3, 30) + mcu.wait_till_operation_is_completed() + mcu.set_illumination(ILLUMINATION_CODE.ILLUMINATION_D4, 90) + mcu.wait_till_operation_is_completed() + + # Verify correct mapping + assert mcu._serial.port_intensity[2] == int(0.3 * 65535) # D3 -> port 2 + assert mcu._serial.port_intensity[3] == int(0.9 * 65535) # D4 -> port 3 + + # Turn on both via new API + mcu.turn_on_port(2) + mcu.wait_till_operation_is_completed() + mcu.turn_on_port(3) + mcu.wait_till_operation_is_completed() + + # Both should be on independently + assert mcu._serial.port_is_on[2] is True + assert mcu._serial.port_is_on[3] is True + + # Turn off D3 via legacy turn_off (turns off all) + mcu.turn_off_illumination() + mcu.wait_till_operation_is_completed() + + # Both should be off (legacy turn_off affects all) + assert mcu._serial.port_is_on[2] is False + assert mcu._serial.port_is_on[3] is False + + def test_all_five_ports_round_trip(self, mcu): + """Verify all 5 ports map correctly: D1-D5 to ports 0-4.""" + mappings = [ + (ILLUMINATION_CODE.ILLUMINATION_D1, 0), # 11 -> 0 + (ILLUMINATION_CODE.ILLUMINATION_D2, 1), # 12 -> 1 + (ILLUMINATION_CODE.ILLUMINATION_D3, 2), # 14 -> 2 (non-sequential!) + (ILLUMINATION_CODE.ILLUMINATION_D4, 3), # 13 -> 3 (non-sequential!) + (ILLUMINATION_CODE.ILLUMINATION_D5, 4), # 15 -> 4 + ] + + for source_code, expected_port in mappings: + # Reset all ports + mcu.turn_off_all_ports() + mcu.wait_till_operation_is_completed() + for i in range(5): + mcu.set_port_intensity(i, 0) + mcu.wait_till_operation_is_completed() + + # Set intensity via legacy + mcu.set_illumination(source_code, 50) + mcu.wait_till_operation_is_completed() + + # Verify only the expected port has intensity + expected_intensity = int(0.5 * 65535) + for port in range(5): + if port == expected_port: + assert ( + mcu._serial.port_intensity[port] == expected_intensity + ), f"Source {source_code} should map to port {expected_port}" + else: + assert ( + mcu._serial.port_intensity[port] == 0 + ), f"Port {port} should be unchanged when setting source {source_code}" + + class TestFirmwareVersionBehavior: """Test firmware version detection and behavior.""" From 7e7457943cdbaa18b670a0f2ef77de93a79f6302 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 21:36:57 -0800 Subject: [PATCH 17/28] docs: Document illumination_intensity_factor scaling behavior Add documentation explaining the intensity scaling factor that is applied to ALL illumination commands (legacy and multi-port): 0.6 = Squid LEDs (0-1.5V output range) 0.8 = Squid laser engine (0-2V output range) 1.0 = Full range (0-2.5V output, when DAC gain is 1 instead of 2) Updated: - firmware/controller/src/globals.cpp: Document default value - firmware/controller/src/functions.cpp: Document set_port_intensity() scaling - software/control/_def.py: Document ILLUMINATION_INTENSITY_FACTOR constant - software/control/microcontroller.py: Add docstring to scaling factor method Co-Authored-By: Claude Opus 4.5 --- firmware/controller/src/functions.cpp | 5 ++++- firmware/controller/src/globals.cpp | 5 +++++ software/control/_def.py | 5 +++++ software/control/microcontroller.py | 13 +++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/firmware/controller/src/functions.cpp b/firmware/controller/src/functions.cpp index 5920ef0e8..615094c1b 100644 --- a/firmware/controller/src/functions.cpp +++ b/firmware/controller/src/functions.cpp @@ -438,6 +438,9 @@ void turn_off_port(int port_index) illumination_port_is_on[port_index] = false; } +// Set DAC intensity for a specific port without changing on/off state. +// The intensity is scaled by illumination_intensity_factor before being sent to DAC. +// The unscaled value is stored in illumination_port_intensity[] for reference. void set_port_intensity(int port_index, uint16_t intensity) { if (port_index < 0 || port_index >= NUM_ILLUMINATION_PORTS) @@ -449,7 +452,7 @@ void set_port_intensity(int port_index, uint16_t intensity) uint16_t scaled_intensity = intensity * illumination_intensity_factor; set_DAC8050x_output(dac_channel, scaled_intensity); - illumination_port_intensity[port_index] = intensity; + illumination_port_intensity[port_index] = intensity; // Store unscaled for reference } void turn_off_all_ports() diff --git a/firmware/controller/src/globals.cpp b/firmware/controller/src/globals.cpp index 7b58e6e10..81b85e737 100644 --- a/firmware/controller/src/globals.cpp +++ b/firmware/controller/src/globals.cpp @@ -131,6 +131,11 @@ bool enable_filterwheel_w2 = false; /***************************************************************************************************/ int illumination_source = 0; uint16_t illumination_intensity = 65535; +// Illumination intensity scaling factor - scales DAC output for different hardware: +// 0.6 = Squid LEDs (0-1.5V output range) +// 0.8 = Squid laser engine (0-2V output range) +// 1.0 = Full range (0-2.5V output, when DAC gain is 1 instead of 2) +// This factor is applied to ALL illumination commands (legacy and multi-port). float illumination_intensity_factor = 0.6; uint8_t led_matrix_r = 0; uint8_t led_matrix_g = 0; diff --git a/software/control/_def.py b/software/control/_def.py index 007cebe76..c70459c5b 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -932,6 +932,11 @@ class SOFTWARE_POS_LIMIT: INVERTED_OBJECTIVE = False +# Illumination intensity scaling factor - scales DAC output for different hardware: +# 0.6 = Squid LEDs (0-1.5V output range) +# 0.8 = Squid laser engine (0-2V output range) +# 1.0 = Full range (0-2.5V output, when DAC gain is 1 instead of 2) +# This factor is applied to ALL illumination commands (legacy and multi-port). ILLUMINATION_INTENSITY_FACTOR = 0.6 CAMERA_TYPE = "Default" diff --git a/software/control/microcontroller.py b/software/control/microcontroller.py index 84bb207d8..9c2257ca3 100644 --- a/software/control/microcontroller.py +++ b/software/control/microcontroller.py @@ -1587,6 +1587,19 @@ def _payload_to_int(payload, number_of_bytes) -> int: return int(signed) def set_dac80508_scaling_factor_for_illumination(self, illumination_intensity_factor): + """Set the illumination intensity scaling factor on the MCU. + + This factor scales the DAC output voltage for ALL illumination commands + (both legacy single-source and new multi-port commands). + + Recommended values for different hardware: + 0.6 = Squid LEDs (0-1.5V output range) + 0.8 = Squid laser engine (0-2V output range) + 1.0 = Full range (0-2.5V output, when DAC gain is 1 instead of 2) + + Args: + illumination_intensity_factor: Scaling factor (0.01-1.0, clamped) + """ if illumination_intensity_factor > 1: illumination_intensity_factor = 1 From a91c5ecbeba2154d5047726241ae4c036d268073 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 21:39:28 -0800 Subject: [PATCH 18/28] docs: Add non-blocking notes to Microcontroller multi-port methods Document that all multi-port illumination methods in Microcontroller are non-blocking and callers should use wait_till_operation_is_completed() if command ordering matters. Updated methods: - set_port_intensity() - turn_on_port() - turn_off_port() - set_port_illumination() - set_multi_port_mask() - turn_off_all_ports() Co-Authored-By: Claude Opus 4.5 --- software/control/microcontroller.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/software/control/microcontroller.py b/software/control/microcontroller.py index 9c2257ca3..10610d5b3 100644 --- a/software/control/microcontroller.py +++ b/software/control/microcontroller.py @@ -767,6 +767,9 @@ def _validate_port_index(self, port_index: int): def set_port_intensity(self, port_index: int, intensity: float): """Set DAC intensity for a specific port without changing on/off state. + Note: Non-blocking. Call wait_till_operation_is_completed() before + sending another command if ordering matters. + Args: port_index: Port index (0=D1, 1=D2, etc.) intensity: Intensity percentage (0-100), clamped to valid range @@ -790,6 +793,9 @@ def set_port_intensity(self, port_index: int, intensity: float): def turn_on_port(self, port_index: int): """Turn on a specific illumination port. + Note: Non-blocking. Call wait_till_operation_is_completed() before + sending another command if ordering matters. + Args: port_index: Port index (0=D1, 1=D2, etc.) @@ -807,6 +813,9 @@ def turn_on_port(self, port_index: int): def turn_off_port(self, port_index: int): """Turn off a specific illumination port. + Note: Non-blocking. Call wait_till_operation_is_completed() before + sending another command if ordering matters. + Args: port_index: Port index (0=D1, 1=D2, etc.) @@ -824,6 +833,9 @@ def turn_off_port(self, port_index: int): def set_port_illumination(self, port_index: int, intensity: float, turn_on: bool): """Set intensity and on/off state for a specific port in one command. + Note: Non-blocking. Call wait_till_operation_is_completed() before + sending another command if ordering matters. + Args: port_index: Port index (0=D1, 1=D2, etc.) intensity: Intensity percentage (0-100), clamped to valid range @@ -852,6 +864,9 @@ def set_multi_port_mask(self, port_mask: int, on_mask: int): This allows turning multiple ports on/off in a single command while leaving other ports unchanged. + Note: Non-blocking. Call wait_till_operation_is_completed() before + sending another command if ordering matters. + Args: port_mask: 16-bit mask of which ports to update (bit 0=D1, bit 15=D16) on_mask: 16-bit mask of on/off state for selected ports (1=on, 0=off) @@ -870,7 +885,11 @@ def set_multi_port_mask(self, port_mask: int, on_mask: int): self.send_command(cmd) def turn_off_all_ports(self): - """Turn off all illumination ports.""" + """Turn off all illumination ports. + + Note: Non-blocking. Call wait_till_operation_is_completed() before + sending another command if ordering matters. + """ self.log.debug("[MCU] turn_off_all_ports") cmd = bytearray(self.tx_buffer_length) cmd[1] = CMD_SET.TURN_OFF_ALL_PORTS From 1d4f483884af4a1712a18b933ca44c17ac983500 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 22:08:06 -0800 Subject: [PATCH 19/28] fix: Address PR review comments for multi-port illumination 1. Remove duplicate illumination_source_to_port_index() from functions.cpp - Now uses shared implementation from utils/illumination_mapping.h - Ensures firmware and tests use identical mapping logic 2. Add supports_multi_port() checks in IlluminationController - All multi-port methods now verify firmware compatibility - Raises RuntimeError with clear message if firmware is too old 3. Remove unused imports from test files - ILLUMINATION_PORT and NUM_ILLUMINATION_PORTS were not used 4. Remove unnecessary re-exports from lighting.py - source_code_to_port_index and port_index_to_source_code - Canonical location is control._def 5. Use the `initial` variable in test_version_detected_after_any_command - Added assertion to verify it's a valid tuple Co-Authored-By: Claude Opus 4.5 --- firmware/controller/src/functions.cpp | 16 ++-------------- firmware/controller/src/functions.h | 4 ++-- software/control/lighting.py | 17 ++++++++++++++--- .../test_multiport_illumination_edge_cases.py | 14 +++++++------- .../test_multiport_illumination_protocol.py | 5 +++-- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/firmware/controller/src/functions.cpp b/firmware/controller/src/functions.cpp index 615094c1b..3a8fb2ea6 100644 --- a/firmware/controller/src/functions.cpp +++ b/firmware/controller/src/functions.cpp @@ -370,20 +370,8 @@ void set_illumination_led_matrix(int source, uint8_t r, uint8_t g, uint8_t b) /********************************** Multi-port illumination control ********************************/ /***************************************************************************************************/ -// Maps illumination source code (e.g., ILLUMINATION_D1=11) to port index (0-4) -// Returns -1 for invalid source codes -int illumination_source_to_port_index(int source) -{ - switch (source) - { - case ILLUMINATION_D1: return 0; - case ILLUMINATION_D2: return 1; - case ILLUMINATION_D3: return 2; - case ILLUMINATION_D4: return 3; - case ILLUMINATION_D5: return 4; - default: return -1; - } -} +// illumination_source_to_port_index() is provided by utils/illumination_mapping.h +// to ensure firmware and tests use the same mapping logic. // Gets GPIO pin for port index (0-15), returns -1 for unsupported ports int port_index_to_pin(int port_index) diff --git a/firmware/controller/src/functions.h b/firmware/controller/src/functions.h index f61597726..6559a2207 100644 --- a/firmware/controller/src/functions.h +++ b/firmware/controller/src/functions.h @@ -3,6 +3,7 @@ #include "constants.h" #include "globals.h" +#include "utils/illumination_mapping.h" #include #include @@ -55,8 +56,7 @@ void set_illumination_led_matrix(int source, uint8_t r, uint8_t g, uint8_t b); void ISR_strobeTimer(); // Multi-port illumination control -// Maps source code (11-15) to port index (0-4), returns -1 for invalid source -int illumination_source_to_port_index(int source); +// illumination_source_to_port_index() is provided by utils/illumination_mapping.h // Gets GPIO pin for port index, returns -1 for invalid port int port_index_to_pin(int port_index); // Per-port control functions (interlock checked for turn_on) diff --git a/software/control/lighting.py b/software/control/lighting.py index 52657dbf9..99f8fc1a4 100644 --- a/software/control/lighting.py +++ b/software/control/lighting.py @@ -8,9 +8,6 @@ from control.core.config import ConfigRepository from control._def import ILLUMINATION_CODE -# Import and re-export mapping functions (canonical location is _def.py) -from control._def import source_code_to_port_index, port_index_to_source_code - # Number of illumination ports supported (matches firmware) NUM_ILLUMINATION_PORTS = 16 @@ -226,6 +223,14 @@ def get_shutter_state(self): # Multi-port illumination methods (firmware v1.0+) # These allow multiple ports to be ON simultaneously with independent intensities + def _check_multi_port_support(self): + """Check if firmware supports multi-port commands, raise if not.""" + if not self.microcontroller.supports_multi_port(): + raise RuntimeError( + "Firmware does not support multi-port illumination commands. " + "Update firmware to version 1.0 or later." + ) + def set_port_intensity(self, port_index: int, intensity: float): """Set intensity for a specific port without changing on/off state. @@ -233,6 +238,7 @@ def set_port_intensity(self, port_index: int, intensity: float): port_index: Port index (0=D1, 1=D2, etc.) intensity: Intensity percentage (0-100) """ + self._check_multi_port_support() if port_index < 0 or port_index >= NUM_ILLUMINATION_PORTS: raise ValueError(f"Invalid port index: {port_index}") self.microcontroller.set_port_intensity(port_index, intensity) @@ -245,6 +251,7 @@ def turn_on_port(self, port_index: int): Args: port_index: Port index (0=D1, 1=D2, etc.) """ + self._check_multi_port_support() if port_index < 0 or port_index >= NUM_ILLUMINATION_PORTS: raise ValueError(f"Invalid port index: {port_index}") self.microcontroller.turn_on_port(port_index) @@ -257,6 +264,7 @@ def turn_off_port(self, port_index: int): Args: port_index: Port index (0=D1, 1=D2, etc.) """ + self._check_multi_port_support() if port_index < 0 or port_index >= NUM_ILLUMINATION_PORTS: raise ValueError(f"Invalid port index: {port_index}") self.microcontroller.turn_off_port(port_index) @@ -271,6 +279,7 @@ def set_port_illumination(self, port_index: int, intensity: float, turn_on: bool intensity: Intensity percentage (0-100) turn_on: Whether to turn the port on """ + self._check_multi_port_support() if port_index < 0 or port_index >= NUM_ILLUMINATION_PORTS: raise ValueError(f"Invalid port index: {port_index}") self.microcontroller.set_port_illumination(port_index, intensity, turn_on) @@ -287,6 +296,7 @@ def turn_on_multiple_ports(self, port_indices: List[int]): if not port_indices: return + self._check_multi_port_support() # Build port mask and on mask port_mask = 0 on_mask = 0 @@ -303,6 +313,7 @@ def turn_on_multiple_ports(self, port_indices: List[int]): def turn_off_all_ports(self): """Turn off all illumination ports.""" + self._check_multi_port_support() self.microcontroller.turn_off_all_ports() self.microcontroller.wait_till_operation_is_completed() for i in range(NUM_ILLUMINATION_PORTS): diff --git a/software/tests/test_multiport_illumination_edge_cases.py b/software/tests/test_multiport_illumination_edge_cases.py index 824ca0f87..edd0d9f88 100644 --- a/software/tests/test_multiport_illumination_edge_cases.py +++ b/software/tests/test_multiport_illumination_edge_cases.py @@ -5,9 +5,9 @@ """ import pytest -from control._def import CMD_SET, ILLUMINATION_PORT, ILLUMINATION_CODE +from control._def import CMD_SET, ILLUMINATION_CODE from control.microcontroller import Microcontroller, SimSerial -from control.lighting import IlluminationController, NUM_ILLUMINATION_PORTS +from control.lighting import IlluminationController class TestIntensityBoundaryConditions: @@ -190,7 +190,7 @@ def test_port_index_to_source_code_mapping(self): 4: ILLUMINATION_CODE.ILLUMINATION_D5, # 15 } - from control.lighting import port_index_to_source_code + from control._def import port_index_to_source_code for port_idx, expected_source in expected_mapping.items(): assert port_index_to_source_code(port_idx) == expected_source @@ -205,14 +205,14 @@ def test_source_code_to_port_index_mapping(self): ILLUMINATION_CODE.ILLUMINATION_D5: 4, } - from control.lighting import source_code_to_port_index + from control._def import source_code_to_port_index for source_code, expected_port in expected_mapping.items(): assert source_code_to_port_index(source_code) == expected_port def test_invalid_port_index_returns_negative_one(self): """Invalid port indices should return -1.""" - from control.lighting import port_index_to_source_code + from control._def import port_index_to_source_code assert port_index_to_source_code(-1) == -1 assert port_index_to_source_code(5) == -1 @@ -220,7 +220,7 @@ def test_invalid_port_index_returns_negative_one(self): def test_invalid_source_code_returns_negative_one(self): """Invalid source codes should return -1.""" - from control.lighting import source_code_to_port_index + from control._def import source_code_to_port_index assert source_code_to_port_index(0) == -1 assert source_code_to_port_index(10) == -1 @@ -228,7 +228,7 @@ def test_invalid_source_code_returns_negative_one(self): def test_round_trip_port_to_source_to_port(self): """Round trip: port -> source -> port should give same value.""" - from control.lighting import port_index_to_source_code, source_code_to_port_index + from control._def import port_index_to_source_code, source_code_to_port_index for port in range(5): source = port_index_to_source_code(port) diff --git a/software/tests/test_multiport_illumination_protocol.py b/software/tests/test_multiport_illumination_protocol.py index 9c6e9584f..3f6951f58 100644 --- a/software/tests/test_multiport_illumination_protocol.py +++ b/software/tests/test_multiport_illumination_protocol.py @@ -5,9 +5,9 @@ """ import pytest -from control._def import CMD_SET, ILLUMINATION_PORT, ILLUMINATION_CODE +from control._def import CMD_SET, ILLUMINATION_CODE from control.microcontroller import Microcontroller, SimSerial -from control.lighting import IlluminationController, NUM_ILLUMINATION_PORTS +from control.lighting import IlluminationController class TestProtocolByteAgreement: @@ -478,6 +478,7 @@ def test_version_detected_after_any_command(self, mcu): """Firmware version should be detected after any command.""" # Initially might be (0, 0) until we get a response initial = mcu.firmware_version + assert isinstance(initial, tuple) and len(initial) == 2 # Send a command to trigger response mcu.turn_off_all_ports() From f25d454ee5a62837f61338a948e30c62bc5953ca Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 22:14:04 -0800 Subject: [PATCH 20/28] docs: Add class docstring explaining legacy vs multi-port illumination APIs Documents when to use each API: - Legacy API: Single channel at a time (standard acquisition) - Multi-port API: Multiple channels ON simultaneously (firmware v1.0+) Includes usage examples for both styles. Co-Authored-By: Claude Opus 4.5 --- software/control/lighting.py | 45 +++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/software/control/lighting.py b/software/control/lighting.py index 99f8fc1a4..21fcf4cbc 100644 --- a/software/control/lighting.py +++ b/software/control/lighting.py @@ -33,6 +33,43 @@ class ShutterControlMode(Enum): class IlluminationController: + """Controls microscope illumination (LEDs, lasers, LED matrix). + + Two API styles are available: + + **Legacy API (single-channel, any firmware):** + Use when only ONE illumination source is needed at a time. + This is the standard acquisition workflow. + + - set_intensity(wavelength, intensity) - Set intensity by wavelength + - turn_on_illumination() / turn_off_illumination() - Control current source + + Example: + controller.set_intensity(488, 50) # 488nm at 50% + controller.turn_on_illumination() + # ... acquire image ... + controller.turn_off_illumination() + + **Multi-port API (firmware v1.0+):** + Use when MULTIPLE illumination sources must be ON simultaneously. + Requires firmware v1.0 or later (checked automatically). + + - set_port_intensity(port_index, intensity) - Set intensity by port (0=D1, 1=D2, ...) + - turn_on_port(port_index) / turn_off_port(port_index) - Control specific port + - turn_on_multiple_ports([port_indices]) - Turn on multiple ports at once + - turn_off_all_ports() - Turn off all ports + + Example: + controller.set_port_intensity(0, 50) # D1 at 50% + controller.set_port_intensity(1, 30) # D2 at 30% + controller.turn_on_multiple_ports([0, 1]) # Both ON simultaneously + # ... acquire image ... + controller.turn_off_all_ports() + + Note: The two APIs share underlying hardware state. Using legacy commands + will update the corresponding port state, and vice versa. + """ + def __init__( self, microcontroller: Microcontroller, @@ -43,7 +80,13 @@ def __init__( disable_intensity_calibration=False, ): """ - disable_intensity_calibration: for Squid LEDs and lasers only - set to True to control LED/laser current directly + Args: + microcontroller: MCU interface for hardware communication + intensity_control_mode: How intensity is controlled (DAC or software) + shutter_control_mode: How shutter is controlled (TTL or software) + light_source_type: Type of light source (SquidLED, SquidLaser, etc.) + light_source: External light source object (for software control) + disable_intensity_calibration: Set True to control LED/laser current directly """ self.microcontroller = microcontroller self.intensity_control_mode = intensity_control_mode From 5a7b3de03fd045eeb4bc0de6932730922a139880 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 22:16:04 -0800 Subject: [PATCH 21/28] docs: Add illumination control documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive guide covering: - Hardware ports (D1-D5) and their mapping - Intensity scaling factors for LEDs vs lasers - Legacy API (single channel, any firmware) - Multi-port API (multiple channels, firmware v1.0+) - When to use each API with examples - Port index ↔ source code mapping functions - MCU protocol commands reference - Troubleshooting common issues Co-Authored-By: Claude Opus 4.5 --- software/docs/illumination-control.md | 189 ++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 software/docs/illumination-control.md diff --git a/software/docs/illumination-control.md b/software/docs/illumination-control.md new file mode 100644 index 000000000..10dcb364e --- /dev/null +++ b/software/docs/illumination-control.md @@ -0,0 +1,189 @@ +# Illumination Control + +This document describes the illumination control system, including the hardware ports, software APIs, and when to use each approach. + +## Overview + +The Squid microscope controller supports up to 16 illumination ports (D1-D16), with D1-D5 currently implemented. Each port controls: +- **DAC output** - Analog voltage for intensity control (0-2.5V range) +- **GPIO output** - Digital on/off control + +Two software APIs are available: +- **Legacy API** - Single channel at a time (standard acquisition) +- **Multi-port API** - Multiple channels ON simultaneously (firmware v1.0+) + +## Hardware Ports + +| Port | Source Code | DAC Channel | GPIO Pin | Typical Use | +|------|-------------|-------------|----------|-------------| +| D1 | 11 | 0 | PIN_D1 | 405nm (violet) | +| D2 | 12 | 1 | PIN_D2 | 470/488nm (blue) | +| D3 | 14 | 2 | PIN_D3 | 545-561nm (green) | +| D4 | 13 | 3 | PIN_D4 | 638/640nm (red) | +| D5 | 15 | 4 | PIN_D5 | 730-750nm (NIR) | + +**Important:** D3 and D4 have non-sequential source codes (14 and 13) for historical API compatibility. The multi-port API uses sequential port indices (0-4) which map correctly internally. + +## Intensity Scaling + +The DAC output is scaled by `illumination_intensity_factor`: +- **0.6** - Squid LEDs (0-1.5V output range) +- **0.8** - Squid laser engine (0-2V output range) +- **1.0** - Full range (0-2.5V, when DAC gain is 1 instead of 2) + +## Legacy API (Single Channel) + +Use when only ONE illumination source is needed at a time. This is the standard acquisition workflow. + +**Works with any firmware version.** + +### Methods + +```python +from control.lighting import IlluminationController + +# Set intensity by wavelength (uses channel mapping) +controller.set_intensity(wavelength=488, intensity=50) # 488nm at 50% + +# Turn on/off the currently selected source +controller.turn_on_illumination() +controller.turn_off_illumination() +``` + +### Example: Sequential Multi-Channel Acquisition + +```python +channels = [488, 561, 640] # Blue, Green, Red + +for channel in channels: + controller.set_intensity(channel, intensity=50) + controller.turn_on_illumination() + camera.capture() + controller.turn_off_illumination() +``` + +## Multi-Port API (Multiple Channels) + +Use when MULTIPLE illumination sources must be ON simultaneously. + +**Requires firmware v1.0 or later.** Methods automatically check firmware version and raise `RuntimeError` if not supported. + +### Methods + +```python +# Set intensity by port index (0=D1, 1=D2, 2=D3, 3=D4, 4=D5) +controller.set_port_intensity(port_index=0, intensity=50) + +# Turn on/off specific ports +controller.turn_on_port(port_index=0) +controller.turn_off_port(port_index=0) + +# Combined: set intensity and on/off in one command +controller.set_port_illumination(port_index=0, intensity=50, turn_on=True) + +# Turn on multiple ports simultaneously +controller.turn_on_multiple_ports([0, 1, 2]) + +# Turn off all ports +controller.turn_off_all_ports() + +# Query active ports +active = controller.get_active_ports() # Returns list of port indices +``` + +### Example: Simultaneous Multi-Color Imaging + +```python +# Set intensities for multiple channels +controller.set_port_intensity(0, 30) # D1 (405nm) at 30% +controller.set_port_intensity(1, 50) # D2 (488nm) at 50% +controller.set_port_intensity(3, 40) # D4 (640nm) at 40% + +# Turn on all three simultaneously +controller.turn_on_multiple_ports([0, 1, 3]) + +# Capture with all three illumination sources +camera.capture() + +# Turn off all +controller.turn_off_all_ports() +``` + +## When to Use Each API + +| Scenario | API | Why | +|----------|-----|-----| +| Standard sequential acquisition | Legacy | Simpler, works with any firmware | +| Z-stack with single channel per slice | Legacy | Standard workflow | +| Simultaneous multi-color imaging | Multi-port | Multiple sources ON at once | +| Custom light mixing experiments | Multi-port | Independent control of each port | +| FRET imaging | Multi-port | Need excitation + emission control | +| Backward compatibility required | Legacy | Works with older firmware | + +## Firmware Version Detection + +The software automatically detects firmware version from MCU responses: + +```python +# Check firmware version +version = microcontroller.firmware_version # Returns tuple (major, minor) + +# Check if multi-port is supported +if microcontroller.supports_multi_port(): + # Use multi-port API + controller.turn_on_multiple_ports([0, 1]) +else: + # Fall back to legacy API + controller.turn_on_illumination() +``` + +## Port Index ↔ Source Code Mapping + +For developers working with both APIs, use the mapping functions in `control._def`: + +```python +from control._def import source_code_to_port_index, port_index_to_source_code + +# Legacy source code → port index +port = source_code_to_port_index(14) # Returns 2 (D3) +port = source_code_to_port_index(13) # Returns 3 (D4) + +# Port index → legacy source code +source = port_index_to_source_code(2) # Returns 14 (D3) +source = port_index_to_source_code(3) # Returns 13 (D4) +``` + +## MCU Protocol Commands + +For low-level debugging or firmware development: + +| Command | Code | Description | +|---------|------|-------------| +| SET_ILLUMINATION | 5 | Legacy: set source + intensity | +| TURN_ON_ILLUMINATION | 6 | Legacy: turn on current source | +| TURN_OFF_ILLUMINATION | 7 | Legacy: turn off current source | +| SET_PORT_INTENSITY | 34 | Multi-port: set DAC for port | +| TURN_ON_PORT | 35 | Multi-port: turn on GPIO for port | +| TURN_OFF_PORT | 36 | Multi-port: turn off GPIO for port | +| SET_PORT_ILLUMINATION | 37 | Multi-port: combined intensity + on/off | +| SET_MULTI_PORT_MASK | 38 | Multi-port: control multiple ports | +| TURN_OFF_ALL_PORTS | 39 | Multi-port: turn off all ports | + +## Troubleshooting + +### "Firmware does not support multi-port illumination commands" + +The connected MCU has firmware older than v1.0. Options: +1. Update the firmware to v1.0+ +2. Use the legacy API instead + +### Intensity appears wrong + +Check `illumination_intensity_factor` in firmware matches your hardware: +- LEDs: 0.6 +- Laser engine: 0.8 +- Full range: 1.0 + +### D3/D4 channels swapped + +The legacy source codes are non-sequential (D3=14, D4=13). If using the multi-port API with port indices, the mapping is handled automatically. If mixing APIs, use the mapping functions to convert. From 769265a344e4a6b174bd842ebd6110dc18a3d64a Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 22:20:49 -0800 Subject: [PATCH 22/28] docs: Add port index column to hardware ports table Makes the mapping between port names (D1-D5) and port indices (0-4) explicit in the documentation table. Co-Authored-By: Claude Opus 4.5 --- software/docs/illumination-control.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/software/docs/illumination-control.md b/software/docs/illumination-control.md index 10dcb364e..dcf87567c 100644 --- a/software/docs/illumination-control.md +++ b/software/docs/illumination-control.md @@ -14,13 +14,13 @@ Two software APIs are available: ## Hardware Ports -| Port | Source Code | DAC Channel | GPIO Pin | Typical Use | -|------|-------------|-------------|----------|-------------| -| D1 | 11 | 0 | PIN_D1 | 405nm (violet) | -| D2 | 12 | 1 | PIN_D2 | 470/488nm (blue) | -| D3 | 14 | 2 | PIN_D3 | 545-561nm (green) | -| D4 | 13 | 3 | PIN_D4 | 638/640nm (red) | -| D5 | 15 | 4 | PIN_D5 | 730-750nm (NIR) | +| Port | Port Index | Source Code | DAC Channel | GPIO Pin | Typical Use | +|------|------------|-------------|-------------|----------|-------------| +| D1 | 0 | 11 | 0 | PIN_D1 | 405nm (violet) | +| D2 | 1 | 12 | 1 | PIN_D2 | 470/488nm (blue) | +| D3 | 2 | 14 | 2 | PIN_D3 | 545-561nm (green) | +| D4 | 3 | 13 | 3 | PIN_D4 | 638/640nm (red) | +| D5 | 4 | 15 | 4 | PIN_D5 | 730-750nm (NIR) | **Important:** D3 and D4 have non-sequential source codes (14 and 13) for historical API compatibility. The multi-port API uses sequential port indices (0-4) which map correctly internally. From f37b88cf8edf3a1fd2b59de4853f0b8d23553fcd Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 22:55:15 -0800 Subject: [PATCH 23/28] fix: Handle D3/D5 ports in ImageDisplayWindow.display_image() The function was missing handling for port_index 2 (D3) and 4 (D5), causing frames from those ports to not be displayed. Now falls back to graphics_widget_1 for these cases. Co-Authored-By: Claude Opus 4.5 --- software/control/core/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/software/control/core/core.py b/software/control/core/core.py index c3462e14b..ac3201638 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -1760,6 +1760,9 @@ def display_image(self, image, illumination_source): self.graphics_widget_3.img.setImage(image, autoLevels=False) elif port_index == 3: # D4 (matches original code behavior) self.graphics_widget_4.img.setImage(image, autoLevels=False) + else: + # D3 (port_index=2) and D5 (port_index=4) fall back to default widget + self.graphics_widget_1.img.setImage(image, autoLevels=False) from scipy.interpolate import SmoothBivariateSpline, RBFInterpolator From c31ad9ce8fd344f85e7bba3b609ce997549c09cf Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 23:05:36 -0800 Subject: [PATCH 24/28] refactor: Remove dead code ImageArrayDisplayWindow USE_NAPARI_FOR_MULTIPOINT was always True (hardcoded), making the ImageArrayDisplayWindow class and all related code unreachable. Removed: - ImageArrayDisplayWindow class from core/core.py - USE_NAPARI_FOR_MULTIPOINT constant from _def.py - All conditional branches using USE_NAPARI_FOR_MULTIPOINT in gui_hcs.py - imageArrayDisplayWindow attribute and related connections This simplifies the codebase by removing the unused pyqtgraph-based multi-channel display in favor of the always-used napari-based widget. Co-Authored-By: Claude Opus 4.5 --- software/control/_def.py | 1 - software/control/core/core.py | 73 ---------------------- software/control/gui_hcs.py | 111 +++++++++++++++------------------- 3 files changed, 48 insertions(+), 137 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index c70459c5b..9a9feeaf8 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -984,7 +984,6 @@ class SOFTWARE_POS_LIMIT: # Napari integration USE_NAPARI_FOR_LIVE_VIEW = False -USE_NAPARI_FOR_MULTIPOINT = True USE_NAPARI_FOR_MOSAIC_DISPLAY = True USE_NAPARI_WELL_SELECTION = False USE_NAPARI_FOR_LIVE_CONTROL = False diff --git a/software/control/core/core.py b/software/control/core/core.py index ac3201638..ad1fa2526 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -1692,79 +1692,6 @@ def handle_mouse_click(self, evt): return -class ImageArrayDisplayWindow(QMainWindow): - - def __init__(self, window_title=""): - super().__init__() - self.setWindowTitle(window_title) - self.setWindowFlags(self.windowFlags() | Qt.CustomizeWindowHint) - self.setWindowFlags(self.windowFlags() & ~Qt.WindowCloseButtonHint) - self.widget = QWidget() - - # interpret image data as row-major instead of col-major - pg.setConfigOptions(imageAxisOrder="row-major") - - self.graphics_widget_1 = pg.GraphicsLayoutWidget() - self.graphics_widget_1.view = self.graphics_widget_1.addViewBox() - self.graphics_widget_1.view.setAspectLocked(True) - self.graphics_widget_1.img = pg.ImageItem(border="w") - self.graphics_widget_1.view.addItem(self.graphics_widget_1.img) - self.graphics_widget_1.view.invertY() - - self.graphics_widget_2 = pg.GraphicsLayoutWidget() - self.graphics_widget_2.view = self.graphics_widget_2.addViewBox() - self.graphics_widget_2.view.setAspectLocked(True) - self.graphics_widget_2.img = pg.ImageItem(border="w") - self.graphics_widget_2.view.addItem(self.graphics_widget_2.img) - self.graphics_widget_2.view.invertY() - - self.graphics_widget_3 = pg.GraphicsLayoutWidget() - self.graphics_widget_3.view = self.graphics_widget_3.addViewBox() - self.graphics_widget_3.view.setAspectLocked(True) - self.graphics_widget_3.img = pg.ImageItem(border="w") - self.graphics_widget_3.view.addItem(self.graphics_widget_3.img) - self.graphics_widget_3.view.invertY() - - self.graphics_widget_4 = pg.GraphicsLayoutWidget() - self.graphics_widget_4.view = self.graphics_widget_4.addViewBox() - self.graphics_widget_4.view.setAspectLocked(True) - self.graphics_widget_4.img = pg.ImageItem(border="w") - self.graphics_widget_4.view.addItem(self.graphics_widget_4.img) - self.graphics_widget_4.view.invertY() - ## Layout - layout = QGridLayout() - layout.addWidget(self.graphics_widget_1, 0, 0) - layout.addWidget(self.graphics_widget_2, 0, 1) - layout.addWidget(self.graphics_widget_3, 1, 0) - layout.addWidget(self.graphics_widget_4, 1, 1) - self.widget.setLayout(layout) - self.setCentralWidget(self.widget) - - # set window size - desktopWidget = QDesktopWidget() - width = min(desktopWidget.height() * 0.9, 1000) # @@@TO MOVE@@@# - height = width - self.setFixedSize(int(width), int(height)) - - def display_image(self, image, illumination_source): - # Map illumination source code to port index for widget selection - # Port indices: D1=0, D2=1, D3=2, D4=3, D5=4 - # Source codes: D1=11, D2=12, D3=14, D4=13, D5=15 (non-sequential D3/D4!) - port_index = source_code_to_port_index(illumination_source) - if port_index < 0: - # Not a D1-D5 source, use default widget - self.graphics_widget_1.img.setImage(image, autoLevels=False) - elif port_index == 0: # D1 - self.graphics_widget_2.img.setImage(image, autoLevels=False) - elif port_index == 1: # D2 - self.graphics_widget_3.img.setImage(image, autoLevels=False) - elif port_index == 3: # D4 (matches original code behavior) - self.graphics_widget_4.img.setImage(image, autoLevels=False) - else: - # D3 (port_index=2) and D5 (port_index=4) fall back to default widget - self.graphics_widget_1.img.setImage(image, autoLevels=False) - - from scipy.interpolate import SmoothBivariateSpline, RBFInterpolator diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index eded78ea2..a015ca875 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -339,10 +339,6 @@ def _signal_acquisition_finished_fn(self): def _signal_new_image_fn(self, frame: squid.abc.CameraFrame, info: CaptureInfo): self.image_to_display.emit(frame.frame) - if not USE_NAPARI_FOR_MULTIPOINT: - ill_config = self.microscope.config_repo.get_illumination_config() - source_code = info.configuration.get_illumination_source_code(ill_config) if ill_config else 0 - self.image_to_display_multi.emit(frame.frame, source_code) # Z for plot in μm: piezo-only uses piezo position, mixed mode combines stepper + piezo stepper_z_um = info.position.z_mm * 1000 if IS_PIEZO_ONLY: @@ -660,7 +656,6 @@ def __init__( self.imageDisplayWindow: Optional[core.ImageDisplayWindow] = None self.imageDisplayWindow_focus: Optional[core.ImageDisplayWindow] = None self.napariMultiChannelWidget: Optional[widgets.NapariMultiChannelWidget] = None - self.imageArrayDisplayWindow: Optional[core.ImageArrayDisplayWindow] = None self.zPlotWidget: Optional[widgets.SurfacePlotWidget] = None self.ramMonitorWidget: Optional[widgets.RAMMonitorWidget] = None self.backpressureMonitorWidget: Optional[widgets.BackpressureMonitorWidget] = None @@ -1136,14 +1131,10 @@ def setupImageDisplayTabs(self): self.imageDisplayTabs.addTab(self.imageDisplayWindow.widget, "Live View") if not self.live_only_mode: - if USE_NAPARI_FOR_MULTIPOINT: - self.napariMultiChannelWidget = widgets.NapariMultiChannelWidget( - self.objectiveStore, self.camera, self.contrastManager - ) - self.imageDisplayTabs.addTab(self.napariMultiChannelWidget, "Multichannel Acquisition") - else: - self.imageArrayDisplayWindow = core.ImageArrayDisplayWindow() - self.imageDisplayTabs.addTab(self.imageArrayDisplayWindow.widget, "Multichannel Acquisition") + self.napariMultiChannelWidget = widgets.NapariMultiChannelWidget( + self.objectiveStore, self.camera, self.contrastManager + ) + self.imageDisplayTabs.addTab(self.napariMultiChannelWidget, "Multichannel Acquisition") self.napariMosaicDisplayWidget = None if USE_NAPARI_FOR_MOSAIC_DISPLAY: @@ -1700,54 +1691,51 @@ def makeNapariConnections(self): if not self.live_only_mode: # Setup multichannel widget connections - if USE_NAPARI_FOR_MULTIPOINT: - self.napari_connections["napariMultiChannelWidget"] = [ - (self.multipointController.napari_layers_init, self.napariMultiChannelWidget.initLayers), - (self.multipointController.napari_layers_update, self.napariMultiChannelWidget.updateLayers), - ] + self.napari_connections["napariMultiChannelWidget"] = [ + (self.multipointController.napari_layers_init, self.napariMultiChannelWidget.initLayers), + (self.multipointController.napari_layers_update, self.napariMultiChannelWidget.updateLayers), + ] - if ENABLE_FLEXIBLE_MULTIPOINT: - self.napari_connections["napariMultiChannelWidget"].extend( - [ - ( - self.flexibleMultiPointWidget.signal_acquisition_channels, - self.napariMultiChannelWidget.initChannels, - ), - ( - self.flexibleMultiPointWidget.signal_acquisition_shape, - self.napariMultiChannelWidget.initLayersShape, - ), - ] - ) + if ENABLE_FLEXIBLE_MULTIPOINT: + self.napari_connections["napariMultiChannelWidget"].extend( + [ + ( + self.flexibleMultiPointWidget.signal_acquisition_channels, + self.napariMultiChannelWidget.initChannels, + ), + ( + self.flexibleMultiPointWidget.signal_acquisition_shape, + self.napariMultiChannelWidget.initLayersShape, + ), + ] + ) - if ENABLE_WELLPLATE_MULTIPOINT: - self.napari_connections["napariMultiChannelWidget"].extend( - [ - ( - self.wellplateMultiPointWidget.signal_acquisition_channels, - self.napariMultiChannelWidget.initChannels, - ), - ( - self.wellplateMultiPointWidget.signal_acquisition_shape, - self.napariMultiChannelWidget.initLayersShape, - ), - ] - ) - if RUN_FLUIDICS: - self.napari_connections["napariMultiChannelWidget"].extend( - [ - ( - self.multiPointWithFluidicsWidget.signal_acquisition_channels, - self.napariMultiChannelWidget.initChannels, - ), - ( - self.multiPointWithFluidicsWidget.signal_acquisition_shape, - self.napariMultiChannelWidget.initLayersShape, - ), - ] - ) - else: - self.multipointController.image_to_display_multi.connect(self.imageArrayDisplayWindow.display_image) + if ENABLE_WELLPLATE_MULTIPOINT: + self.napari_connections["napariMultiChannelWidget"].extend( + [ + ( + self.wellplateMultiPointWidget.signal_acquisition_channels, + self.napariMultiChannelWidget.initChannels, + ), + ( + self.wellplateMultiPointWidget.signal_acquisition_shape, + self.napariMultiChannelWidget.initLayersShape, + ), + ] + ) + if RUN_FLUIDICS: + self.napari_connections["napariMultiChannelWidget"].extend( + [ + ( + self.multiPointWithFluidicsWidget.signal_acquisition_channels, + self.napariMultiChannelWidget.initChannels, + ), + ( + self.multiPointWithFluidicsWidget.signal_acquisition_shape, + self.napariMultiChannelWidget.initLayersShape, + ), + ] + ) # Setup mosaic display widget connections if USE_NAPARI_FOR_MOSAIC_DISPLAY: @@ -1891,10 +1879,8 @@ def setAcquisitionDisplayTabs(self, selected_configurations, Nz, xy_mode=None): self.imageDisplayTabs.setCurrentWidget(self.napariPlateViewWidget) elif USE_NAPARI_FOR_MOSAIC_DISPLAY and Nz == 1: self.imageDisplayTabs.setCurrentWidget(self.napariMosaicDisplayWidget) - elif USE_NAPARI_FOR_MULTIPOINT: - self.imageDisplayTabs.setCurrentWidget(self.napariMultiChannelWidget) else: - self.imageDisplayTabs.setCurrentIndex(0) + self.imageDisplayTabs.setCurrentWidget(self.napariMultiChannelWidget) def openLedMatrixSettings(self): if SUPPORT_SCIMICROSCOPY_LED_ARRAY: @@ -2582,7 +2568,6 @@ def _cleanup_common(self, for_restart: bool = False): self.imageDisplay.close() if not SINGLE_WINDOW: self.imageDisplayWindow.close() - self.imageArrayDisplayWindow.close() self.tabbedImageDisplayWindow.close() except Exception: if for_restart: From 5273dd9918fc04572a780c0123a104a1cad68c1f Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 27 Jan 2026 23:08:37 -0800 Subject: [PATCH 25/28] Revert "refactor: Remove dead code ImageArrayDisplayWindow" This reverts commit c31ad9ce8fd344f85e7bba3b609ce997549c09cf. --- software/control/_def.py | 1 + software/control/core/core.py | 73 ++++++++++++++++++++++ software/control/gui_hcs.py | 111 +++++++++++++++++++--------------- 3 files changed, 137 insertions(+), 48 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 9a9feeaf8..c70459c5b 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -984,6 +984,7 @@ class SOFTWARE_POS_LIMIT: # Napari integration USE_NAPARI_FOR_LIVE_VIEW = False +USE_NAPARI_FOR_MULTIPOINT = True USE_NAPARI_FOR_MOSAIC_DISPLAY = True USE_NAPARI_WELL_SELECTION = False USE_NAPARI_FOR_LIVE_CONTROL = False diff --git a/software/control/core/core.py b/software/control/core/core.py index ad1fa2526..ac3201638 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -1692,6 +1692,79 @@ def handle_mouse_click(self, evt): return +class ImageArrayDisplayWindow(QMainWindow): + + def __init__(self, window_title=""): + super().__init__() + self.setWindowTitle(window_title) + self.setWindowFlags(self.windowFlags() | Qt.CustomizeWindowHint) + self.setWindowFlags(self.windowFlags() & ~Qt.WindowCloseButtonHint) + self.widget = QWidget() + + # interpret image data as row-major instead of col-major + pg.setConfigOptions(imageAxisOrder="row-major") + + self.graphics_widget_1 = pg.GraphicsLayoutWidget() + self.graphics_widget_1.view = self.graphics_widget_1.addViewBox() + self.graphics_widget_1.view.setAspectLocked(True) + self.graphics_widget_1.img = pg.ImageItem(border="w") + self.graphics_widget_1.view.addItem(self.graphics_widget_1.img) + self.graphics_widget_1.view.invertY() + + self.graphics_widget_2 = pg.GraphicsLayoutWidget() + self.graphics_widget_2.view = self.graphics_widget_2.addViewBox() + self.graphics_widget_2.view.setAspectLocked(True) + self.graphics_widget_2.img = pg.ImageItem(border="w") + self.graphics_widget_2.view.addItem(self.graphics_widget_2.img) + self.graphics_widget_2.view.invertY() + + self.graphics_widget_3 = pg.GraphicsLayoutWidget() + self.graphics_widget_3.view = self.graphics_widget_3.addViewBox() + self.graphics_widget_3.view.setAspectLocked(True) + self.graphics_widget_3.img = pg.ImageItem(border="w") + self.graphics_widget_3.view.addItem(self.graphics_widget_3.img) + self.graphics_widget_3.view.invertY() + + self.graphics_widget_4 = pg.GraphicsLayoutWidget() + self.graphics_widget_4.view = self.graphics_widget_4.addViewBox() + self.graphics_widget_4.view.setAspectLocked(True) + self.graphics_widget_4.img = pg.ImageItem(border="w") + self.graphics_widget_4.view.addItem(self.graphics_widget_4.img) + self.graphics_widget_4.view.invertY() + ## Layout + layout = QGridLayout() + layout.addWidget(self.graphics_widget_1, 0, 0) + layout.addWidget(self.graphics_widget_2, 0, 1) + layout.addWidget(self.graphics_widget_3, 1, 0) + layout.addWidget(self.graphics_widget_4, 1, 1) + self.widget.setLayout(layout) + self.setCentralWidget(self.widget) + + # set window size + desktopWidget = QDesktopWidget() + width = min(desktopWidget.height() * 0.9, 1000) # @@@TO MOVE@@@# + height = width + self.setFixedSize(int(width), int(height)) + + def display_image(self, image, illumination_source): + # Map illumination source code to port index for widget selection + # Port indices: D1=0, D2=1, D3=2, D4=3, D5=4 + # Source codes: D1=11, D2=12, D3=14, D4=13, D5=15 (non-sequential D3/D4!) + port_index = source_code_to_port_index(illumination_source) + if port_index < 0: + # Not a D1-D5 source, use default widget + self.graphics_widget_1.img.setImage(image, autoLevels=False) + elif port_index == 0: # D1 + self.graphics_widget_2.img.setImage(image, autoLevels=False) + elif port_index == 1: # D2 + self.graphics_widget_3.img.setImage(image, autoLevels=False) + elif port_index == 3: # D4 (matches original code behavior) + self.graphics_widget_4.img.setImage(image, autoLevels=False) + else: + # D3 (port_index=2) and D5 (port_index=4) fall back to default widget + self.graphics_widget_1.img.setImage(image, autoLevels=False) + + from scipy.interpolate import SmoothBivariateSpline, RBFInterpolator diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index a015ca875..eded78ea2 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -339,6 +339,10 @@ def _signal_acquisition_finished_fn(self): def _signal_new_image_fn(self, frame: squid.abc.CameraFrame, info: CaptureInfo): self.image_to_display.emit(frame.frame) + if not USE_NAPARI_FOR_MULTIPOINT: + ill_config = self.microscope.config_repo.get_illumination_config() + source_code = info.configuration.get_illumination_source_code(ill_config) if ill_config else 0 + self.image_to_display_multi.emit(frame.frame, source_code) # Z for plot in μm: piezo-only uses piezo position, mixed mode combines stepper + piezo stepper_z_um = info.position.z_mm * 1000 if IS_PIEZO_ONLY: @@ -656,6 +660,7 @@ def __init__( self.imageDisplayWindow: Optional[core.ImageDisplayWindow] = None self.imageDisplayWindow_focus: Optional[core.ImageDisplayWindow] = None self.napariMultiChannelWidget: Optional[widgets.NapariMultiChannelWidget] = None + self.imageArrayDisplayWindow: Optional[core.ImageArrayDisplayWindow] = None self.zPlotWidget: Optional[widgets.SurfacePlotWidget] = None self.ramMonitorWidget: Optional[widgets.RAMMonitorWidget] = None self.backpressureMonitorWidget: Optional[widgets.BackpressureMonitorWidget] = None @@ -1131,10 +1136,14 @@ def setupImageDisplayTabs(self): self.imageDisplayTabs.addTab(self.imageDisplayWindow.widget, "Live View") if not self.live_only_mode: - self.napariMultiChannelWidget = widgets.NapariMultiChannelWidget( - self.objectiveStore, self.camera, self.contrastManager - ) - self.imageDisplayTabs.addTab(self.napariMultiChannelWidget, "Multichannel Acquisition") + if USE_NAPARI_FOR_MULTIPOINT: + self.napariMultiChannelWidget = widgets.NapariMultiChannelWidget( + self.objectiveStore, self.camera, self.contrastManager + ) + self.imageDisplayTabs.addTab(self.napariMultiChannelWidget, "Multichannel Acquisition") + else: + self.imageArrayDisplayWindow = core.ImageArrayDisplayWindow() + self.imageDisplayTabs.addTab(self.imageArrayDisplayWindow.widget, "Multichannel Acquisition") self.napariMosaicDisplayWidget = None if USE_NAPARI_FOR_MOSAIC_DISPLAY: @@ -1691,51 +1700,54 @@ def makeNapariConnections(self): if not self.live_only_mode: # Setup multichannel widget connections - self.napari_connections["napariMultiChannelWidget"] = [ - (self.multipointController.napari_layers_init, self.napariMultiChannelWidget.initLayers), - (self.multipointController.napari_layers_update, self.napariMultiChannelWidget.updateLayers), - ] + if USE_NAPARI_FOR_MULTIPOINT: + self.napari_connections["napariMultiChannelWidget"] = [ + (self.multipointController.napari_layers_init, self.napariMultiChannelWidget.initLayers), + (self.multipointController.napari_layers_update, self.napariMultiChannelWidget.updateLayers), + ] - if ENABLE_FLEXIBLE_MULTIPOINT: - self.napari_connections["napariMultiChannelWidget"].extend( - [ - ( - self.flexibleMultiPointWidget.signal_acquisition_channels, - self.napariMultiChannelWidget.initChannels, - ), - ( - self.flexibleMultiPointWidget.signal_acquisition_shape, - self.napariMultiChannelWidget.initLayersShape, - ), - ] - ) + if ENABLE_FLEXIBLE_MULTIPOINT: + self.napari_connections["napariMultiChannelWidget"].extend( + [ + ( + self.flexibleMultiPointWidget.signal_acquisition_channels, + self.napariMultiChannelWidget.initChannels, + ), + ( + self.flexibleMultiPointWidget.signal_acquisition_shape, + self.napariMultiChannelWidget.initLayersShape, + ), + ] + ) - if ENABLE_WELLPLATE_MULTIPOINT: - self.napari_connections["napariMultiChannelWidget"].extend( - [ - ( - self.wellplateMultiPointWidget.signal_acquisition_channels, - self.napariMultiChannelWidget.initChannels, - ), - ( - self.wellplateMultiPointWidget.signal_acquisition_shape, - self.napariMultiChannelWidget.initLayersShape, - ), - ] - ) - if RUN_FLUIDICS: - self.napari_connections["napariMultiChannelWidget"].extend( - [ - ( - self.multiPointWithFluidicsWidget.signal_acquisition_channels, - self.napariMultiChannelWidget.initChannels, - ), - ( - self.multiPointWithFluidicsWidget.signal_acquisition_shape, - self.napariMultiChannelWidget.initLayersShape, - ), - ] - ) + if ENABLE_WELLPLATE_MULTIPOINT: + self.napari_connections["napariMultiChannelWidget"].extend( + [ + ( + self.wellplateMultiPointWidget.signal_acquisition_channels, + self.napariMultiChannelWidget.initChannels, + ), + ( + self.wellplateMultiPointWidget.signal_acquisition_shape, + self.napariMultiChannelWidget.initLayersShape, + ), + ] + ) + if RUN_FLUIDICS: + self.napari_connections["napariMultiChannelWidget"].extend( + [ + ( + self.multiPointWithFluidicsWidget.signal_acquisition_channels, + self.napariMultiChannelWidget.initChannels, + ), + ( + self.multiPointWithFluidicsWidget.signal_acquisition_shape, + self.napariMultiChannelWidget.initLayersShape, + ), + ] + ) + else: + self.multipointController.image_to_display_multi.connect(self.imageArrayDisplayWindow.display_image) # Setup mosaic display widget connections if USE_NAPARI_FOR_MOSAIC_DISPLAY: @@ -1879,8 +1891,10 @@ def setAcquisitionDisplayTabs(self, selected_configurations, Nz, xy_mode=None): self.imageDisplayTabs.setCurrentWidget(self.napariPlateViewWidget) elif USE_NAPARI_FOR_MOSAIC_DISPLAY and Nz == 1: self.imageDisplayTabs.setCurrentWidget(self.napariMosaicDisplayWidget) - else: + elif USE_NAPARI_FOR_MULTIPOINT: self.imageDisplayTabs.setCurrentWidget(self.napariMultiChannelWidget) + else: + self.imageDisplayTabs.setCurrentIndex(0) def openLedMatrixSettings(self): if SUPPORT_SCIMICROSCOPY_LED_ARRAY: @@ -2568,6 +2582,7 @@ def _cleanup_common(self, for_restart: bool = False): self.imageDisplay.close() if not SINGLE_WINDOW: self.imageDisplayWindow.close() + self.imageArrayDisplayWindow.close() self.tabbedImageDisplayWindow.close() except Exception: if for_restart: From d8c9d6fcfaf9927fdb5f3f91eb3dd7687b4241a3 Mon Sep 17 00:00:00 2001 From: hongquanli Date: Tue, 27 Jan 2026 23:35:16 -0800 Subject: [PATCH 26/28] Update illumination control documentation --- software/docs/illumination-control.md | 1 - 1 file changed, 1 deletion(-) diff --git a/software/docs/illumination-control.md b/software/docs/illumination-control.md index dcf87567c..1576fe792 100644 --- a/software/docs/illumination-control.md +++ b/software/docs/illumination-control.md @@ -117,7 +117,6 @@ controller.turn_off_all_ports() | Z-stack with single channel per slice | Legacy | Standard workflow | | Simultaneous multi-color imaging | Multi-port | Multiple sources ON at once | | Custom light mixing experiments | Multi-port | Independent control of each port | -| FRET imaging | Multi-port | Need excitation + emission control | | Backward compatibility required | Legacy | Works with older firmware | ## Firmware Version Detection From c67b382c3db799716e0f2b664288d148e9a9cad5 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 28 Jan 2026 00:13:36 -0800 Subject: [PATCH 27/28] fix: Add multi-port illumination commands to firmware validator Add the new command IDs (34-39) to the command_names whitelist in FirmwareConstants.command_ids so that FirmwareSimSerial accepts them. Co-Authored-By: Claude Opus 4.5 --- software/control/firmware_sim_serial.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/software/control/firmware_sim_serial.py b/software/control/firmware_sim_serial.py index 14797e6c3..49184ba95 100644 --- a/software/control/firmware_sim_serial.py +++ b/software/control/firmware_sim_serial.py @@ -111,6 +111,13 @@ def command_ids(self) -> dict: "INITFILTERWHEEL", "INITIALIZE", "RESET", + # Multi-port illumination commands (firmware v1.0+) + "SET_PORT_INTENSITY", + "TURN_ON_PORT", + "TURN_OFF_PORT", + "SET_PORT_ILLUMINATION", + "SET_MULTI_PORT_MASK", + "TURN_OFF_ALL_PORTS", ] return {name: self._constants[name] for name in command_names if name in self._constants} From c0c14c39dc143203e8a2e8970cfd5fde2212c4f0 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Wed, 28 Jan 2026 03:53:50 -0800 Subject: [PATCH 28/28] feat: Add multi-camera support with LiveController camera switching Phase 2 of multi-camera support implementation: - Add camera config factory for generating per-camera configs from registry - Support multiple cameras in Microscope class with Dict[int, AbstractCamera] - Add channel-driven camera switching in LiveController.set_microscope_mode() - Add Channel Group editor UI (ChannelGroupEditorDialog) for multi-camera sync - Add duplicate channel validation in validate_channel_group() Key changes: - LiveController tracks active camera ID and switches based on channel.camera - Race condition protection: is_live=False during camera switch - Early validation: check target camera exists before any state changes - Explicit camera ID passing to LiveController (removes initialization fragility) - Proper cleanup: stop_streaming before close in Microscope.close() Includes 28 unit tests covering camera config factory, Microscope camera API, LiveController multi-camera switching, and channel group validation. Co-Authored-By: Claude Opus 4.5 --- software/control/core/live_controller.py | 90 ++- software/control/gui_hcs.py | 13 + software/control/microscope.py | 152 +++- software/control/models/acquisition_config.py | 6 + software/control/widgets.py | 750 +++++++++++++++++- software/squid/camera/config_factory.py | 94 +++ .../tests/control/test_microscope_cameras.py | 485 +++++++++++ 7 files changed, 1542 insertions(+), 48 deletions(-) create mode 100644 software/squid/camera/config_factory.py create mode 100644 software/tests/control/test_microscope_cameras.py diff --git a/software/control/core/live_controller.py b/software/control/core/live_controller.py index d1b6a9239..e820de1c3 100644 --- a/software/control/core/live_controller.py +++ b/software/control/core/live_controller.py @@ -25,10 +25,16 @@ def __init__( control_illumination: bool = True, use_internal_timer_for_hardware_trigger: bool = True, for_displacement_measurement: bool = False, + initial_camera_id: Optional[int] = None, ): self._log = squid.logging.get_logger(self.__class__.__name__) self.microscope = microscope self.camera: AbstractCamera = camera + # Track which camera is currently active (for multi-camera support) + # Use explicit ID if provided, otherwise fall back to microscope's primary + self._active_camera_id: int = ( + initial_camera_id if initial_camera_id is not None else microscope._primary_camera_id + ) self.currentConfiguration: Optional[AcquisitionChannel] = None self.trigger_mode: Optional[TriggerMode] = TriggerMode.SOFTWARE # @@@ change to None self.is_live = False @@ -114,6 +120,54 @@ def sync_confocal_mode_from_hardware(self, confocal: bool) -> None: """ self.toggle_confocal_widefield(confocal) + # ───────────────────────────────────────────────────────────────────────────── + # Multi-camera support + # ───────────────────────────────────────────────────────────────────────────── + + def _get_target_camera_id(self, configuration: "AcquisitionChannel") -> int: + """Get the camera ID to use for the given channel configuration. + + Args: + configuration: The acquisition channel configuration + + Returns: + Camera ID to use. If channel.camera is None, returns the primary camera ID. + """ + if configuration.camera is not None: + return configuration.camera + return self.microscope._primary_camera_id + + def _switch_camera(self, camera_id: int) -> None: + """Switch to a different camera for live preview. + + This method updates the active camera used by the LiveController. + The caller is responsible for stopping live view before calling + this method if necessary. + + Args: + camera_id: The camera ID to switch to + + Raises: + ValueError: If the camera ID is not found in the microscope + """ + if camera_id == self._active_camera_id: + return # Already using this camera + + new_camera = self.microscope.get_camera(camera_id) + old_camera_id = self._active_camera_id + + self._log.info(f"Switching live camera: {old_camera_id} -> {camera_id}") + self.camera = new_camera + self._active_camera_id = camera_id + + def get_active_camera_id(self) -> int: + """Get the ID of the currently active camera. + + Returns: + The camera ID currently being used for live preview. + """ + return self._active_camera_id + # ───────────────────────────────────────────────────────────────────────────── # Channel configuration access # ───────────────────────────────────────────────────────────────────────────── @@ -452,8 +506,26 @@ def set_microscope_mode(self, configuration: "AcquisitionChannel"): return self._log.info("setting microscope mode to " + configuration.name) - # temporarily stop live while changing mode - if self.is_live is True: + # Check if we need to switch cameras (multi-camera support) + target_camera_id = self._get_target_camera_id(configuration) + + # Validate target camera exists BEFORE any state changes + if target_camera_id not in self.microscope._cameras: + available = sorted(self.microscope._cameras.keys()) + self._log.error( + f"Channel '{configuration.name}' requires camera {target_camera_id}, " + f"but only cameras {available} exist. Mode not changed." + ) + return # Exit cleanly, no state changed + + needs_camera_switch = target_camera_id != self._active_camera_id + + # Save live state and temporarily disable to prevent race conditions + # (frame callbacks accessing camera during switch) + was_live = self.is_live + + if was_live: + self.is_live = False # Prevent concurrent access during mode change self._stop_existing_timer() if self.control_illumination: # Turn off illumination BEFORE switching self.currentConfiguration. @@ -462,6 +534,17 @@ def set_microscope_mode(self, configuration: "AcquisitionChannel"): # channel's laser instead of the OLD channel's laser (which is still on). self.turn_off_illumination() + # Stop streaming on old camera before switching + if needs_camera_switch: + self.camera.stop_streaming() + + # Switch camera if needed + if needs_camera_switch: + self._switch_camera(target_camera_id) + # Start streaming on new camera if we were live + if was_live: + self.camera.start_streaming() + self.currentConfiguration = configuration # set camera exposure time and analog gain @@ -476,7 +559,8 @@ def set_microscope_mode(self, configuration: "AcquisitionChannel"): self.update_illumination() # restart live - if self.is_live is True: + if was_live: + self.is_live = True # Restore live state if self.control_illumination: self.turn_on_illumination() self._start_new_timer() diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 6a248bafb..6f69c5d48 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -743,6 +743,13 @@ def __init__( filter_wheel_config_action.triggered.connect(self.openFilterWheelConfigEditor) advanced_menu.addAction(filter_wheel_config_action) + # Channel Group Configuration (only shown if multi-camera system) + camera_count = len(self.microscope.config_repo.get_camera_names()) + if camera_count > 1: + channel_group_config_action = QAction("Channel Group Configuration", self) + channel_group_config_action.triggered.connect(self.openChannelGroupConfigEditor) + advanced_menu.addAction(channel_group_config_action) + if USE_JUPYTER_CONSOLE: # Create namespace to expose to Jupyter self.namespace = { @@ -2134,6 +2141,12 @@ def openFilterWheelConfigEditor(self): dialog.signal_config_updated.connect(self._refresh_channel_lists) dialog.exec_() + def openChannelGroupConfigEditor(self): + """Open the channel group configuration dialog""" + dialog = widgets.ChannelGroupEditorDialog(self.microscope.config_repo, self) + dialog.signal_config_updated.connect(self._refresh_channel_lists) + dialog.exec_() + def _refresh_channel_lists(self): """Refresh channel lists in all widgets after channel configuration changes""" if self.liveControlWidget: diff --git a/software/control/microscope.py b/software/control/microscope.py index 79e87ae09..7277e9288 100644 --- a/software/control/microscope.py +++ b/software/control/microscope.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Dict, List, Optional, Union import numpy as np @@ -27,6 +27,11 @@ import squid.logging import squid.stage.cephla import squid.stage.utils +from squid.camera.config_factory import ( + DEFAULT_SINGLE_CAMERA_ID, + create_camera_configs, + get_primary_camera_id, +) if control._def.USE_XERYON: from control.objective_changer_2_pos_controller import ( @@ -291,12 +296,39 @@ def acquisition_camera_hw_strobe_delay_fn(strobe_delay_ms: float) -> bool: return True - camera = squid.camera.utils.get_camera( - config=squid.config.get_camera_config(), - simulated=camera_simulated, - hw_trigger_fn=acquisition_camera_hw_trigger_fn, - hw_set_strobe_delay_ms_fn=acquisition_camera_hw_strobe_delay_fn, - ) + # Get camera registry for multi-camera support + from control.core.config import ConfigRepository + + config_repo = ConfigRepository() + camera_registry = config_repo.get_camera_registry() + + # Generate per-camera configs from registry + base template + base_camera_config = squid.config.get_camera_config() + camera_configs = create_camera_configs(camera_registry, base_camera_config) + + # Instantiate all cameras + cameras: Dict[int, AbstractCamera] = {} + for camera_id, cam_config in camera_configs.items(): + cam_trigger_log.info(f"Initializing camera {camera_id} (SN: {cam_config.serial_number})") + try: + camera = squid.camera.utils.get_camera( + config=cam_config, + simulated=camera_simulated, + hw_trigger_fn=acquisition_camera_hw_trigger_fn, + hw_set_strobe_delay_ms_fn=acquisition_camera_hw_strobe_delay_fn, + ) + cameras[camera_id] = camera + except Exception as e: + cam_trigger_log.error(f"Failed to initialize camera {camera_id}: {e}") + # Close any cameras we've already opened + for cleanup_cam_id, opened_camera in cameras.items(): + try: + opened_camera.close() + except Exception as cleanup_exc: + cam_trigger_log.error(f"Cleanup failed for camera {cleanup_cam_id}: {cleanup_exc}") + raise RuntimeError(f"Failed to initialize camera {camera_id}: {e}") from e + + cam_trigger_log.info(f"Initialized {len(cameras)} camera(s): IDs {sorted(cameras.keys())}") if control._def.USE_LDI_SERIAL_CONTROL and not simulated: ldi = serial_peripherals.LDI() @@ -329,10 +361,11 @@ def acquisition_camera_hw_strobe_delay_fn(strobe_delay_ms: float) -> bool: return Microscope( stage=stage, - camera=camera, + cameras=cameras, illumination_controller=illumination_controller, addons=addons, low_level_drivers=low_level_devices, + config_repo=config_repo, simulated=simulated, skip_init=skip_init, ) @@ -340,11 +373,12 @@ def acquisition_camera_hw_strobe_delay_fn(strobe_delay_ms: float) -> bool: def __init__( self, stage: AbstractStage, - camera: AbstractCamera, + cameras: Union[AbstractCamera, Dict[int, AbstractCamera]], illumination_controller: IlluminationController, addons: MicroscopeAddons, low_level_drivers: LowLevelDrivers, stream_handler_callbacks: Optional[StreamHandlerFunctions] = NoOpStreamHandlerFunctions, + config_repo: Optional["ConfigRepository"] = None, simulated: bool = False, skip_prepare_for_use: bool = False, skip_init: bool = False, @@ -352,7 +386,20 @@ def __init__( self._log = squid.logging.get_logger(self.__class__.__name__) self.stage: AbstractStage = stage - self.camera: AbstractCamera = camera + + # Multi-camera support: accept either a single camera or a dict of cameras + # For backward compatibility, a single camera is wrapped in a dict with default ID + if isinstance(cameras, dict): + self._cameras: Dict[int, AbstractCamera] = cameras + else: + # Backward compatibility: wrap single camera in dict + self._cameras = {DEFAULT_SINGLE_CAMERA_ID: cameras} + + self._primary_camera_id: int = get_primary_camera_id(list(self._cameras.keys())) + self._log.info( + f"Initialized with {len(self._cameras)} camera(s), " f"primary camera ID: {self._primary_camera_id}" + ) + self.illumination_controller: IlluminationController = illumination_controller self.addons = addons @@ -362,8 +409,8 @@ def __init__( self.objective_store: ObjectiveStore = ObjectiveStore() - # Centralized config management - self.config_repo: ConfigRepository = ConfigRepository() + # Centralized config management - reuse passed repo or create new one + self.config_repo: ConfigRepository = config_repo if config_repo is not None else ConfigRepository() # Note: Migration from acquisition_configurations to user_profiles is handled # by run_auto_migration() in main_hcs.py before Microscope is created @@ -393,7 +440,11 @@ def __init__( for_displacement_measurement=True, ) - self.live_controller: LiveController = LiveController(microscope=self, camera=self.camera) + self.live_controller: LiveController = LiveController( + microscope=self, + camera=self._cameras[self._primary_camera_id], + initial_camera_id=self._primary_camera_id, + ) # Sync confocal mode from hardware (must be after LiveController creation) if control._def.ENABLE_SPINNING_DISK_CONFOCAL: @@ -402,14 +453,68 @@ def __init__( if not skip_prepare_for_use: self._prepare_for_use(skip_init=skip_init) + # ═══════════════════════════════════════════════════════════════════════════ + # MULTI-CAMERA API + # ═══════════════════════════════════════════════════════════════════════════ + + @property + def camera(self) -> AbstractCamera: + """Get the primary camera (backward compatible). + + Returns: + The primary camera instance (camera with lowest ID). + """ + return self._cameras[self._primary_camera_id] + + def get_camera(self, camera_id: int) -> AbstractCamera: + """Get a camera by its ID. + + Args: + camera_id: The camera ID (from cameras.yaml). + + Returns: + The camera instance. + + Raises: + ValueError: If camera_id is not found. + """ + if camera_id not in self._cameras: + available = sorted(self._cameras.keys()) + raise ValueError(f"Camera ID {camera_id} not found. Available IDs: {available}") + return self._cameras[camera_id] + + def get_camera_ids(self) -> List[int]: + """Get sorted list of all camera IDs. + + Returns: + List of camera IDs in ascending order. + """ + return sorted(self._cameras.keys()) + + def get_camera_count(self) -> int: + """Get the number of cameras. + + Returns: + Number of cameras in the system. + """ + return len(self._cameras) + + # ═══════════════════════════════════════════════════════════════════════════ + # INITIALIZATION AND LIFECYCLE + # ═══════════════════════════════════════════════════════════════════════════ + def _prepare_for_use(self, skip_init: bool = False): self.low_level_drivers.prepare_for_use(skip_init=skip_init) self.addons.prepare_for_use(skip_init=skip_init) - self.camera.set_pixel_format( - squid.config.CameraPixelFormat.from_string(control._def.CAMERA_CONFIG.PIXEL_FORMAT_DEFAULT) + # Initialize all cameras with default settings + default_pixel_format = squid.config.CameraPixelFormat.from_string( + control._def.CAMERA_CONFIG.PIXEL_FORMAT_DEFAULT ) - self.camera.set_acquisition_mode(CameraAcquisitionMode.SOFTWARE_TRIGGER) + for camera_id, camera in self._cameras.items(): + self._log.debug(f"Initializing camera {camera_id}") + camera.set_pixel_format(default_pixel_format) + camera.set_acquisition_mode(CameraAcquisitionMode.SOFTWARE_TRIGGER) if self.addons.camera_focus: self.addons.camera_focus.set_pixel_format(squid.config.CameraPixelFormat.from_string("MONO8")) @@ -711,10 +816,17 @@ def close(self) -> None: except Exception as e: self._log.warning(f"Error closing focus camera: {e}") - try: - self.camera.close() - except Exception as e: - self._log.warning(f"Error closing camera: {e}") + # Close all cameras + for camera_id, camera in self._cameras.items(): + try: + # Stop streaming first (some cameras require this before close) + try: + camera.stop_streaming() + except Exception as stream_e: + self._log.debug(f"stop_streaming for camera {camera_id}: {stream_e}") + camera.close() + except Exception as e: + self._log.warning(f"Error closing camera {camera_id}: {e}") def move_to_position(self, x: float, y: float, z: float) -> None: """Move the stage to an absolute XYZ position. diff --git a/software/control/models/acquisition_config.py b/software/control/models/acquisition_config.py index 6ca7549c2..cfdacd472 100644 --- a/software/control/models/acquisition_config.py +++ b/software/control/models/acquisition_config.py @@ -535,6 +535,12 @@ def validate_channel_group( """ errors = [] + # Check for duplicate channel names in the group + channel_names = [entry.name for entry in group.channels] + if len(channel_names) != len(set(channel_names)): + duplicates = [name for name in set(channel_names) if channel_names.count(name) > 1] + errors.append(f"Group '{group.name}' has duplicate channels: {duplicates}") + # Track cameras used (v1.0: camera field is int ID) cameras_used: List[Optional[int]] = [] for entry in group.channels: diff --git a/software/control/widgets.py b/software/control/widgets.py index b8b107dbe..6a2ec3ff1 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -18,6 +18,16 @@ import squid.logging from control.core.config import ConfigRepository from control.core.core import TrackingController, LiveController +from control.models import ( + AcquisitionChannel, + CameraSettings, + ChannelGroup, + ChannelGroupEntry, + GeneralChannelConfig, + IlluminationSettings, + SynchronizationMode, + validate_channel_group, +) from control.core.multi_point_controller import MultiPointController from control.core.downsampled_views import format_well_id from control.core.geometry_utils import get_effective_well_size, calculate_well_coverage @@ -15438,6 +15448,42 @@ def _populate_filter_positions_for_combo( combo.setCurrentIndex(0) +def _populate_camera_combo( + combo: QComboBox, + config_repo, + current_camera_id: Optional[int] = None, + include_none: bool = True, +) -> None: + """Populate a camera combo box from the camera registry. + + Args: + combo: The QComboBox to populate + config_repo: ConfigRepository instance + current_camera_id: Camera ID to select (None for "(None)" option) + include_none: Whether to include "(None)" as first option + """ + combo.clear() + + if include_none: + combo.addItem("(None)", None) + + registry = config_repo.get_camera_registry() + if registry: + for cam in registry.cameras: + combo.addItem(cam.name, cam.id) + + # Set current selection + if current_camera_id is not None: + for i in range(combo.count()): + if combo.itemData(i) == current_camera_id: + combo.setCurrentIndex(i) + return + + # Default to first item (usually "(None)") + if combo.count() > 0: + combo.setCurrentIndex(0) + + class AcquisitionChannelConfiguratorDialog(QDialog): """Dialog for editing acquisition channel configurations. @@ -15601,7 +15647,6 @@ def _load_channels(self): def _populate_row(self, row: int, channel): """Populate a table row with channel data.""" - from control.models import AcquisitionChannel # Enabled checkbox checkbox_widget = QWidget() @@ -15629,13 +15674,9 @@ def _populate_row(self, row: int, channel): illum_combo.setCurrentText(current_illum) self.table.setCellWidget(row, self.COL_ILLUMINATION, illum_combo) - # Camera dropdown + # Camera dropdown - stores camera ID (int) as userData, displays name camera_combo = QComboBox() - camera_combo.addItem("(None)") - camera_names = self.config_repo.get_camera_names() - camera_combo.addItems(camera_names) - if channel.camera and channel.camera in camera_names: - camera_combo.setCurrentText(channel.camera) + _populate_camera_combo(camera_combo, self.config_repo, channel.camera) self.table.setCellWidget(row, self.COL_CAMERA, camera_combo) # Filter wheel dropdown @@ -15785,7 +15826,6 @@ def _save_changes(self): def _export_config(self): """Export current channel configuration to a YAML file.""" - from control.models import GeneralChannelConfig import yaml # Get save file path @@ -15821,7 +15861,6 @@ def _export_config(self): def _import_config(self): """Import channel configuration from a YAML file.""" from pydantic import ValidationError - from control.models import GeneralChannelConfig import yaml # Get file path @@ -15892,11 +15931,10 @@ def _sync_table_to_config(self): if illum_combo and isinstance(illum_combo, QComboBox): channel.illumination_settings.illumination_channel = illum_combo.currentText() - # Camera + # Camera - extract camera ID from userData (int or None) camera_combo = self.table.cellWidget(row, self.COL_CAMERA) if camera_combo and isinstance(camera_combo, QComboBox): - camera_text = camera_combo.currentText() - channel.camera = camera_text if camera_text != "(None)" else None + channel.camera = camera_combo.currentData() # Returns int or None # Filter wheel: None = no selection, else explicit wheel name wheel_combo = self.table.cellWidget(row, self.COL_FILTER_WHEEL) @@ -15940,11 +15978,12 @@ def _setup_ui(self): layout.addRow("Illumination:", self.illumination_combo) # Camera dropdown (hidden if single camera - 0 or 1 cameras) - camera_names = self.config_repo.get_camera_names() - if len(camera_names) > 1: + # Stores camera ID (int) as userData, displays name + registry = self.config_repo.get_camera_registry() + camera_count = len(registry.cameras) if registry else 0 + if camera_count > 1: self.camera_combo = QComboBox() - self.camera_combo.addItem("(None)") - self.camera_combo.addItems(camera_names) + _populate_camera_combo(self.camera_combo, self.config_repo) layout.addRow("Camera:", self.camera_combo) else: self.camera_combo = None @@ -16020,20 +16059,13 @@ def _validate_and_accept(self): def get_channel(self): """Build AcquisitionChannel from dialog inputs.""" - from control.models import ( - AcquisitionChannel, - CameraSettings, - IlluminationSettings, - ) - name = self.name_edit.text().strip() illum_name = self.illumination_combo.currentText() - # Camera + # Camera - extract camera ID from userData (int or None) camera = None if self.camera_combo: - camera_text = self.camera_combo.currentText() - camera = camera_text if camera_text != "(None)" else None + camera = self.camera_combo.currentData() # Returns int or None # Filter wheel and position filter_wheel = None @@ -16262,6 +16294,674 @@ def _save_config(self): QMessageBox.critical(self, "Error", f"Failed to save configuration:\n{e}") +# ═══════════════════════════════════════════════════════════════════════════════ +# CHANNEL GROUP CONFIGURATION UI +# ═══════════════════════════════════════════════════════════════════════════════ + + +class ChannelPickerDialog(QDialog): + """Dialog for selecting channels to add to a group.""" + + def __init__(self, available_channels: list, existing_names: list, parent=None): + """ + Args: + available_channels: List of channel names that can be selected. + existing_names: List of channel names already in the group (shown but disabled). + parent: Parent widget. + """ + super().__init__(parent) + self.setWindowTitle("Select Channels") + self.setMinimumWidth(300) + + self._available_channels = available_channels + self._existing_names = set(existing_names) + self._selected_names = [] + + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + instructions = QLabel("Select channels to add to the group:") + layout.addWidget(instructions) + + # Checkboxes for each channel + self._checkboxes = [] + for name in self._available_channels: + cb = QCheckBox(name) + if name in self._existing_names: + cb.setChecked(True) + cb.setEnabled(False) + cb.setToolTip("Already in group") + self._checkboxes.append(cb) + layout.addWidget(cb) + + # Buttons + button_layout = QHBoxLayout() + button_layout.addStretch() + + btn_ok = QPushButton("Add Selected") + btn_ok.clicked.connect(self._on_accept) + button_layout.addWidget(btn_ok) + + btn_cancel = QPushButton("Cancel") + btn_cancel.clicked.connect(self.reject) + button_layout.addWidget(btn_cancel) + + layout.addLayout(button_layout) + + def _on_accept(self): + """Collect selected channels and accept.""" + self._selected_names = [] + for cb in self._checkboxes: + if cb.isChecked() and cb.isEnabled(): + self._selected_names.append(cb.text()) + self.accept() + + def get_selected_channels(self) -> list: + """Get list of newly selected channel names (excludes existing).""" + return self._selected_names + + +class AddChannelGroupDialog(QDialog): + """Dialog for creating a new channel group.""" + + def __init__(self, existing_names: list, parent=None): + """ + Args: + existing_names: List of existing group names (for validation). + parent: Parent widget. + """ + super().__init__(parent) + self.setWindowTitle("Add Channel Group") + self.setMinimumWidth(350) + + self._existing_names = set(existing_names) + self._setup_ui() + + def _setup_ui(self): + layout = QFormLayout(self) + + # Group name + self.name_edit = QLineEdit() + self.name_edit.setPlaceholderText("e.g., BF + GFP Simultaneous") + layout.addRow("Group Name:", self.name_edit) + + # Synchronization mode + self.sync_combo = QComboBox() + self.sync_combo.addItem("Sequential (one channel at a time)", "sequential") + self.sync_combo.addItem("Simultaneous (multi-camera)", "simultaneous") + layout.addRow("Mode:", self.sync_combo) + + # Buttons + button_layout = QHBoxLayout() + button_layout.addStretch() + + btn_ok = QPushButton("Create") + btn_ok.clicked.connect(self._validate_and_accept) + button_layout.addWidget(btn_ok) + + btn_cancel = QPushButton("Cancel") + btn_cancel.clicked.connect(self.reject) + button_layout.addWidget(btn_cancel) + + layout.addRow(button_layout) + + def _validate_and_accept(self): + """Validate input before accepting.""" + name = self.name_edit.text().strip() + if not name: + QMessageBox.warning(self, "Validation Error", "Group name cannot be empty.") + return + + if name in self._existing_names: + QMessageBox.warning(self, "Validation Error", f"Group '{name}' already exists.") + return + + self.accept() + + def get_group_name(self) -> str: + """Get the entered group name.""" + return self.name_edit.text().strip() + + def get_sync_mode(self) -> str: + """Get the selected synchronization mode ('sequential' or 'simultaneous').""" + return self.sync_combo.currentData() + + +class ChannelGroupDetailWidget(QWidget): + """Widget for editing a single channel group's details.""" + + signal_modified = Signal() # Emitted when group is modified + + def __init__(self, config_repo, parent=None): + super().__init__(parent) + self._log = squid.logging.get_logger(self.__class__.__name__) + self.config_repo = config_repo + self._current_group = None + self._channels = [] # Available AcquisitionChannel objects + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Empty state label (shown when no group selected) + self.empty_label = QLabel("Select a group from the list\nor create a new one.") + self.empty_label.setAlignment(Qt.AlignCenter) + self.empty_label.setStyleSheet("color: #888; font-style: italic;") + layout.addWidget(self.empty_label) + + # Group detail container (hidden when no group selected) + self.detail_container = QWidget() + detail_layout = QVBoxLayout(self.detail_container) + detail_layout.setContentsMargins(0, 0, 0, 0) + + # Group name (read-only for now) + name_layout = QHBoxLayout() + name_layout.addWidget(QLabel("Group:")) + self.name_label = QLabel() + self.name_label.setStyleSheet("font-weight: bold;") + name_layout.addWidget(self.name_label) + name_layout.addStretch() + detail_layout.addLayout(name_layout) + + # Synchronization mode + sync_layout = QHBoxLayout() + sync_layout.addWidget(QLabel("Mode:")) + self.sync_combo = QComboBox() + self.sync_combo.addItem("Sequential", "sequential") + self.sync_combo.addItem("Simultaneous", "simultaneous") + self.sync_combo.currentIndexChanged.connect(self._on_sync_changed) + sync_layout.addWidget(self.sync_combo) + sync_layout.addStretch() + detail_layout.addLayout(sync_layout) + + # Channels table + detail_layout.addWidget(QLabel("Channels:")) + self.table = QTableWidget() + self.table.setColumnCount(4) + self.table.setHorizontalHeaderLabels(["Channel", "Camera", "Offset (μs)", ""]) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Fixed) + self.table.setColumnWidth(3, 30) # Remove button column + self.table.setSelectionBehavior(QTableWidget.SelectRows) + self.table.setSelectionMode(QTableWidget.SingleSelection) + detail_layout.addWidget(self.table) + + # Channel buttons + chan_btn_layout = QHBoxLayout() + self.btn_add_channel = QPushButton("Add Channel") + self.btn_add_channel.clicked.connect(self._add_channel) + chan_btn_layout.addWidget(self.btn_add_channel) + + self.btn_move_up = QPushButton("▲") + self.btn_move_up.setFixedWidth(30) + self.btn_move_up.clicked.connect(self._move_channel_up) + chan_btn_layout.addWidget(self.btn_move_up) + + self.btn_move_down = QPushButton("▼") + self.btn_move_down.setFixedWidth(30) + self.btn_move_down.clicked.connect(self._move_channel_down) + chan_btn_layout.addWidget(self.btn_move_down) + + chan_btn_layout.addStretch() + detail_layout.addLayout(chan_btn_layout) + + # Validation warnings + self.warning_label = QLabel() + self.warning_label.setStyleSheet("color: #c00; font-size: 11px;") + self.warning_label.setWordWrap(True) + self.warning_label.setVisible(False) + detail_layout.addWidget(self.warning_label) + + layout.addWidget(self.detail_container) + self.detail_container.setVisible(False) + + def set_channels(self, channels: list): + """Set available channels for selection.""" + self._channels = channels + + def load_group(self, group): + """Load a channel group for editing.""" + self._current_group = group + + if group is None: + self.empty_label.setVisible(True) + self.detail_container.setVisible(False) + return + + self.empty_label.setVisible(False) + self.detail_container.setVisible(True) + + # Set name + self.name_label.setText(group.name) + + # Set sync mode + index = 0 if group.synchronization.value == "sequential" else 1 + self.sync_combo.blockSignals(True) + self.sync_combo.setCurrentIndex(index) + self.sync_combo.blockSignals(False) + + # Load channels into table + self._populate_table() + self._validate() + + def _populate_table(self): + """Populate the channels table.""" + # Disconnect signals from existing cell widgets before clearing + # This prevents stale lambda callbacks with invalid row indices + for row in range(self.table.rowCount()): + offset_widget = self.table.cellWidget(row, 2) + if offset_widget is not None: + try: + offset_widget.valueChanged.disconnect() + except (TypeError, RuntimeError): + # TypeError: No connections to disconnect + # RuntimeError: Widget was already deleted + pass + remove_widget = self.table.cellWidget(row, 3) + if remove_widget is not None: + try: + remove_widget.clicked.disconnect() + except (TypeError, RuntimeError): + # TypeError: No connections to disconnect + # RuntimeError: Widget was already deleted + pass + + self.table.setRowCount(0) + + if self._current_group is None: + return + + for entry in self._current_group.channels: + row = self.table.rowCount() + self.table.insertRow(row) + + # Channel name (read-only) + name_item = QTableWidgetItem(entry.name) + name_item.setFlags(name_item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(row, 0, name_item) + + # Camera (look up from channels list) + camera_text = "(None)" + for ch in self._channels: + if ch.name == entry.name: + if ch.camera is not None: + registry = self.config_repo.get_camera_registry() + if registry: + cam_def = registry.get_camera_by_id(ch.camera) + if cam_def: + camera_text = cam_def.name + break + camera_item = QTableWidgetItem(camera_text) + camera_item.setFlags(camera_item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(row, 1, camera_item) + + # Offset (editable spinbox) + offset_spin = QDoubleSpinBox() + offset_spin.setRange(0, 1000000) # Up to 1 second + offset_spin.setDecimals(1) + offset_spin.setSuffix(" μs") + offset_spin.setValue(entry.offset_us) + offset_spin.valueChanged.connect(lambda v, r=row: self._on_offset_changed(r, v)) + self.table.setCellWidget(row, 2, offset_spin) + + # Remove button + remove_btn = QPushButton("✕") + remove_btn.setFixedSize(24, 24) + remove_btn.setStyleSheet("border: none; color: #888;") + remove_btn.clicked.connect(lambda checked, r=row: self._remove_channel(r)) + self.table.setCellWidget(row, 3, remove_btn) + + # Update offset column visibility based on mode + is_simultaneous = self.sync_combo.currentData() == "simultaneous" + self.table.setColumnHidden(2, not is_simultaneous) + + def _on_sync_changed(self, index): + """Handle synchronization mode change.""" + if self._current_group is None: + return + + mode = SynchronizationMode.SEQUENTIAL if index == 0 else SynchronizationMode.SIMULTANEOUS + self._current_group.synchronization = mode + + # Show/hide offset column + self.table.setColumnHidden(2, index == 0) + + self._validate() + self.signal_modified.emit() + + def _on_offset_changed(self, row: int, value: float): + """Handle offset value change.""" + if self._current_group is None or row >= len(self._current_group.channels): + return + + self._current_group.channels[row].offset_us = value + self.signal_modified.emit() + + def _add_channel(self): + """Add channels to the group.""" + if self._current_group is None: + return + + available_names = [ch.name for ch in self._channels] + existing_names = [entry.name for entry in self._current_group.channels] + + dialog = ChannelPickerDialog(available_names, existing_names, self) + if dialog.exec_() == QDialog.Accepted: + selected = dialog.get_selected_channels() + for name in selected: + self._current_group.channels.append(ChannelGroupEntry(name=name, offset_us=0.0)) + self._populate_table() + self._validate() + self.signal_modified.emit() + + def _remove_channel(self, row: int): + """Remove a channel from the group.""" + if self._current_group is None or row >= len(self._current_group.channels): + return + + # Don't allow removing the last channel + if len(self._current_group.channels) <= 1: + QMessageBox.warning(self, "Cannot Remove", "A group must have at least one channel.") + return + + del self._current_group.channels[row] + self._populate_table() + self._validate() + self.signal_modified.emit() + + def _move_channel_up(self): + """Move selected channel up in the list.""" + row = self.table.currentRow() + if row <= 0 or self._current_group is None: + return + + channels = self._current_group.channels + channels[row], channels[row - 1] = channels[row - 1], channels[row] + self._populate_table() + self.table.selectRow(row - 1) + self.signal_modified.emit() + + def _move_channel_down(self): + """Move selected channel down in the list.""" + row = self.table.currentRow() + if self._current_group is None or row < 0 or row >= len(self._current_group.channels) - 1: + return + + channels = self._current_group.channels + channels[row], channels[row + 1] = channels[row + 1], channels[row] + self._populate_table() + self.table.selectRow(row + 1) + self.signal_modified.emit() + + def _validate(self): + """Validate the current group and show warnings.""" + if self._current_group is None: + self.warning_label.setVisible(False) + return + + errors = validate_channel_group(self._current_group, self._channels) + if errors: + self.warning_label.setText("\n".join(errors)) + self.warning_label.setVisible(True) + else: + self.warning_label.setVisible(False) + + def get_validation_errors(self) -> list: + """Get validation errors for the current group.""" + if self._current_group is None: + return [] + + return validate_channel_group(self._current_group, self._channels) + + +class ChannelGroupEditorDialog(QDialog): + """Master-detail dialog for editing channel groups. + + Edits user_profiles/{profile}/channel_configs/general.yaml (channel_groups field). + """ + + signal_config_updated = Signal() + + def __init__(self, config_repo, parent=None): + super().__init__(parent) + self._log = squid.logging.get_logger(self.__class__.__name__) + self.config_repo = config_repo + self.general_config = None + self._is_dirty = False # Track unsaved changes + + self.setWindowTitle("Channel Group Configuration") + self.setMinimumSize(700, 500) + + self._setup_ui() + self._load_config() + + def _setup_ui(self): + layout = QHBoxLayout(self) + + # Left panel: group list + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + left_layout.setContentsMargins(0, 0, 10, 0) + + left_layout.addWidget(QLabel("Channel Groups:")) + + self.group_list = QListWidget() + self.group_list.setMinimumWidth(180) + self.group_list.currentRowChanged.connect(self._on_group_selected) + left_layout.addWidget(self.group_list) + + # Group list buttons + list_btn_layout = QHBoxLayout() + self.btn_add_group = QPushButton("Add") + self.btn_add_group.clicked.connect(self._add_group) + list_btn_layout.addWidget(self.btn_add_group) + + self.btn_remove_group = QPushButton("Remove") + self.btn_remove_group.clicked.connect(self._remove_group) + list_btn_layout.addWidget(self.btn_remove_group) + + left_layout.addLayout(list_btn_layout) + + layout.addWidget(left_panel) + + # Right panel: group detail + self.detail_widget = ChannelGroupDetailWidget(self.config_repo, self) + self.detail_widget.signal_modified.connect(self._on_modified) + layout.addWidget(self.detail_widget, 1) + + # Bottom buttons (spans both panels) + main_layout = QVBoxLayout() + main_layout.addLayout(layout) + + button_layout = QHBoxLayout() + button_layout.addStretch() + + self.btn_save = QPushButton("Save") + self.btn_save.clicked.connect(self._save_config) + button_layout.addWidget(self.btn_save) + + self.btn_cancel = QPushButton("Cancel") + self.btn_cancel.clicked.connect(self.reject) + button_layout.addWidget(self.btn_cancel) + + main_layout.addLayout(button_layout) + + # Replace the main layout + central_widget = QWidget() + central_widget.setLayout(main_layout) + self.setLayout(QVBoxLayout()) + self.layout().setContentsMargins(10, 10, 10, 10) + self.layout().addWidget(central_widget) + + def _load_config(self): + """Load channel configuration.""" + original_config = self.config_repo.get_general_config() + + if original_config is None: + self.general_config = None + else: + # Work on a deep copy to avoid mutating the original until save + self.general_config = original_config.model_copy(deep=True) + + self._is_dirty = False + + if self.general_config is None: + QMessageBox.warning( + self, + "No Configuration", + "No channel configuration found. Please ensure a profile is loaded.", + ) + return + + # Pass available channels to detail widget + self.detail_widget.set_channels(self.general_config.channels) + + # Populate group list + self.group_list.clear() + for group in self.general_config.channel_groups: + self.group_list.addItem(group.name) + + # Select first group if any + if self.group_list.count() > 0: + self.group_list.setCurrentRow(0) + + def _on_group_selected(self, row: int): + """Handle group selection change.""" + if self.general_config is None or row < 0: + self.detail_widget.load_group(None) + return + + if row < len(self.general_config.channel_groups): + self.detail_widget.load_group(self.general_config.channel_groups[row]) + else: + self.detail_widget.load_group(None) + + def _on_modified(self): + """Handle modification in detail widget.""" + self._is_dirty = True + + # Update list item text if name changed (future: allow name editing) + row = self.group_list.currentRow() + if row >= 0 and self.general_config and row < len(self.general_config.channel_groups): + group = self.general_config.channel_groups[row] + item = self.group_list.item(row) + if item and item.text() != group.name: + item.setText(group.name) + + def _add_group(self): + """Add a new channel group.""" + if self.general_config is None: + return + + existing_names = [g.name for g in self.general_config.channel_groups] + dialog = AddChannelGroupDialog(existing_names, self) + + if dialog.exec_() == QDialog.Accepted: + name = dialog.get_group_name() + mode_str = dialog.get_sync_mode() + mode = SynchronizationMode.SEQUENTIAL if mode_str == "sequential" else SynchronizationMode.SIMULTANEOUS + + # Create group with first available channel as default + default_channel = self.general_config.channels[0].name if self.general_config.channels else "Channel" + new_group = ChannelGroup( + name=name, + synchronization=mode, + channels=[ChannelGroupEntry(name=default_channel)], + ) + + self.general_config.channel_groups.append(new_group) + self.group_list.addItem(name) + self.group_list.setCurrentRow(self.group_list.count() - 1) + self._is_dirty = True + + def _remove_group(self): + """Remove selected channel group.""" + row = self.group_list.currentRow() + if row < 0 or self.general_config is None: + return + + group_name = self.group_list.item(row).text() + reply = QMessageBox.question( + self, + "Confirm Removal", + f"Remove channel group '{group_name}'?", + QMessageBox.Yes | QMessageBox.No, + ) + + if reply == QMessageBox.Yes: + del self.general_config.channel_groups[row] + self.group_list.takeItem(row) + self._is_dirty = True + + # Select another group if available + if self.group_list.count() > 0: + self.group_list.setCurrentRow(min(row, self.group_list.count() - 1)) + else: + self.detail_widget.load_group(None) + + def closeEvent(self, event): + """Handle dialog close - warn about unsaved changes.""" + if self._is_dirty: + reply = QMessageBox.question( + self, + "Unsaved Changes", + "You have unsaved changes. Discard them?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply == QMessageBox.No: + event.ignore() + return + super().closeEvent(event) + + def _save_config(self): + """Save channel group configuration.""" + if self.general_config is None: + return + + # Validate all groups before saving + all_errors = [] + for group in self.general_config.channel_groups: + errors = validate_channel_group(group, self.general_config.channels) + if errors: + all_errors.extend([f"Group '{group.name}': {e}" for e in errors]) + + if all_errors: + reply = QMessageBox.warning( + self, + "Validation Warnings", + "The following issues were found:\n\n" + "\n".join(all_errors) + "\n\nSave anyway?", + QMessageBox.Yes | QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + + try: + profile = self.config_repo.current_profile + if not profile: + QMessageBox.critical(self, "Error", "No profile loaded.") + return + self.config_repo.save_general_config(profile, self.general_config) + self._is_dirty = False # Reset dirty flag before close + self.signal_config_updated.emit() + QMessageBox.information(self, "Saved", "Channel group configuration saved.") + self.accept() + except (PermissionError, OSError) as e: + self._log.error(f"Failed to save channel group config: {e}") + QMessageBox.critical(self, "Error", f"Cannot write configuration file:\n{e}") + except yaml.YAMLError as e: + self._log.error(f"Failed to serialize channel group config: {e}") + QMessageBox.critical(self, "Error", f"Configuration data could not be serialized:\n{e}") + except Exception as e: + self._log.exception(f"Unexpected error saving channel group config: {e}") + QMessageBox.critical(self, "Error", f"Failed to save configuration:\n{e}") + + class _QtLogSignalHolder(QObject): """QObject that holds the signal for QtLoggingHandler. diff --git a/software/squid/camera/config_factory.py b/software/squid/camera/config_factory.py new file mode 100644 index 000000000..63e9419b9 --- /dev/null +++ b/software/squid/camera/config_factory.py @@ -0,0 +1,94 @@ +""" +Camera configuration factory for multi-camera support. + +This module generates per-camera CameraConfig instances from the camera registry, +allowing each camera to have its own configuration while sharing common settings. +""" + +import logging +from typing import Dict, Optional + +from control.models import CameraRegistryConfig, CameraDefinition +from squid.config import CameraConfig + +logger = logging.getLogger(__name__) + +# Default camera ID for single-camera systems or fallback scenarios. +# This is used when no camera registry is configured, maintaining backward +# compatibility with single-camera workflows. +DEFAULT_SINGLE_CAMERA_ID = 1 + + +def create_camera_configs( + camera_registry: Optional[CameraRegistryConfig], + base_config: CameraConfig, +) -> Dict[int, CameraConfig]: + """Generate per-camera configs from registry + base template. + + For each camera in the registry, creates a copy of the base config with + the camera's serial number. This allows each camera to have its own + configuration while sharing common settings. + + Args: + camera_registry: Camera registry configuration (from cameras.yaml). + If None or empty, returns a single camera config with ID 1. + base_config: Base camera configuration template (from _def.py). + + Returns: + Dict mapping camera ID to CameraConfig. + For single camera systems: {1: config} + For multi-camera systems: {cam.id: config for each camera} + """ + if not camera_registry or not camera_registry.cameras: + # Single-camera system: return base config with default ID + logger.debug(f"No camera registry, using single camera with ID {DEFAULT_SINGLE_CAMERA_ID}") + return {DEFAULT_SINGLE_CAMERA_ID: base_config} + + configs: Dict[int, CameraConfig] = {} + + for camera_def in camera_registry.cameras: + # Create a copy of the base config for this camera + cam_config = base_config.model_copy(deep=True) + + # Override serial number from registry + cam_config.serial_number = camera_def.serial_number + + # Use camera ID from registry (guaranteed to be set for multi-camera) + camera_id = camera_def.id + if camera_id is None: + # Shouldn't happen due to registry validation, but handle gracefully + logger.warning( + f"Camera '{camera_def.name}' has no ID, skipping. " "This indicates a registry validation bug." + ) + continue + + configs[camera_id] = cam_config + logger.debug(f"Created config for camera {camera_id} ('{camera_def.name}', " f"SN: {camera_def.serial_number})") + + if not configs: + # Fallback if all cameras were skipped + logger.warning(f"No valid camera configs created, using base config with ID {DEFAULT_SINGLE_CAMERA_ID}") + return {DEFAULT_SINGLE_CAMERA_ID: base_config} + + logger.info(f"Created {len(configs)} camera configurations: IDs {sorted(configs.keys())}") + return configs + + +def get_primary_camera_id(camera_ids: list[int]) -> int: + """Get the primary camera ID from a list of camera IDs. + + The primary camera is the one with the lowest ID, which is used for + backward compatibility with single-camera code paths. + + Args: + camera_ids: List of camera IDs. + + Returns: + The lowest camera ID. + + Raises: + ValueError: If camera_ids is empty. + """ + if not camera_ids: + raise ValueError("No camera IDs provided") + return min(camera_ids) diff --git a/software/tests/control/test_microscope_cameras.py b/software/tests/control/test_microscope_cameras.py new file mode 100644 index 000000000..7a5b06eaf --- /dev/null +++ b/software/tests/control/test_microscope_cameras.py @@ -0,0 +1,485 @@ +"""Tests for multi-camera support in Microscope class.""" + +import pytest +from unittest.mock import MagicMock + +from control.models import CameraRegistryConfig, CameraDefinition +from squid.camera.config_factory import create_camera_configs, get_primary_camera_id +from squid.config import CameraConfig, CameraVariant, CameraPixelFormat + + +@pytest.fixture +def base_camera_config(): + """Minimal camera config for testing.""" + return CameraConfig( + camera_type=CameraVariant.TOUPCAM, + default_pixel_format=CameraPixelFormat.MONO16, + ) + + +class TestCameraConfigFactory: + """Tests for create_camera_configs().""" + + def test_no_registry_returns_single_camera(self, base_camera_config): + """When no registry exists, return single camera with ID 1.""" + configs = create_camera_configs(None, base_camera_config) + assert list(configs.keys()) == [1] + assert configs[1] == base_camera_config + + def test_empty_registry_returns_single_camera(self, base_camera_config): + """When registry has no cameras, return single camera with ID 1.""" + registry = CameraRegistryConfig(cameras=[]) + configs = create_camera_configs(registry, base_camera_config) + assert list(configs.keys()) == [1] + + def test_single_camera_registry(self, base_camera_config): + """Single camera in registry gets default ID 1 and default name.""" + registry = CameraRegistryConfig( + cameras=[ + CameraDefinition(serial_number="SN001"), # ID and name will default + ] + ) + configs = create_camera_configs(registry, base_camera_config) + assert list(configs.keys()) == [1] + assert configs[1].serial_number == "SN001" + + def test_multi_camera_registry(self, base_camera_config): + """Multiple cameras in registry each get their own config.""" + registry = CameraRegistryConfig( + cameras=[ + CameraDefinition(id=1, name="Main", serial_number="SN001"), + CameraDefinition(id=2, name="Side", serial_number="SN002"), + ] + ) + configs = create_camera_configs(registry, base_camera_config) + assert sorted(configs.keys()) == [1, 2] + assert configs[1].serial_number == "SN001" + assert configs[2].serial_number == "SN002" + + def test_camera_ids_not_sequential(self, base_camera_config): + """Camera IDs don't have to be sequential.""" + registry = CameraRegistryConfig( + cameras=[ + CameraDefinition(id=5, name="A", serial_number="SN005"), + CameraDefinition(id=10, name="B", serial_number="SN010"), + ] + ) + configs = create_camera_configs(registry, base_camera_config) + assert sorted(configs.keys()) == [5, 10] + + def test_base_config_is_copied(self, base_camera_config): + """Each camera gets a deep copy of base config.""" + registry = CameraRegistryConfig( + cameras=[ + CameraDefinition(id=1, name="A", serial_number="SN001"), + CameraDefinition(id=2, name="B", serial_number="SN002"), + ] + ) + configs = create_camera_configs(registry, base_camera_config) + + # Verify they're different objects + assert configs[1] is not configs[2] + assert configs[1] is not base_camera_config + + # Verify serial numbers are different + assert configs[1].serial_number == "SN001" + assert configs[2].serial_number == "SN002" + + # Verify other properties are copied from base + assert configs[1].camera_type == base_camera_config.camera_type + assert configs[2].camera_type == base_camera_config.camera_type + + +class TestGetPrimaryCameraId: + """Tests for get_primary_camera_id().""" + + def test_single_camera(self): + """Single camera returns its ID.""" + assert get_primary_camera_id([1]) == 1 + assert get_primary_camera_id([5]) == 5 + + def test_multiple_cameras_returns_lowest(self): + """Multiple cameras returns lowest ID.""" + assert get_primary_camera_id([3, 1, 2]) == 1 + assert get_primary_camera_id([10, 5, 20]) == 5 + + def test_empty_list_raises(self): + """Empty list raises ValueError.""" + with pytest.raises(ValueError, match="No camera IDs provided"): + get_primary_camera_id([]) + + +class TestMicroscopeCameraAPI: + """Tests for Microscope multi-camera API.""" + + def test_microscope_camera_property(self): + """microscope.camera returns primary camera for backward compatibility.""" + from control.microscope import Microscope + + # Create mock cameras + camera1 = MagicMock() + camera2 = MagicMock() + cameras = {1: camera1, 2: camera2} + + # Create minimal microscope (skip normal init) + microscope = object.__new__(Microscope) + microscope._cameras = cameras + microscope._primary_camera_id = 1 + + assert microscope.camera is camera1 + + def test_microscope_get_camera(self): + """microscope.get_camera() returns camera by ID.""" + from control.microscope import Microscope + + camera1 = MagicMock() + camera2 = MagicMock() + cameras = {1: camera1, 2: camera2} + + microscope = object.__new__(Microscope) + microscope._cameras = cameras + microscope._primary_camera_id = 1 + + assert microscope.get_camera(1) is camera1 + assert microscope.get_camera(2) is camera2 + + def test_microscope_get_camera_invalid_id(self): + """microscope.get_camera() raises for invalid ID.""" + from control.microscope import Microscope + + microscope = object.__new__(Microscope) + microscope._cameras = {1: MagicMock()} + microscope._primary_camera_id = 1 + + with pytest.raises(ValueError, match="Camera ID 99 not found"): + microscope.get_camera(99) + + def test_microscope_get_camera_ids(self): + """microscope.get_camera_ids() returns sorted IDs.""" + from control.microscope import Microscope + + microscope = object.__new__(Microscope) + microscope._cameras = {5: MagicMock(), 1: MagicMock(), 3: MagicMock()} + microscope._primary_camera_id = 1 + + assert microscope.get_camera_ids() == [1, 3, 5] + + def test_microscope_get_camera_count(self): + """microscope.get_camera_count() returns number of cameras.""" + from control.microscope import Microscope + + microscope = object.__new__(Microscope) + microscope._cameras = {1: MagicMock(), 2: MagicMock()} + microscope._primary_camera_id = 1 + + assert microscope.get_camera_count() == 2 + + def test_microscope_backward_compat_single_camera(self): + """Passing single camera wraps it in dict with ID 1.""" + from control.microscope import Microscope + + single_camera = MagicMock() + + microscope = object.__new__(Microscope) + microscope._log = MagicMock() + + # Simulate __init__ logic for camera handling + cameras = single_camera # Not a dict + if isinstance(cameras, dict): + microscope._cameras = cameras + else: + microscope._cameras = {1: cameras} + microscope._primary_camera_id = get_primary_camera_id(list(microscope._cameras.keys())) + + assert microscope._cameras == {1: single_camera} + assert microscope._primary_camera_id == 1 + assert microscope.camera is single_camera + + +class TestLiveControllerMultiCamera: + """Tests for LiveController multi-camera support.""" + + def _create_mock_microscope(self, cameras_dict): + """Create a mock microscope with given cameras.""" + from control.microscope import Microscope + + microscope = object.__new__(Microscope) + microscope._cameras = cameras_dict + microscope._primary_camera_id = min(cameras_dict.keys()) + microscope._log = MagicMock() + microscope.config_repo = MagicMock() + return microscope + + def _create_mock_channel(self, name="Channel", camera_id=None): + """Create a mock acquisition channel.""" + channel = MagicMock() + channel.name = name + channel.camera = camera_id + channel.exposure_time = 100 + channel.analog_gain = 1.0 + return channel + + def test_live_controller_tracks_active_camera(self): + """LiveController tracks active camera ID.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + camera2 = MagicMock() + microscope = self._create_mock_microscope({1: camera1, 2: camera2}) + + controller = LiveController(microscope, camera1, control_illumination=False) + + assert controller._active_camera_id == 1 + assert controller.get_active_camera_id() == 1 + assert controller.camera is camera1 + + def test_get_target_camera_id_returns_channel_camera(self): + """_get_target_camera_id returns channel's camera ID when specified.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + camera2 = MagicMock() + microscope = self._create_mock_microscope({1: camera1, 2: camera2}) + + controller = LiveController(microscope, camera1, control_illumination=False) + + channel = self._create_mock_channel(camera_id=2) + assert controller._get_target_camera_id(channel) == 2 + + def test_get_target_camera_id_returns_primary_for_none(self): + """_get_target_camera_id returns primary camera ID when channel.camera is None.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + camera2 = MagicMock() + microscope = self._create_mock_microscope({1: camera1, 2: camera2}) + + controller = LiveController(microscope, camera1, control_illumination=False) + + channel = self._create_mock_channel(camera_id=None) + assert controller._get_target_camera_id(channel) == 1 + + def test_switch_camera_updates_camera_reference(self): + """_switch_camera updates the camera reference.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + camera2 = MagicMock() + microscope = self._create_mock_microscope({1: camera1, 2: camera2}) + + controller = LiveController(microscope, camera1, control_illumination=False) + assert controller.camera is camera1 + + controller._switch_camera(2) + + assert controller.camera is camera2 + assert controller._active_camera_id == 2 + + def test_switch_camera_noop_when_same_camera(self): + """_switch_camera does nothing when switching to same camera.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + microscope = self._create_mock_microscope({1: camera1}) + + controller = LiveController(microscope, camera1, control_illumination=False) + + # This should be a no-op + controller._switch_camera(1) + + assert controller.camera is camera1 + assert controller._active_camera_id == 1 + + def test_switch_camera_raises_for_invalid_id(self): + """_switch_camera raises ValueError for invalid camera ID.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + microscope = self._create_mock_microscope({1: camera1}) + + controller = LiveController(microscope, camera1, control_illumination=False) + + with pytest.raises(ValueError, match="Camera ID 99 not found"): + controller._switch_camera(99) + + def test_set_microscope_mode_switches_camera(self): + """set_microscope_mode switches to channel's camera.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + camera2 = MagicMock() + microscope = self._create_mock_microscope({1: camera1, 2: camera2}) + + controller = LiveController(microscope, camera1, control_illumination=False) + controller.is_live = False # Not live, so no streaming operations + + channel = self._create_mock_channel(name="Camera2 Channel", camera_id=2) + controller.set_microscope_mode(channel) + + assert controller.camera is camera2 + assert controller._active_camera_id == 2 + camera2.set_exposure_time.assert_called_once_with(100) + + def test_set_microscope_mode_stays_on_same_camera(self): + """set_microscope_mode doesn't switch when channel uses same camera.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + camera2 = MagicMock() + microscope = self._create_mock_microscope({1: camera1, 2: camera2}) + + controller = LiveController(microscope, camera1, control_illumination=False) + controller.is_live = False + + # Channel uses camera 1 (same as current) + channel = self._create_mock_channel(name="Camera1 Channel", camera_id=1) + controller.set_microscope_mode(channel) + + assert controller.camera is camera1 + assert controller._active_camera_id == 1 + camera1.set_exposure_time.assert_called_once_with(100) + + def test_set_microscope_mode_uses_primary_for_none_camera(self): + """set_microscope_mode uses primary camera when channel.camera is None.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + camera2 = MagicMock() + microscope = self._create_mock_microscope({1: camera1, 2: camera2}) + + controller = LiveController(microscope, camera1, control_illumination=False) + controller.is_live = False + + # Channel has no camera specified + channel = self._create_mock_channel(camera_id=None) + controller.set_microscope_mode(channel) + + assert controller.camera is camera1 + assert controller._active_camera_id == 1 + + def test_set_microscope_mode_handles_streaming_on_camera_switch(self): + """set_microscope_mode stops/starts streaming when switching cameras while live.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + camera2 = MagicMock() + microscope = self._create_mock_microscope({1: camera1, 2: camera2}) + + controller = LiveController(microscope, camera1, control_illumination=False) + controller.is_live = True + controller.timer_trigger = None # No active timer + + channel = self._create_mock_channel(name="Camera2 Channel", camera_id=2) + controller.set_microscope_mode(channel) + + # Old camera streaming stopped + camera1.stop_streaming.assert_called_once() + # New camera streaming started + camera2.start_streaming.assert_called_once() + # New camera has exposure set + camera2.set_exposure_time.assert_called_once_with(100) + + def test_set_microscope_mode_invalid_camera_no_state_change(self): + """set_microscope_mode with invalid camera ID changes nothing.""" + from control.core.live_controller import LiveController + + camera1 = MagicMock() + microscope = self._create_mock_microscope({1: camera1}) + + controller = LiveController(microscope, camera1, control_illumination=False) + controller.is_live = False + + # Set an initial configuration + initial_channel = self._create_mock_channel(name="Initial", camera_id=1) + controller.set_microscope_mode(initial_channel) + camera1.reset_mock() + + # Try to switch to non-existent camera 99 + invalid_channel = self._create_mock_channel(name="Invalid Camera", camera_id=99) + controller.set_microscope_mode(invalid_channel) + + # State should be unchanged + assert controller.camera is camera1 + assert controller._active_camera_id == 1 + assert controller.currentConfiguration is initial_channel # Not changed + # Camera should not have been touched + camera1.set_exposure_time.assert_not_called() + camera1.stop_streaming.assert_not_called() + + +class TestChannelGroupValidation: + """Tests for validate_channel_group function.""" + + def test_duplicate_channel_names_detected(self): + """Duplicate channel names in a group are detected.""" + from control.models import ( + AcquisitionChannel, + CameraSettings, + ChannelGroup, + ChannelGroupEntry, + IlluminationSettings, + SynchronizationMode, + validate_channel_group, + ) + + # Create channels + channels = [ + AcquisitionChannel( + name="Channel A", + camera_settings=CameraSettings(exposure_time_ms=100, gain_mode=1.0), + illumination_settings=IlluminationSettings(illumination_channel="BF", intensity=50.0), + ), + ] + + # Create group with duplicate channel + group = ChannelGroup( + name="Test Group", + synchronization=SynchronizationMode.SEQUENTIAL, + channels=[ + ChannelGroupEntry(name="Channel A"), + ChannelGroupEntry(name="Channel A"), # Duplicate! + ], + ) + + errors = validate_channel_group(group, channels) + assert any("duplicate channels" in e.lower() for e in errors) + + def test_no_duplicate_channels_passes(self): + """Group without duplicate channels passes validation.""" + from control.models import ( + AcquisitionChannel, + CameraSettings, + ChannelGroup, + ChannelGroupEntry, + IlluminationSettings, + SynchronizationMode, + validate_channel_group, + ) + + # Create channels + channels = [ + AcquisitionChannel( + name="Channel A", + camera_settings=CameraSettings(exposure_time_ms=100, gain_mode=1.0), + illumination_settings=IlluminationSettings(illumination_channel="BF", intensity=50.0), + ), + AcquisitionChannel( + name="Channel B", + camera_settings=CameraSettings(exposure_time_ms=100, gain_mode=1.0), + illumination_settings=IlluminationSettings(illumination_channel="GFP", intensity=50.0), + ), + ] + + # Create group with unique channels + group = ChannelGroup( + name="Test Group", + synchronization=SynchronizationMode.SEQUENTIAL, + channels=[ + ChannelGroupEntry(name="Channel A"), + ChannelGroupEntry(name="Channel B"), + ], + ) + + errors = validate_channel_group(group, channels) + # Should not have duplicate channel error + assert not any("duplicate channels" in e.lower() for e in errors)