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/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/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/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/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