Skip to content

Conversation

@hongquanli
Copy link
Contributor

Summary

Phase 2 of multi-camera support implementation, building on the schema/models from Phase 1.

  • Camera config factory: Generate per-camera configs from registry + base template
  • Multi-camera Microscope: Store cameras as Dict[int, AbstractCamera] with backward compatibility
  • LiveController camera switching: Switch active camera based on channel.camera field
  • Channel Group UI: Master-detail editor dialog for configuring channel groups
  • Validation: Duplicate channel detection in channel groups

Key Implementation Details

LiveController Multi-Camera Switching

  • Tracks _active_camera_id for current camera
  • set_microscope_mode() checks channel.camera and switches if different
  • Race condition protection: Sets is_live=False during switch to prevent frame callback conflicts
  • Early validation: Checks target camera exists before any state changes (prevents stuck state)
  • Explicit initial_camera_id parameter removes fragile initialization order dependency

Microscope Changes

  • Cameras stored in Dict[int, AbstractCamera] with DEFAULT_SINGLE_CAMERA_ID = 1 constant
  • Backward compatible: single camera wrapped in dict automatically
  • get_camera(id), get_camera_ids(), get_camera_count() API
  • Cleanup: stop_streaming() before close() in Microscope.close()

Channel Group Editor UI

  • ChannelGroupEditorDialog: Master-detail dialog for managing groups
  • ChannelGroupDetailWidget: Edit sync mode, channels, timing offsets
  • Dirty state tracking with unsaved changes warning on close
  • Deep copy of config to prevent accidental mutation

Test Plan

  • 28 unit tests passing
  • Manual test: Run main_hcs.py --simulation
  • Manual test: Settings > Advanced > Channel Group Configuration
  • Manual test: Create group, add channels, save, verify YAML persists
  • Manual test: Camera selector shows camera column when 2+ cameras configured

🤖 Generated with Claude Code

hongquanli and others added 30 commits January 26, 2026 23:50
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
More accurate terminology - these ports can be used for any
TTL-controlled illumination source, not just lasers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- D3: 14 (561nm), D4: 13 (638nm) - consistent with code defaults
- Update channel assignments: 561nm → D3, 638nm → D4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…n 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements Phase 2 of multi-camera support (per-camera config generation, multi-camera storage in Microscope, and live camera switching in LiveController) and adds a new Channel Group configuration UI with validation—while also introducing a substantial multi-port illumination protocol + firmware/versioning update.

Changes:

  • Add multi-camera infrastructure: camera config factory, Microscope multi-camera API, and LiveController camera switching keyed by channel.camera.
  • Add Channel Group configuration UI (master-detail editor) and validation (including duplicate-channel detection).
  • Add multi-port illumination support across Python + firmware (new command IDs 34–39, SimSerial behavior, tests, and documentation).

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
software/control/_def.py Adds multi-port illumination command IDs and port/source mapping helpers.
software/control/core/live_controller.py Adds active camera tracking and camera switching during mode changes.
software/control/firmware_sim_serial.py Exposes multi-port illumination commands to firmware simulator wrapper.
software/control/gui_hcs.py Adds Channel Group Configuration entry to Advanced menu (multi-camera only).
software/control/lighting.py Adds multi-port illumination API to IlluminationController and port state tracking.
software/control/microcontroller.py Adds multi-port illumination commands, firmware version parsing, and SimSerial multi-port behavior.
software/control/microscope.py Stores cameras as dict + adds camera APIs; initializes/closes all cameras; wires LiveController initial camera ID.
software/control/models/acquisition_config.py Adds duplicate channel detection to validate_channel_group.
software/control/widgets.py Adds camera-ID-based camera combo population and Channel Group editor dialog UI.
software/docs/illumination-control.md Documents legacy vs multi-port illumination APIs and mapping details.
software/squid/camera/config_factory.py Adds per-camera CameraConfig factory and primary camera selection helper.
software/tests/control/test_microscope_cameras.py Adds unit tests for multi-camera config factory, Microscope camera API, LiveController switching, and group validation.
software/tests/test_multiport_illumination.py Adds core tests for multi-port illumination simulation and controller behavior.
software/tests/test_multiport_illumination_bugs.py Adds targeted tests for common multi-port illumination failure modes.
software/tests/test_multiport_illumination_edge_cases.py Adds edge case + integration-style tests for multi-port illumination and mappings.
software/tests/test_multiport_illumination_protocol.py Adds protocol byte agreement tests for multi-port illumination commands.
firmware/controller/src/commands/commands.cpp Registers multi-port illumination command callbacks.
firmware/controller/src/commands/light_commands.cpp Implements multi-port illumination command callbacks.
firmware/controller/src/commands/light_commands.h Declares multi-port illumination callbacks.
firmware/controller/src/constants.h Adds firmware version constants.
firmware/controller/src/constants_protocol.h Adds protocol constants for multi-port illumination commands.
firmware/controller/src/functions.cpp Adds per-port tracking updates and implements multi-port port control helpers.
firmware/controller/src/functions.h Declares multi-port illumination control functions and includes mapping util.
firmware/controller/src/globals.cpp Adds global arrays for per-port illumination state/intensity.
firmware/controller/src/globals.h Declares globals for multi-port illumination state/intensity.
firmware/controller/src/serial_communication.cpp Encodes firmware version in response byte 22.
firmware/controller/src/utils/illumination_mapping.h Adds pure mapping utilities for legacy source ↔ port index.
firmware/controller/src/utils/illumination_utils.h Adds pure utilities for intensity conversion, version encoding, mask helpers.
firmware/controller/test/test_command_layout/test_command_layout.cpp Adds tests for command/response byte layouts (including version byte).
firmware/controller/test/test_illumination_mapping/test_illumination_mapping.cpp Adds tests for legacy source ↔ port mapping correctness (incl D3/D4 swap).
firmware/controller/test/test_illumination_utils/test_illumination_utils.cpp Adds tests for intensity conversion, version encoding, and mask helpers.
firmware/controller/test/test_protocol/test_protocol.cpp Updates protocol tests to include new commands and W2 axis.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 270 to +275
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;
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Firmware legacy turn_off_illumination() only turns off the currently selected legacy source pin, but with multi-port illumination it’s now possible for other ports to remain ON (e.g., after SET_MULTI_PORT_MASK / TURN_ON_PORT). This conflicts with the Python SimSerial/tests which assume TURN_OFF_ILLUMINATION is a global shutdown. Consider making legacy TURN_OFF_ILLUMINATION call turn_off_all_ports() (or otherwise guarantee all ports are OFF) to avoid leaving illumination outputs active unintentionally and to keep firmware/simulation behavior consistent.

Copilot uses AI. Check for mistakes.
Comment on lines +878 to +882
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
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_multi_port_mask() doesn’t validate port_mask/on_mask bounds. Negative values or values > 0xFFFF will be silently truncated by the shifts/masks, potentially sending an unintended pattern (e.g., -1 becomes 0xFFFF). Add explicit validation (type, 0..0xFFFF) or clamp/mask inputs before logging/sending.

Copilot uses AI. Check for mistakes.
Comment on lines 60 to +69
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",
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces substantial multi-port illumination changes (new MCU commands 34–39, firmware version encoding, SimSerial behavior, extensive illumination tests/docs) that aren’t described in the PR title/description (which focuses on multi-camera + channel group UI). Please either update the PR description to cover the illumination/firmware scope, or split these illumination changes into a separate PR for clearer review and rollout.

Copilot uses AI. Check for mistakes.
Comment on lines +16744 to +16748
layout = QHBoxLayout(self)

# Left panel: group list
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChannelGroupEditorDialog._setup_ui() creates a QHBoxLayout with self as parent, then later adds that same layout into another layout and calls setLayout() again. In Qt, reparenting layouts like this triggers warnings and can prevent the bottom Save/Cancel buttons (or the whole dialog layout) from rendering correctly. Create the top-level layout only once (e.g., a single QVBoxLayout on self) and add the horizontal splitter layout as a child layout without giving it an initial parent.

Copilot uses AI. Check for mistakes.
Comment on lines +341 to +343
# Turn off all ports (matches firmware behavior)
for i in range(SimSerial.NUM_ILLUMINATION_PORTS):
self.port_is_on[i] = False
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SimSerial treats legacy TURN_OFF_ILLUMINATION as turning off all illumination ports and the comment says this matches firmware behavior, but the firmware turn_off_illumination() implementation only turns off the currently selected legacy source (D1-D5) and leaves any ports enabled via multi-port commands untouched. This makes simulation/tests disagree with real hardware behavior; either update firmware to make TURN_OFF_ILLUMINATION shut down all ports, or change SimSerial/tests to match the firmware semantics.

Suggested change
# Turn off all ports (matches firmware behavior)
for i in range(SimSerial.NUM_ILLUMINATION_PORTS):
self.port_is_on[i] = False
# Turn off only the currently selected legacy 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] = False

Copilot uses AI. Check for mistakes.
import logging
from typing import Dict, Optional

from control.models import CameraRegistryConfig, CameraDefinition
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'CameraDefinition' is not used.

Suggested change
from control.models import CameraRegistryConfig, CameraDefinition
from control.models import CameraRegistryConfig

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants