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..3a8fb2ea6 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,99 @@ 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 ********************************/ +/***************************************************************************************************/ + +// 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) +{ + 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; +} + +// 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) + 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; // Store unscaled for reference +} + +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..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 @@ -54,6 +55,16 @@ 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 +// 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) +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..81b85e737 100644 --- a/firmware/controller/src/globals.cpp +++ b/firmware/controller/src/globals.cpp @@ -131,8 +131,17 @@ 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; 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; 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 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(); } diff --git a/software/control/_def.py b/software/control/_def.py index d609c8353..3dad0aeff 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 @@ -855,6 +923,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/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/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} 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/lighting.py b/software/control/lighting.py index b365b0e9d..21fcf4cbc 100644 --- a/software/control/lighting.py +++ b/software/control/lighting.py @@ -2,12 +2,15 @@ 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 +# Number of illumination ports supported (matches firmware) +NUM_ILLUMINATION_PORTS = 16 + class LightSourceType(Enum): SquidLED = 0 @@ -30,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, @@ -40,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 @@ -76,6 +122,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 +263,113 @@ 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 _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. + + Args: + 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) + self.microcontroller.wait_till_operation_is_completed() + 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.) + """ + 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) + 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.) + """ + 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) + 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 + """ + 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) + 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 + + self._check_multi_port_support() + # 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._check_multi_port_support() + 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..10610d5b3 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 @@ -567,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 @@ -666,6 +752,149 @@ 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 + # 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. + + 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 + + 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)) + 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. + + 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.) + + 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 + cmd[2] = port_index + self.send_command(cmd) + + 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.) + + 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 + 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. + + 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 + 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)) + 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. + + 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) + + 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. + + 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 + 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 +1521,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 +1537,29 @@ 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. + + 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 @@ -1349,6 +1606,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 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/docs/illumination-control.md b/software/docs/illumination-control.md new file mode 100644 index 000000000..1576fe792 --- /dev/null +++ b/software/docs/illumination-control.md @@ -0,0 +1,188 @@ +# 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 | 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. + +## 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 | +| 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. 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) 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..edd0d9f88 --- /dev/null +++ b/software/tests/test_multiport_illumination_edge_cases.py @@ -0,0 +1,307 @@ +"""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_CODE +from control.microcontroller import Microcontroller, SimSerial +from control.lighting import IlluminationController + + +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._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 + + 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._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._def 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._def 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._def 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_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 + # Version should already be populated - no need to send a command first + assert mcu.firmware_version == (1, 0) + + 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) + # 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): + """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..3f6951f58 --- /dev/null +++ b/software/tests/test_multiport_illumination_protocol.py @@ -0,0 +1,622 @@ +"""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_CODE +from control.microcontroller import Microcontroller, SimSerial +from control.lighting import IlluminationController + + +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_rejected_by_mcu(self, mcu): + """Port index 16 is rejected by Microcontroller validation. + + Microcontroller validates port indices (0-15) before sending commands. + """ + 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.""" + # 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 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.""" + + @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 + assert isinstance(initial, tuple) and len(initial) == 2 + + # 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