diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py new file mode 100644 index 00000000000..747d9457b91 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py @@ -0,0 +1,5 @@ +from .chatterbox import NimbusChatterboxDriver +from .door import NimbusDoor +from .driver import NimbusDriver +from .nimbus import Nimbus +from .pip_backend import NimbusPIPBackend diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py new file mode 100644 index 00000000000..3dbcb5582ef --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py @@ -0,0 +1,69 @@ +"""NimbusChatterboxDriver: prints commands instead of sending them over TCP.""" + +from __future__ import annotations + +import logging +from typing import Optional + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.packets import Address + +from .door import NimbusDoor +from .driver import NimbusDriver + +logger = logging.getLogger(__name__) + + +class NimbusChatterboxDriver(NimbusDriver): + """Chatterbox driver for Nimbus. Simulates commands for testing without hardware. + + Inherits NimbusDriver but overrides setup/stop/send_command to skip TCP + and use canned addresses and responses instead. + """ + + def __init__(self, num_channels: int = 8): + # Pass dummy host — Socket is created but never opened + super().__init__(host="chatterbox", port=2000) + self._num_channels = num_channels + + async def setup(self, deck=None): + from .pip_backend import NimbusPIPBackend + + # Use canned addresses (skip TCP connection entirely) + pipette_address = Address(1, 1, 257) + self._nimbus_core_address = Address(1, 1, 48896) + door_address = Address(1, 1, 268) + + self.pip = NimbusPIPBackend( + driver=self, deck=deck, address=pipette_address, num_channels=self._num_channels + ) + self.door = NimbusDoor(driver=self, address=door_address) + + async def stop(self): + if self.door is not None: + await self.door._on_stop() + self.door = None + + async def send_command(self, command: HamiltonCommand, timeout: float = 10.0) -> Optional[dict]: + logger.info(f"[Chatterbox] {command.__class__.__name__}") + + # Return canned responses for commands that need them + from .commands import ( + GetChannelConfiguration, + GetChannelConfiguration_1, + IsDoorLocked, + IsInitialized, + IsTipPresent, + ) + + if isinstance(command, GetChannelConfiguration_1): + return {"channels": self._num_channels} + if isinstance(command, IsInitialized): + return {"initialized": True} + if isinstance(command, IsTipPresent): + return {"tip_present": [False] * self._num_channels} + if isinstance(command, IsDoorLocked): + return {"locked": True} + if isinstance(command, GetChannelConfiguration): + return {"enabled": [False]} + return None diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py new file mode 100644 index 00000000000..33273fa73ce --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py @@ -0,0 +1,869 @@ +"""Hamilton Nimbus command classes and supporting types. + +This module contains all Nimbus-specific Hamilton protocol commands, including +tip management, initialization, door control, ADC, aspirate, and dispense. +""" + +from __future__ import annotations + +import enum +import logging +from typing import List + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.messages import HoiParams, HoiParamsParser +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.resources import Tip +from pylabrobot.resources.hamilton import HamiltonTip, TipSize + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# TIP TYPE ENUM +# ============================================================================ + + +class NimbusTipType(enum.IntEnum): + """Hamilton Nimbus tip type enumeration. + + Maps tip type names to their integer values used in Hamilton protocol commands. + """ + + STANDARD_300UL = 0 # "300ul Standard Volume Tip" + STANDARD_300UL_FILTER = 1 # "300ul Standard Volume Tip with filter" + LOW_VOLUME_10UL = 2 # "10ul Low Volume Tip" + LOW_VOLUME_10UL_FILTER = 3 # "10ul Low Volume Tip with filter" + HIGH_VOLUME_1000UL = 4 # "1000ul High Volume Tip" + HIGH_VOLUME_1000UL_FILTER = 5 # "1000ul High Volume Tip with filter" + TIP_50UL = 22 # "50ul Tip" + TIP_50UL_FILTER = 23 # "50ul Tip with filter" + SLIM_CORE_300UL = 36 # "SLIM CO-RE Tip 300ul" + + +def _get_tip_type_from_tip(tip: Tip) -> int: + """Map Tip object characteristics to Hamilton tip type integer. + + Args: + tip: Tip object with volume and filter information. Must be a HamiltonTip. + + Returns: + Hamilton tip type integer value. + + Raises: + ValueError: If tip characteristics don't match any known tip type. + """ + + if not isinstance(tip, HamiltonTip): + raise ValueError("Tip must be a HamiltonTip to determine tip type.") + + if tip.tip_size == TipSize.LOW_VOLUME: # 10ul tip + return NimbusTipType.LOW_VOLUME_10UL_FILTER if tip.has_filter else NimbusTipType.LOW_VOLUME_10UL + + if tip.tip_size == TipSize.STANDARD_VOLUME and tip.maximal_volume < 60: # 50ul tip + return NimbusTipType.TIP_50UL_FILTER if tip.has_filter else NimbusTipType.TIP_50UL + + if tip.tip_size == TipSize.STANDARD_VOLUME: # 300ul tip + return NimbusTipType.STANDARD_300UL_FILTER if tip.has_filter else NimbusTipType.STANDARD_300UL + + if tip.tip_size == TipSize.HIGH_VOLUME: # 1000ul tip + return ( + NimbusTipType.HIGH_VOLUME_1000UL_FILTER + if tip.has_filter + else NimbusTipType.HIGH_VOLUME_1000UL + ) + + raise ValueError( + f"Cannot determine tip type for tip with volume {tip.maximal_volume}uL " + f"and filter={tip.has_filter}. No matching Hamilton tip type found." + ) + + +def _get_default_flow_rate(tip: Tip, is_aspirate: bool) -> float: + """Get default flow rate based on tip type. + + Defaults from Hamilton Nimbus: + - 1000 ul tip: 250 asp / 400 disp + - 300 and 50 ul tip: 100 asp / 180 disp + - 10 ul tip: 100 asp / 75 disp + + Args: + tip: Tip object to determine default flow rate for. + is_aspirate: True for aspirate, False for dispense. + + Returns: + Default flow rate in uL/s. + """ + tip_type = _get_tip_type_from_tip(tip) + + if tip_type in (NimbusTipType.HIGH_VOLUME_1000UL, NimbusTipType.HIGH_VOLUME_1000UL_FILTER): + return 250.0 if is_aspirate else 400.0 + + if tip_type in (NimbusTipType.LOW_VOLUME_10UL, NimbusTipType.LOW_VOLUME_10UL_FILTER): + return 100.0 if is_aspirate else 75.0 + + # 50 and 300 ul tips + return 100.0 if is_aspirate else 180.0 + + +# ============================================================================ +# COMMAND CLASSES +# ============================================================================ + + +class LockDoor(HamiltonCommand): + """Lock door command (DoorLock at 1:1:268, interface_id=1, command_id=1).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 1 + + +class UnlockDoor(HamiltonCommand): + """Unlock door command (DoorLock at 1:1:268, interface_id=1, command_id=2).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 2 + + +class IsDoorLocked(HamiltonCommand): + """Check if door is locked (DoorLock at 1:1:268, interface_id=1, command_id=3).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 3 + action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse IsDoorLocked response.""" + parser = HoiParamsParser(data) + _, locked = parser.parse_next() + return {"locked": bool(locked)} + + +class PreInitializeSmart(HamiltonCommand): + """Pre-initialize smart command (Pipette at 1:1:257, interface_id=1, command_id=32).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 32 + + +class InitializeSmartRoll(HamiltonCommand): + """Initialize smart roll command (NimbusCore at 1:1:48896, interface_id=1, command_id=29).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 29 + + def __init__( + self, + dest: Address, + x_positions: List[int], + y_positions: List[int], + begin_tip_deposit_process: List[int], + end_tip_deposit_process: List[int], + z_position_at_end_of_a_command: List[int], + roll_distances: List[int], + ): + """Initialize InitializeSmartRoll command. + + Args: + dest: Destination address (NimbusCore) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + begin_tip_deposit_process: Z start positions in 0.01mm units + end_tip_deposit_process: Z stop positions in 0.01mm units + z_position_at_end_of_a_command: Z position at end of command in 0.01mm units + roll_distances: Roll distances in 0.01mm units + """ + super().__init__(dest) + self.x_positions = x_positions + self.y_positions = y_positions + self.begin_tip_deposit_process = begin_tip_deposit_process + self.end_tip_deposit_process = end_tip_deposit_process + self.z_position_at_end_of_a_command = z_position_at_end_of_a_command + self.roll_distances = roll_distances + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32_array(self.begin_tip_deposit_process) + .i32_array(self.end_tip_deposit_process) + .i32_array(self.z_position_at_end_of_a_command) + .i32_array(self.roll_distances) + ) + + +class IsInitialized(HamiltonCommand): + """Check if instrument is initialized (NimbusCore at 1:1:48896, interface_id=1, command_id=14).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 14 + action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse IsInitialized response.""" + parser = HoiParamsParser(data) + _, initialized = parser.parse_next() + return {"initialized": bool(initialized)} + + +class IsTipPresent(HamiltonCommand): + """Check tip presence (Pipette at 1:1:257, interface_id=1, command_id=16).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 16 + action_code = 0 + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse IsTipPresent response - returns List[i16].""" + parser = HoiParamsParser(data) + # Parse array of i16 values representing tip presence per channel + _, tip_presence = parser.parse_next() + return {"tip_present": tip_presence} + + +class GetChannelConfiguration_1(HamiltonCommand): + """Get channel configuration (NimbusCore root, interface_id=1, command_id=15).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 15 + action_code = 0 + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse GetChannelConfiguration_1 response. + + Returns: (channels: u16, channel_types: List[i16]) + """ + parser = HoiParamsParser(data) + _, channels = parser.parse_next() + _, channel_types = parser.parse_next() + return {"channels": channels, "channel_types": channel_types} + + +class SetChannelConfiguration(HamiltonCommand): + """Set channel configuration (Pipette at 1:1:257, interface_id=1, command_id=67).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 67 + + def __init__( + self, + dest: Address, + channel: int, + indexes: List[int], + enables: List[bool], + ): + """Initialize SetChannelConfiguration command. + + Args: + dest: Destination address (Pipette) + channel: Channel number (1-based) + indexes: List of configuration indexes (e.g., [1, 3, 4]) + 1: Tip Recognition, 2: Aspirate and clot monitoring pLLD, + 3: Aspirate monitoring with cLLD, 4: Clot monitoring with cLLD + enables: List of enable flags (e.g., [True, False, False, False]) + """ + super().__init__(dest) + self.channel = channel + self.indexes = indexes + self.enables = enables + + def build_parameters(self) -> HoiParams: + return HoiParams().u16(self.channel).i16_array(self.indexes).bool_array(self.enables) + + +class GetChannelConfiguration(HamiltonCommand): + """Get channel configuration command (Pipette at 1:1:257, interface_id=1, command_id=66).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 66 + action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) + + def __init__( + self, + dest: Address, + channel: int, + indexes: List[int], + ): + """Initialize GetChannelConfiguration command. + + Args: + dest: Destination address (Pipette) + channel: Channel number (1-based) + indexes: List of configuration indexes (e.g., [2] for "Aspirate monitoring with cLLD") + """ + super().__init__(dest) + self.channel = channel + self.indexes = indexes + + def build_parameters(self) -> HoiParams: + return HoiParams().u16(self.channel).i16_array(self.indexes) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse GetChannelConfiguration response. + + Returns: { enabled: List[bool] } + """ + parser = HoiParamsParser(data) + _, enabled = parser.parse_next() + return {"enabled": enabled} + + +class Park(HamiltonCommand): + """Park command (NimbusCore at 1:1:48896, interface_id=1, command_id=3).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 3 + + +class PickupTips(HamiltonCommand): + """Pick up tips command (Pipette at 1:1:257, interface_id=1, command_id=4).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 4 + + def __init__( + self, + dest: Address, + channels_involved: List[int], + x_positions: List[int], + y_positions: List[int], + minimum_traverse_height_at_beginning_of_a_command: int, + begin_tip_pick_up_process: List[int], + end_tip_pick_up_process: List[int], + tip_types: List[int], + ): + """Initialize PickupTips command. + + Args: + dest: Destination address (Pipette) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units + begin_tip_pick_up_process: Z start positions in 0.01mm units + end_tip_pick_up_process: Z stop positions in 0.01mm units + tip_types: Tip type integers for each channel + """ + super().__init__(dest) + self.channels_involved = channels_involved + self.x_positions = x_positions + self.y_positions = y_positions + self.minimum_traverse_height_at_beginning_of_a_command = ( + minimum_traverse_height_at_beginning_of_a_command + ) + self.begin_tip_pick_up_process = begin_tip_pick_up_process + self.end_tip_pick_up_process = end_tip_pick_up_process + self.tip_types = tip_types + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .u16_array(self.channels_involved) + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32(self.minimum_traverse_height_at_beginning_of_a_command) + .i32_array(self.begin_tip_pick_up_process) + .i32_array(self.end_tip_pick_up_process) + .u16_array(self.tip_types) + ) + + +class DropTips(HamiltonCommand): + """Drop tips command (Pipette at 1:1:257, interface_id=1, command_id=5).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 5 + + def __init__( + self, + dest: Address, + channels_involved: List[int], + x_positions: List[int], + y_positions: List[int], + minimum_traverse_height_at_beginning_of_a_command: int, + begin_tip_deposit_process: List[int], + end_tip_deposit_process: List[int], + z_position_at_end_of_a_command: List[int], + default_waste: bool, + ): + """Initialize DropTips command. + + Args: + dest: Destination address (Pipette) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units + begin_tip_deposit_process: Z start positions in 0.01mm units + end_tip_deposit_process: Z stop positions in 0.01mm units + z_position_at_end_of_a_command: Z position at end of command in 0.01mm units + default_waste: If True, drop to default waste (positions may be ignored) + """ + super().__init__(dest) + self.channels_involved = channels_involved + self.x_positions = x_positions + self.y_positions = y_positions + self.minimum_traverse_height_at_beginning_of_a_command = ( + minimum_traverse_height_at_beginning_of_a_command + ) + self.begin_tip_deposit_process = begin_tip_deposit_process + self.end_tip_deposit_process = end_tip_deposit_process + self.z_position_at_end_of_a_command = z_position_at_end_of_a_command + self.default_waste = default_waste + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .u16_array(self.channels_involved) + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32(self.minimum_traverse_height_at_beginning_of_a_command) + .i32_array(self.begin_tip_deposit_process) + .i32_array(self.end_tip_deposit_process) + .i32_array(self.z_position_at_end_of_a_command) + .bool_value(self.default_waste) + ) + + +class DropTipsRoll(HamiltonCommand): + """Drop tips with roll command (Pipette at 1:1:257, interface_id=1, command_id=82).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 82 + + def __init__( + self, + dest: Address, + channels_involved: List[int], + x_positions: List[int], + y_positions: List[int], + minimum_traverse_height_at_beginning_of_a_command: int, + begin_tip_deposit_process: List[int], + end_tip_deposit_process: List[int], + z_position_at_end_of_a_command: List[int], + roll_distances: List[int], + ): + """Initialize DropTipsRoll command. + + Args: + dest: Destination address (Pipette) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units + begin_tip_deposit_process: Z start positions in 0.01mm units + end_tip_deposit_process: Z stop positions in 0.01mm units + z_position_at_end_of_a_command: Z position at end of command in 0.01mm units + roll_distances: Roll distance for each channel in 0.01mm units + """ + super().__init__(dest) + self.channels_involved = channels_involved + self.x_positions = x_positions + self.y_positions = y_positions + self.minimum_traverse_height_at_beginning_of_a_command = ( + minimum_traverse_height_at_beginning_of_a_command + ) + self.begin_tip_deposit_process = begin_tip_deposit_process + self.end_tip_deposit_process = end_tip_deposit_process + self.z_position_at_end_of_a_command = z_position_at_end_of_a_command + self.roll_distances = roll_distances + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .u16_array(self.channels_involved) + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32(self.minimum_traverse_height_at_beginning_of_a_command) + .i32_array(self.begin_tip_deposit_process) + .i32_array(self.end_tip_deposit_process) + .i32_array(self.z_position_at_end_of_a_command) + .i32_array(self.roll_distances) + ) + + +class EnableADC(HamiltonCommand): + """Enable ADC command (Pipette at 1:1:257, interface_id=1, command_id=43).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 43 + + def __init__( + self, + dest: Address, + channels_involved: List[int], + ): + """Initialize EnableADC command. + + Args: + dest: Destination address (Pipette) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + """ + super().__init__(dest) + self.channels_involved = channels_involved + + def build_parameters(self) -> HoiParams: + return HoiParams().u16_array(self.channels_involved) + + +class DisableADC(HamiltonCommand): + """Disable ADC command (Pipette at 1:1:257, interface_id=1, command_id=44).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 44 + + def __init__( + self, + dest: Address, + channels_involved: List[int], + ): + """Initialize DisableADC command. + + Args: + dest: Destination address (Pipette) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + """ + super().__init__(dest) + self.channels_involved = channels_involved + + def build_parameters(self) -> HoiParams: + return HoiParams().u16_array(self.channels_involved) + + +class Aspirate(HamiltonCommand): + """Aspirate command (Pipette at 1:1:257, interface_id=1, command_id=6).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 6 + + def __init__( + self, + dest: Address, + aspirate_type: List[int], + channels_involved: List[int], + x_positions: List[int], + y_positions: List[int], + minimum_traverse_height_at_beginning_of_a_command: int, + lld_search_height: List[int], + liquid_height: List[int], + immersion_depth: List[int], + surface_following_distance: List[int], + minimum_height: List[int], + clot_detection_height: List[int], + min_z_endpos: int, + swap_speed: List[int], + blow_out_air_volume: List[int], + pre_wetting_volume: List[int], + aspirate_volume: List[int], + transport_air_volume: List[int], + aspiration_speed: List[int], + settling_time: List[int], + mix_volume: List[int], + mix_cycles: List[int], + mix_position_from_liquid_surface: List[int], + mix_surface_following_distance: List[int], + mix_speed: List[int], + tube_section_height: List[int], + tube_section_ratio: List[int], + lld_mode: List[int], + gamma_lld_sensitivity: List[int], + dp_lld_sensitivity: List[int], + lld_height_difference: List[int], + tadm_enabled: bool, + limit_curve_index: List[int], + recording_mode: int, + ): + """Initialize Aspirate command. + + Args: + dest: Destination address (Pipette) + aspirate_type: Aspirate type for each channel (List[i16]) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units + lld_search_height: LLD search height for each channel in 0.01mm units + liquid_height: Liquid height for each channel in 0.01mm units + immersion_depth: Immersion depth for each channel in 0.01mm units + surface_following_distance: Surface following distance for each channel in 0.01mm units + minimum_height: Minimum height for each channel in 0.01mm units + clot_detection_height: Clot detection height for each channel in 0.01mm units + min_z_endpos: Minimum Z end position in 0.01mm units + swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units + blow_out_air_volume: Blowout volume for each channel in 0.1uL units + pre_wetting_volume: Pre-wetting volume for each channel in 0.1uL units + aspirate_volume: Aspirate volume for each channel in 0.1uL units + transport_air_volume: Transport air volume for each channel in 0.1uL units + aspiration_speed: Aspirate speed for each channel in 0.1uL/s units + settling_time: Settling time for each channel in 0.1s units + mix_volume: Mix volume for each channel in 0.1uL units + mix_cycles: Mix cycles for each channel + mix_position_from_liquid_surface: Mix position from liquid surface in 0.01mm units + mix_surface_following_distance: Mix follow distance in 0.01mm units + mix_speed: Mix speed for each channel in 0.1uL/s units + tube_section_height: Tube section height for each channel in 0.01mm units + tube_section_ratio: Tube section ratio for each channel + lld_mode: LLD mode for each channel (List[i16]) + gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) + dp_lld_sensitivity: DP LLD sensitivity for each channel (List[i16]) + lld_height_difference: LLD height difference for each channel in 0.01mm units + tadm_enabled: TADM enabled flag + limit_curve_index: Limit curve index for each channel + recording_mode: Recording mode (u16) + """ + super().__init__(dest) + self.aspirate_type = aspirate_type + self.channels_involved = channels_involved + self.x_positions = x_positions + self.y_positions = y_positions + self.minimum_traverse_height_at_beginning_of_a_command = ( + minimum_traverse_height_at_beginning_of_a_command + ) + self.lld_search_height = lld_search_height + self.liquid_height = liquid_height + self.immersion_depth = immersion_depth + self.surface_following_distance = surface_following_distance + self.minimum_height = minimum_height + self.clot_detection_height = clot_detection_height + self.min_z_endpos = min_z_endpos + self.swap_speed = swap_speed + self.blow_out_air_volume = blow_out_air_volume + self.pre_wetting_volume = pre_wetting_volume + self.aspirate_volume = aspirate_volume + self.transport_air_volume = transport_air_volume + self.aspiration_speed = aspiration_speed + self.settling_time = settling_time + self.mix_volume = mix_volume + self.mix_cycles = mix_cycles + self.mix_position_from_liquid_surface = mix_position_from_liquid_surface + self.mix_surface_following_distance = mix_surface_following_distance + self.mix_speed = mix_speed + self.tube_section_height = tube_section_height + self.tube_section_ratio = tube_section_ratio + self.lld_mode = lld_mode + self.gamma_lld_sensitivity = gamma_lld_sensitivity + self.dp_lld_sensitivity = dp_lld_sensitivity + self.lld_height_difference = lld_height_difference + self.tadm_enabled = tadm_enabled + self.limit_curve_index = limit_curve_index + self.recording_mode = recording_mode + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .i16_array(self.aspirate_type) + .u16_array(self.channels_involved) + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32(self.minimum_traverse_height_at_beginning_of_a_command) + .i32_array(self.lld_search_height) + .i32_array(self.liquid_height) + .i32_array(self.immersion_depth) + .i32_array(self.surface_following_distance) + .i32_array(self.minimum_height) + .i32_array(self.clot_detection_height) + .i32(self.min_z_endpos) + .u32_array(self.swap_speed) + .u32_array(self.blow_out_air_volume) + .u32_array(self.pre_wetting_volume) + .u32_array(self.aspirate_volume) + .u32_array(self.transport_air_volume) + .u32_array(self.aspiration_speed) + .u32_array(self.settling_time) + .u32_array(self.mix_volume) + .u32_array(self.mix_cycles) + .i32_array(self.mix_position_from_liquid_surface) + .i32_array(self.mix_surface_following_distance) + .u32_array(self.mix_speed) + .i32_array(self.tube_section_height) + .i32_array(self.tube_section_ratio) + .i16_array(self.lld_mode) + .i16_array(self.gamma_lld_sensitivity) + .i16_array(self.dp_lld_sensitivity) + .i32_array(self.lld_height_difference) + .bool_value(self.tadm_enabled) + .u32_array(self.limit_curve_index) + .u16(self.recording_mode) + ) + + +class Dispense(HamiltonCommand): + """Dispense command (Pipette at 1:1:257, interface_id=1, command_id=7).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 7 + + def __init__( + self, + dest: Address, + dispense_type: List[int], + channels_involved: List[int], + x_positions: List[int], + y_positions: List[int], + minimum_traverse_height_at_beginning_of_a_command: int, + lld_search_height: List[int], + liquid_height: List[int], + immersion_depth: List[int], + surface_following_distance: List[int], + minimum_height: List[int], + min_z_endpos: int, + swap_speed: List[int], + transport_air_volume: List[int], + dispense_volume: List[int], + stop_back_volume: List[int], + blow_out_air_volume: List[int], + dispense_speed: List[int], + cut_off_speed: List[int], + settling_time: List[int], + mix_volume: List[int], + mix_cycles: List[int], + mix_position_from_liquid_surface: List[int], + mix_surface_following_distance: List[int], + mix_speed: List[int], + side_touch_off_distance: int, + dispense_offset: List[int], + tube_section_height: List[int], + tube_section_ratio: List[int], + lld_mode: List[int], + gamma_lld_sensitivity: List[int], + tadm_enabled: bool, + limit_curve_index: List[int], + recording_mode: int, + ): + """Initialize Dispense command. + + Args: + dest: Destination address (Pipette) + dispense_type: Dispense type for each channel (List[i16]) + channels_involved: Tip pattern (1 for active channels, 0 for inactive) + x_positions: X positions in 0.01mm units + y_positions: Y positions in 0.01mm units + minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units + lld_search_height: LLD search height for each channel in 0.01mm units + liquid_height: Liquid height for each channel in 0.01mm units + immersion_depth: Immersion depth for each channel in 0.01mm units + surface_following_distance: Surface following distance for each channel in 0.01mm units + minimum_height: Minimum height for each channel in 0.01mm units + min_z_endpos: Minimum Z end position in 0.01mm units + swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units + transport_air_volume: Transport air volume for each channel in 0.1uL units + dispense_volume: Dispense volume for each channel in 0.1uL units + stop_back_volume: Stop back volume for each channel in 0.1uL units + blow_out_air_volume: Blowout volume for each channel in 0.1uL units + dispense_speed: Dispense speed for each channel in 0.1uL/s units + cut_off_speed: Cut off speed for each channel in 0.1uL/s units + settling_time: Settling time for each channel in 0.1s units + mix_volume: Mix volume for each channel in 0.1uL units + mix_cycles: Mix cycles for each channel + mix_position_from_liquid_surface: Mix position from liquid surface in 0.01mm units + mix_surface_following_distance: Mix follow distance in 0.01mm units + mix_speed: Mix speed for each channel in 0.1uL/s units + side_touch_off_distance: Side touch off distance in 0.01mm units + dispense_offset: Dispense offset for each channel in 0.01mm units + tube_section_height: Tube section height for each channel in 0.01mm units + tube_section_ratio: Tube section ratio for each channel + lld_mode: LLD mode for each channel (List[i16]) + gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) + tadm_enabled: TADM enabled flag + limit_curve_index: Limit curve index for each channel + recording_mode: Recording mode (u16) + """ + super().__init__(dest) + self.dispense_type = dispense_type + self.channels_involved = channels_involved + self.x_positions = x_positions + self.y_positions = y_positions + self.minimum_traverse_height_at_beginning_of_a_command = ( + minimum_traverse_height_at_beginning_of_a_command + ) + self.lld_search_height = lld_search_height + self.liquid_height = liquid_height + self.immersion_depth = immersion_depth + self.surface_following_distance = surface_following_distance + self.minimum_height = minimum_height + self.min_z_endpos = min_z_endpos + self.swap_speed = swap_speed + self.transport_air_volume = transport_air_volume + self.dispense_volume = dispense_volume + self.stop_back_volume = stop_back_volume + self.blow_out_air_volume = blow_out_air_volume + self.dispense_speed = dispense_speed + self.cut_off_speed = cut_off_speed + self.settling_time = settling_time + self.mix_volume = mix_volume + self.mix_cycles = mix_cycles + self.mix_position_from_liquid_surface = mix_position_from_liquid_surface + self.mix_surface_following_distance = mix_surface_following_distance + self.mix_speed = mix_speed + self.side_touch_off_distance = side_touch_off_distance + self.dispense_offset = dispense_offset + self.tube_section_height = tube_section_height + self.tube_section_ratio = tube_section_ratio + self.lld_mode = lld_mode + self.gamma_lld_sensitivity = gamma_lld_sensitivity + self.tadm_enabled = tadm_enabled + self.limit_curve_index = limit_curve_index + self.recording_mode = recording_mode + + def build_parameters(self) -> HoiParams: + return ( + HoiParams() + .i16_array(self.dispense_type) + .u16_array(self.channels_involved) + .i32_array(self.x_positions) + .i32_array(self.y_positions) + .i32(self.minimum_traverse_height_at_beginning_of_a_command) + .i32_array(self.lld_search_height) + .i32_array(self.liquid_height) + .i32_array(self.immersion_depth) + .i32_array(self.surface_following_distance) + .i32_array(self.minimum_height) + .i32(self.min_z_endpos) + .u32_array(self.swap_speed) + .u32_array(self.transport_air_volume) + .u32_array(self.dispense_volume) + .u32_array(self.stop_back_volume) + .u32_array(self.blow_out_air_volume) + .u32_array(self.dispense_speed) + .u32_array(self.cut_off_speed) + .u32_array(self.settling_time) + .u32_array(self.mix_volume) + .u32_array(self.mix_cycles) + .i32_array(self.mix_position_from_liquid_surface) + .i32_array(self.mix_surface_following_distance) + .u32_array(self.mix_speed) + .i32(self.side_touch_off_distance) + .i32_array(self.dispense_offset) + .i32_array(self.tube_section_height) + .i32_array(self.tube_section_ratio) + .i16_array(self.lld_mode) + .i16_array(self.gamma_lld_sensitivity) + .bool_value(self.tadm_enabled) + .u32_array(self.limit_curve_index) + .u16(self.recording_mode) + ) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/door.py b/pylabrobot/hamilton/liquid_handlers/nimbus/door.py new file mode 100644 index 00000000000..363bb93b6ca --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/door.py @@ -0,0 +1,56 @@ +"""NimbusDoor: door control subsystem for Hamilton Nimbus.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from pylabrobot.hamilton.tcp.packets import Address + +from .commands import IsDoorLocked, LockDoor, UnlockDoor + +if TYPE_CHECKING: + from .driver import NimbusDriver + +logger = logging.getLogger(__name__) + + +class NimbusDoor: + """Controls the door on a Hamilton Nimbus. + + Plain helper class (not a CapabilityBackend), following the STARCover pattern. + Owned by NimbusDriver, exposed via convenience methods on the Nimbus device. + """ + + def __init__(self, driver: "NimbusDriver", address: Address): + self.driver = driver + self.address = address + + async def _on_setup(self): + """Lock door on setup if available.""" + try: + if not await self.is_locked(): + await self.lock() + else: + logger.info("Door already locked") + except Exception as e: + logger.warning(f"Door operations skipped during setup: {e}") + + async def _on_stop(self): + pass + + async def is_locked(self) -> bool: + """Check if the door is locked.""" + status = await self.driver.send_command(IsDoorLocked(self.address)) + assert status is not None, "IsDoorLocked command returned None" + return bool(status["locked"]) + + async def lock(self) -> None: + """Lock the door.""" + await self.driver.send_command(LockDoor(self.address)) + logger.info("Door locked successfully") + + async def unlock(self) -> None: + """Unlock the door.""" + await self.driver.send_command(UnlockDoor(self.address)) + logger.info("Door unlocked successfully") diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py new file mode 100644 index 00000000000..34e853a083f --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -0,0 +1,145 @@ +"""NimbusDriver: TCP-based driver for Hamilton Nimbus liquid handlers.""" + +from __future__ import annotations + +import logging +from typing import Dict, Optional + +from pylabrobot.hamilton.liquid_handlers.tcp_base import HamiltonTCPHandler +from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection +from pylabrobot.hamilton.tcp.packets import Address + +from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + +from .commands import ( + GetChannelConfiguration_1, + Park, +) +from .door import NimbusDoor +from .pip_backend import NimbusPIPBackend + +logger = logging.getLogger(__name__) + + +class NimbusDriver(HamiltonTCPHandler): + """Driver for Hamilton Nimbus liquid handlers. + + Handles TCP communication, hardware discovery via introspection, and + manages the PIP backend and door subsystem. + """ + + def __init__( + self, + host: str, + port: int = 2000, + read_timeout: float = 30.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + ): + super().__init__( + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + auto_reconnect=auto_reconnect, + max_reconnect_attempts=max_reconnect_attempts, + ) + + self._nimbus_core_address: Optional[Address] = None + + self.pip: NimbusPIPBackend # set in setup() + self.door: Optional[NimbusDoor] = None # set in setup() if available + + @property + def nimbus_core_address(self) -> Address: + if self._nimbus_core_address is None: + raise RuntimeError("NimbusCore address not discovered. Call setup() first.") + return self._nimbus_core_address + + async def setup(self, deck: Optional[NimbusDeck] = None): + """Initialize connection, discover hardware, and create backends. + + Args: + deck: NimbusDeck for coordinate conversion. Required for pipetting operations. + """ + # TCP connection + Protocol 7 + Protocol 3 + root discovery + await super().setup() + + # Discover instrument objects via introspection + addresses = await self._discover_instrument_objects() + + pipette_address = addresses.get("Pipette") + door_address = addresses.get("DoorLock") + + if pipette_address is None: + raise RuntimeError("Pipette object not discovered. Cannot proceed with setup.") + if self._nimbus_core_address is None: + raise RuntimeError("NimbusCore root object not discovered. Cannot proceed with setup.") + + # Query channel configuration + config = await self.send_command(GetChannelConfiguration_1(self._nimbus_core_address)) + assert config is not None, "GetChannelConfiguration_1 command returned None" + num_channels = config["channels"] + logger.info(f"Channel configuration: {num_channels} channels") + + # Create backends — each object stores its own address and state + self.pip = NimbusPIPBackend( + driver=self, deck=deck, address=pipette_address, num_channels=num_channels + ) + + if door_address is not None: + self.door = NimbusDoor(driver=self, address=door_address) + + # Initialize subsystems + if self.door is not None: + await self.door._on_setup() + + async def stop(self): + """Stop driver and close connection.""" + if self.door is not None: + await self.door._on_stop() + await super().stop() + self.door = None + + async def _discover_instrument_objects(self) -> Dict[str, Address]: + """Discover instrument-specific objects using introspection. + + Returns: + Dictionary mapping object names (e.g. "Pipette", "DoorLock") to their addresses. + """ + introspection = HamiltonIntrospection(self) + addresses: Dict[str, Address] = {} + + root_objects = self._discovered_objects.get("root", []) + if not root_objects: + logger.warning("No root objects discovered") + return addresses + + nimbus_core_addr = root_objects[0] + self._nimbus_core_address = nimbus_core_addr + + try: + core_info = await introspection.get_object(nimbus_core_addr) + + for i in range(core_info.subobject_count): + try: + sub_addr = await introspection.get_subobject_address(nimbus_core_addr, i) + sub_info = await introspection.get_object(sub_addr) + addresses[sub_info.name] = sub_addr + logger.info(f"Found {sub_info.name} at {sub_addr}") + except Exception as e: + logger.debug(f"Failed to get subobject {i}: {e}") + + except Exception as e: + logger.warning(f"Failed to discover instrument objects: {e}") + + if "DoorLock" not in addresses: + logger.info("DoorLock not available on this instrument") + + return addresses + + async def park(self): + """Park the instrument.""" + await self.send_command(Park(self.nimbus_core_address)) + logger.info("Instrument parked successfully") diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py new file mode 100644 index 00000000000..a32517577bc --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py @@ -0,0 +1,91 @@ +"""Nimbus device: wires NimbusDriver backends to PIP capability frontend.""" + +from typing import Optional + +from pylabrobot.capabilities.liquid_handling.pip import PIP +from pylabrobot.device import Device +from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + +from .chatterbox import NimbusChatterboxDriver +from .driver import NimbusDriver + + +class Nimbus(Device): + """Hamilton Nimbus liquid handler. + + User-facing device that wires the PIP capability frontend to the + NimbusDriver's PIP backend after hardware discovery during setup(). + """ + + def __init__( + self, + deck: NimbusDeck, + chatterbox: bool = False, + host: Optional[str] = None, + port: int = 2000, + ): + if chatterbox: + driver: NimbusDriver = NimbusChatterboxDriver() + else: + if not host: + raise ValueError("host must be provided when chatterbox is False.") + driver = NimbusDriver(host=host, port=port) + super().__init__(driver=driver) + self.driver: NimbusDriver = driver + self.deck = deck + self.pip: PIP # set in setup() + + async def setup(self): + """Initialize the Nimbus instrument. + + Establishes the TCP connection, discovers hardware objects, queries channel + configuration and tip presence, locks the door (if available), conditionally + runs InitializeSmartRoll, and wires the PIP capability frontend to the driver's + PIP backend. + """ + try: + await self.driver.setup(deck=self.deck) + + self.pip = PIP(backend=self.driver.pip) + self._capabilities = [self.pip] + await self.pip._on_setup() + self._setup_finished = True + except Exception: + await self.driver.stop() + raise + + async def stop(self): + """Tear down the Nimbus instrument. + + Stops all capabilities in reverse order and closes the driver connection. + """ + if not self._setup_finished: + return + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + + # -- Convenience methods delegating to driver/subsystems -------------------- + + async def park(self): + """Park the instrument.""" + await self.driver.park() + + async def lock_door(self): + """Lock the door.""" + if self.driver.door is None: + raise RuntimeError("Door lock is not available on this instrument.") + await self.driver.door.lock() + + async def unlock_door(self): + """Unlock the door.""" + if self.driver.door is None: + raise RuntimeError("Door lock is not available on this instrument.") + await self.driver.door.unlock() + + async def is_door_locked(self) -> bool: + """Check if the door is locked.""" + if self.driver.door is None: + raise RuntimeError("Door lock is not available on this instrument.") + return await self.driver.door.is_locked() diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py new file mode 100644 index 00000000000..d0947928a50 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -0,0 +1,1068 @@ +"""NimbusPIPBackend: translates PIP operations into Nimbus firmware commands.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, TypeVar, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend +from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.resources import Tip +from pylabrobot.resources.container import Container +from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.trash import Trash + +from .commands import ( + Aspirate, + Dispense as DispenseCommand, + DisableADC, + DropTips, + DropTipsRoll, + EnableADC, + GetChannelConfiguration, + InitializeSmartRoll, + IsInitialized, + IsTipPresent, + PickupTips, + SetChannelConfiguration, + _get_default_flow_rate, + _get_tip_type_from_tip, +) + +if TYPE_CHECKING: + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + from .driver import NimbusDriver + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fill_in_defaults(val: Optional[List[T]], default: List[T]) -> List[T]: + """If val is None, return default. Otherwise validate length and fill None entries.""" + if val is None: + return default + if len(val) != len(default): + raise ValueError(f"Value length must equal num operations ({len(default)}), but is {len(val)}") + return [v if v is not None else d for v, d in zip(val, default)] + + +# --------------------------------------------------------------------------- +# BackendParams dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class NimbusPIPPickUpTipsParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + + +@dataclass +class NimbusPIPDropTipsParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + default_waste: bool = False + z_position_at_end_of_a_command: Optional[float] = None + roll_distance: Optional[float] = None + + +@dataclass +class NimbusPIPAspirateParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + adc_enabled: bool = False + lld_mode: Optional[List[int]] = None + lld_search_height: Optional[List[float]] = None + immersion_depth: Optional[List[float]] = None + surface_following_distance: Optional[List[float]] = None + gamma_lld_sensitivity: Optional[List[int]] = None + dp_lld_sensitivity: Optional[List[int]] = None + settling_time: Optional[List[float]] = None + transport_air_volume: Optional[List[float]] = None + pre_wetting_volume: Optional[List[float]] = None + swap_speed: Optional[List[float]] = None + mix_position_from_liquid_surface: Optional[List[float]] = None + limit_curve_index: Optional[List[int]] = None + tadm_enabled: bool = False + + +@dataclass +class NimbusPIPDispenseParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + adc_enabled: bool = False + lld_mode: Optional[List[int]] = None + lld_search_height: Optional[List[float]] = None + immersion_depth: Optional[List[float]] = None + surface_following_distance: Optional[List[float]] = None + gamma_lld_sensitivity: Optional[List[int]] = None + settling_time: Optional[List[float]] = None + transport_air_volume: Optional[List[float]] = None + swap_speed: Optional[List[float]] = None + mix_position_from_liquid_surface: Optional[List[float]] = None + limit_curve_index: Optional[List[int]] = None + tadm_enabled: bool = False + cut_off_speed: Optional[List[float]] = None + stop_back_volume: Optional[List[float]] = None + side_touch_off_distance: float = 0.0 + dispense_offset: Optional[List[float]] = None + + +# --------------------------------------------------------------------------- +# NimbusPIPBackend +# --------------------------------------------------------------------------- + + +class NimbusPIPBackend(PIPBackend): + """PIP backend for Hamilton Nimbus liquid handlers. + + Translates abstract PIP operations (pick_up_tips, drop_tips, aspirate, dispense) + into Nimbus-specific Hamilton TCP commands. + """ + + def __init__( + self, + driver: "NimbusDriver", + deck: Optional["NimbusDeck"] = None, + address: Optional["Address"] = None, + num_channels: int = 8, + traversal_height: float = 146.0, + ): + self.driver = driver + self.deck = deck + self.address = address + self._num_channels = num_channels + self.traversal_height = traversal_height + self._channel_configurations: Optional[dict] = None + + @property + def num_channels(self) -> int: + return self._num_channels + + @property + def pipette_address(self) -> Address: + if self.address is None: + raise RuntimeError("Pipette address not set. Call setup() first.") + return self.address + + def _ensure_deck(self) -> "NimbusDeck": + """Return the deck, raising if not set.""" + if self.deck is None: + raise RuntimeError("Deck must be set for pipetting operations.") + return self.deck + + async def _on_setup(self): + """Initialize SmartRoll if not already initialized.""" + # Query initialization status + init_status = await self.driver.send_command(IsInitialized(self.driver.nimbus_core_address)) + assert init_status is not None + is_initialized = init_status.get("initialized", False) + + if not is_initialized: + await self._initialize_smart_roll() + else: + logger.info("Instrument already initialized, skipping SmartRoll init") + + async def _on_stop(self): + pass + + async def _initialize_smart_roll(self): + """Configure channels and initialize SmartRoll with waste positions.""" + self._ensure_deck() + # Set channel configuration for each channel + for channel in range(1, self.num_channels + 1): + await self.driver.send_command( + SetChannelConfiguration( + dest=self.pipette_address, + channel=channel, + indexes=[1, 3, 4], + enables=[True, False, False, False], + ) + ) + logger.info(f"Channel configuration set for {self.num_channels} channels") + + # Initialize SmartRoll using waste positions + all_channels = list(range(self.num_channels)) + ( + x_positions_full, + y_positions_full, + begin_tip_deposit_process_full, + end_tip_deposit_process_full, + z_position_at_end_of_a_command_full, + roll_distances_full, + ) = self._build_waste_position_params(use_channels=all_channels) + + await self.driver.send_command( + InitializeSmartRoll( + dest=self.driver.nimbus_core_address, + x_positions=x_positions_full, + y_positions=y_positions_full, + begin_tip_deposit_process=begin_tip_deposit_process_full, + end_tip_deposit_process=end_tip_deposit_process_full, + z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, + roll_distances=roll_distances_full, + ) + ) + logger.info("NimbusCore initialized with InitializeSmartRoll successfully") + + # --------------------------------------------------------------------------- + # Channel fill helper + # --------------------------------------------------------------------------- + + def _fill_by_channels(self, values: List[T], use_channels: List[int], default: T) -> List[T]: + """Returns a full-length list of size `num_channels` where positions in `use_channels` + are filled from `values` in order; all others are `default`.""" + if len(values) != len(use_channels): + raise ValueError( + f"values and channels must have same length (got {len(values)} vs {len(use_channels)})" + ) + for ch in use_channels: + if ch < 0 or ch >= self.num_channels: + raise ValueError( + f"Channel index {ch} out of range for {self.num_channels}-channel instrument" + ) + out = [default] * self.num_channels + for ch, v in zip(use_channels, values): + out[ch] = v + return out + + # --------------------------------------------------------------------------- + # Coordinate helpers + # --------------------------------------------------------------------------- + + def _compute_ops_xy_locations( + self, ops: Sequence, use_channels: List[int] + ) -> Tuple[List[int], List[int]]: + """Compute X and Y positions in Hamilton coordinates for the given operations.""" + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + if not isinstance(self.deck, NimbusDeck): + raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") + + x_positions_mm: List[float] = [] + y_positions_mm: List[float] = [] + + for op in ops: + abs_location = op.resource.get_location_wrt(self.deck) + final_location = abs_location + op.offset + hamilton_coord = self.deck.to_hamilton_coordinate(final_location) + x_positions_mm.append(hamilton_coord.x) + y_positions_mm.append(hamilton_coord.y) + + x_positions = [round(x * 100) for x in x_positions_mm] + y_positions = [round(y * 100) for y in y_positions_mm] + + x_positions_full = self._fill_by_channels(x_positions, use_channels, default=0) + y_positions_full = self._fill_by_channels(y_positions, use_channels, default=0) + + return x_positions_full, y_positions_full + + def _compute_tip_handling_parameters( + self, + ops: Sequence, + use_channels: List[int], + use_fixed_offset: bool = False, + fixed_offset_mm: float = 10.0, + ) -> Tuple[List[int], List[int]]: + """Calculate Z positions for tip pickup/drop operations. + + Pickup (use_fixed_offset=False): Z based on tip length. + Drop (use_fixed_offset=True): Z based on fixed offset. + + Returns: (begin_position, end_position) in 0.01mm units, full num_channels arrays. + """ + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + if not isinstance(self.deck, NimbusDeck): + raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") + + z_positions_mm: List[float] = [] + for op in ops: + abs_location = op.resource.get_location_wrt(self.deck) + op.offset + hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) + z_positions_mm.append(hamilton_coord.z) + + max_z_hamilton = max(z_positions_mm) + + if use_fixed_offset: + begin_position_mm = max_z_hamilton + fixed_offset_mm + end_position_mm = max_z_hamilton + else: + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + begin_position_mm = max_z_hamilton + max_total_tip_length + end_position_mm = max_z_hamilton + max_tip_length + + begin_position = [round(begin_position_mm * 100)] * len(ops) + end_position = [round(end_position_mm * 100)] * len(ops) + + begin_position_full = self._fill_by_channels(begin_position, use_channels, default=0) + end_position_full = self._fill_by_channels(end_position, use_channels, default=0) + + return begin_position_full, end_position_full + + def _build_waste_position_params( + self, + use_channels: List[int], + z_position_at_end_of_a_command: Optional[float] = None, + roll_distance: Optional[float] = None, + ) -> Tuple[List[int], List[int], List[int], List[int], List[int], List[int]]: + """Build waste position parameters for InitializeSmartRoll or DropTipsRoll.""" + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + if not isinstance(self.deck, NimbusDeck): + raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") + + x_positions_mm: List[float] = [] + y_positions_mm: List[float] = [] + z_positions_mm: List[float] = [] + + for channel_idx in use_channels: + if not hasattr(self.deck, "waste_type") or self.deck.waste_type is None: + raise RuntimeError( + f"Deck does not have waste_type attribute. " + f"Cannot determine waste position for channel {channel_idx}." + ) + waste_pos_name = f"{self.deck.waste_type}_{channel_idx + 1}" + waste_pos = self.deck.get_resource(waste_pos_name) + abs_location = waste_pos.get_location_wrt(self.deck) + hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) + + x_positions_mm.append(hamilton_coord.x) + y_positions_mm.append(hamilton_coord.y) + z_positions_mm.append(hamilton_coord.z) + + x_positions = [round(x * 100) for x in x_positions_mm] + y_positions = [round(y * 100) for y in y_positions_mm] + + max_z_hamilton = max(z_positions_mm) + z_start_absolute_mm = max_z_hamilton + 4.0 + z_stop_absolute_mm = max_z_hamilton + + if z_position_at_end_of_a_command is None: + z_position_at_end_of_a_command = self.traversal_height + if roll_distance is None: + roll_distance = 9.0 + + begin_tip_deposit_process = [round(z_start_absolute_mm * 100)] * len(use_channels) + end_tip_deposit_process = [round(z_stop_absolute_mm * 100)] * len(use_channels) + z_position_at_end_list = [round(z_position_at_end_of_a_command * 100)] * len(use_channels) + roll_distances = [round(roll_distance * 100)] * len(use_channels) + + x_positions_full = self._fill_by_channels(x_positions, use_channels, default=0) + y_positions_full = self._fill_by_channels(y_positions, use_channels, default=0) + begin_full = self._fill_by_channels(begin_tip_deposit_process, use_channels, default=0) + end_full = self._fill_by_channels(end_tip_deposit_process, use_channels, default=0) + z_end_full = self._fill_by_channels(z_position_at_end_list, use_channels, default=0) + roll_full = self._fill_by_channels(roll_distances, use_channels, default=0) + + return x_positions_full, y_positions_full, begin_full, end_full, z_end_full, roll_full + + # --------------------------------------------------------------------------- + # PIPBackend interface + # --------------------------------------------------------------------------- + + async def request_tip_presence(self) -> List[Optional[bool]]: + tip_status = await self.driver.send_command(IsTipPresent(self.pipette_address)) + assert tip_status is not None, "IsTipPresent command returned None" + tip_present = tip_status.get("tip_present", []) + return [bool(v) for v in tip_present] + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + if not isinstance(tip, HamiltonTip): + return False + if tip.tip_size in {TipSize.XL}: + return False + if channel_idx >= self._num_channels: + return False + return True + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Pick up tips from the specified resource. + + Z positions are calculated from resource locations and tip properties: + - begin_tip_pick_up_process: max(resource Z) + max(tip total_tip_length) + - end_tip_pick_up_process: max(resource Z) + max(tip total_tip_length - fitting_depth) + + Checks tip presence before pickup and raises if channels already have tips. + + Args: + ops: List of Pickup operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPPickUpTipsParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``, typically 146.0 mm). + + Raises: + RuntimeError: If channels already have tips mounted. + """ + if not ops: + return + self._ensure_deck() + params = ( + backend_params + if isinstance(backend_params, NimbusPIPPickUpTipsParams) + else NimbusPIPPickUpTipsParams() + ) + + # Check tip presence before picking up + try: + tip_present = await self.request_tip_presence() + channels_with_tips = [ + i for i, present in enumerate(tip_present) if i in use_channels and present + ] + if channels_with_tips: + raise RuntimeError( + f"Cannot pick up tips: channels {channels_with_tips} already have tips mounted." + ) + except RuntimeError: + raise + except Exception as e: + logger.warning(f"Could not check tip presence before pickup: {e}") + + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + begin_tip_pick_up_process, end_tip_pick_up_process = self._compute_tip_handling_parameters( + ops, use_channels + ) + + channels_involved = [int(ch in use_channels) for ch in range(self.num_channels)] + + tip_types = [_get_tip_type_from_tip(op.tip) for op in ops] + tip_types_full = self._fill_by_channels(tip_types, use_channels, default=0) + + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + command = PickupTips( + dest=self.pipette_address, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + begin_tip_pick_up_process=begin_tip_pick_up_process, + end_tip_pick_up_process=end_tip_pick_up_process, + tip_types=tip_types_full, + ) + + await self.driver.send_command(command) + logger.info(f"Picked up tips on channels {use_channels}") + + async def drop_tips( + self, + ops: List[TipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Drop tips to the specified resource. + + Auto-detects waste positions and uses the appropriate firmware command: + - If resource is a Trash, uses **DropTipsRoll** (roll-off into waste chute). + - Otherwise, uses **DropTips** (return tips to a tip rack). + + Z positions are calculated from resource locations: + - Waste positions: Z start/stop from deck waste coordinates via ``_build_waste_position_params``. + - Regular resources: Fixed offset (max_z + 10 mm start, max_z stop) -- independent of tip + length because the tip is already mounted on the pipette. + + Cannot mix waste and regular resources in a single call. + + Args: + ops: List of TipDrop operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPDropTipsParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``). + - default_waste: For DropTips command, if True the instrument drops to the + default waste position (default: False). + - z_position_at_end_of_a_command: Z final position in mm, absolute + (default: traversal height). + - roll_distance: Roll distance in mm for DropTipsRoll (default: 9.0 mm). + + Raises: + ValueError: If operations mix waste and regular resources. + """ + if not ops: + return + self._ensure_deck() + params = ( + backend_params + if isinstance(backend_params, NimbusPIPDropTipsParams) + else NimbusPIPDropTipsParams() + ) + + # Check if resources are waste positions + is_waste_positions = [isinstance(op.resource, Trash) for op in ops] + all_waste = all(is_waste_positions) + all_regular = not any(is_waste_positions) + + if not (all_waste or all_regular): + raise ValueError( + "Cannot mix waste positions and regular resources in a single drop_tips call." + ) + + channels_involved = [int(ch in use_channels) for ch in range(self.num_channels)] + + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + command: Union[DropTips, DropTipsRoll] + + if all_waste: + ( + x_positions_full, + y_positions_full, + begin_tip_deposit_process_full, + end_tip_deposit_process_full, + z_position_at_end_of_a_command_full, + roll_distances_full, + ) = self._build_waste_position_params( + use_channels=use_channels, + z_position_at_end_of_a_command=params.z_position_at_end_of_a_command, + roll_distance=params.roll_distance, + ) + + command = DropTipsRoll( + dest=self.pipette_address, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + begin_tip_deposit_process=begin_tip_deposit_process_full, + end_tip_deposit_process=end_tip_deposit_process_full, + z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, + roll_distances=roll_distances_full, + ) + else: + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + begin_tip_deposit_process, end_tip_deposit_process = self._compute_tip_handling_parameters( + ops, use_channels, use_fixed_offset=True + ) + + z_end = params.z_position_at_end_of_a_command + if z_end is None: + z_end = traverse_height + z_position_at_end_list = [round(z_end * 100)] * len(ops) + z_position_at_end_full = self._fill_by_channels( + z_position_at_end_list, use_channels, default=0 + ) + + command = DropTips( + dest=self.pipette_address, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + begin_tip_deposit_process=begin_tip_deposit_process, + end_tip_deposit_process=end_tip_deposit_process, + z_position_at_end_of_a_command=z_position_at_end_full, + default_waste=params.default_waste, + ) + + await self.driver.send_command(command) + logger.info(f"Dropped tips on channels {use_channels}") + + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Aspirate liquid from the specified resource. + + Volumes, flow rates, blow-out air volumes, and mix parameters are taken from the + ``Aspiration`` operations. Hardware-level parameters are set via ``backend_params``. + + Args: + ops: List of Aspiration operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPAspirateParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``). + - adc_enabled: Enable Automatic Drip Control (default: False). + - lld_mode: LLD mode per channel -- 0=OFF, 1=cLLD, 2=pLLD, 3=DUAL (default: [0]*n). + - lld_search_height: **Relative offset** from well bottom (mm) where LLD search + starts. The instrument adds this to minimum_height internally. + If None, defaults to the well's size_z (i.e. search from the top of the well). + - immersion_depth: Depth to submerge below liquid surface (mm, default: [0.0]*n). + - surface_following_distance: Distance to follow liquid surface during aspiration + (mm, default: [0.0]*n). + - gamma_lld_sensitivity: Gamma LLD sensitivity, 1-4 (default: [0]*n). + - dp_lld_sensitivity: Differential-pressure LLD sensitivity, 1-4 (default: [0]*n). + - settling_time: Settling time after aspiration (s, default: [1.0]*n). + - transport_air_volume: Transport air volume (uL, default: [5.0]*n). + - pre_wetting_volume: Pre-wetting volume (uL, default: [0.0]*n). + - swap_speed: Swap speed on leaving liquid (uL/s, default: [20.0]*n). + - mix_position_from_liquid_surface: Mix position offset from liquid surface + (mm, default: [0.0]*n). + - limit_curve_index: Limit curve index (default: [0]*n). + - tadm_enabled: Enable TADM (Total Aspiration and Dispense Monitoring) + (default: False). + """ + if not ops: + return + params = ( + backend_params + if isinstance(backend_params, NimbusPIPAspirateParams) + else NimbusPIPAspirateParams() + ) + + n = len(ops) + + channels_involved = [0] * self.num_channels + for channel_idx in use_channels: + channels_involved[channel_idx] = 1 + + # ADC control + if params.adc_enabled: + await self.driver.send_command(EnableADC(self.pipette_address, channels_involved)) + else: + await self.driver.send_command(DisableADC(self.pipette_address, channels_involved)) + + # Query channel configurations + if self._channel_configurations is None: + self._channel_configurations = {} + for channel_idx in use_channels: + channel_num = channel_idx + 1 + try: + config = await self.driver.send_command( + GetChannelConfiguration(self.pipette_address, channel=channel_num, indexes=[2]) + ) + assert config is not None + enabled = config["enabled"][0] if config["enabled"] else False + if channel_num not in self._channel_configurations: + self._channel_configurations[channel_num] = {} + self._channel_configurations[channel_num][2] = enabled + except Exception as e: + logger.warning(f"Failed to get channel config for channel {channel_num}: {e}") + + # Compute XY positions + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + + # Traverse height + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + deck = self._ensure_deck() + + # Well bottoms + well_bottoms = [] + for op in ops: + abs_location = op.resource.get_location_wrt(deck) + op.offset + if isinstance(op.resource, Container): + abs_location.z += op.resource.material_z_thickness + hamilton_coord = deck.to_hamilton_coordinate(abs_location) + well_bottoms.append(hamilton_coord.z) + + liquid_heights_mm = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] + + lld_search_height = params.lld_search_height + if lld_search_height is None: + lld_search_height = [op.resource.get_absolute_size_z() for op in ops] + + minimum_heights_mm = well_bottoms.copy() + + volumes = [op.volume for op in ops] + flow_rates: List[float] = [ + op.flow_rate if op.flow_rate is not None else _get_default_flow_rate(op.tip, is_aspirate=True) + for op in ops + ] + blow_out_air_volumes = [ + op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops + ] + + mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_speed: List[float] = [ + op.mix.flow_rate + if op.mix is not None + else ( + op.flow_rate + if op.flow_rate is not None + else _get_default_flow_rate(op.tip, is_aspirate=True) + ) + for op in ops + ] + + # Advanced parameters + lld_mode = _fill_in_defaults(params.lld_mode, [0] * n) + immersion_depth = _fill_in_defaults(params.immersion_depth, [0.0] * n) + surface_following_distance = _fill_in_defaults(params.surface_following_distance, [0.0] * n) + gamma_lld_sensitivity = _fill_in_defaults(params.gamma_lld_sensitivity, [0] * n) + dp_lld_sensitivity = _fill_in_defaults(params.dp_lld_sensitivity, [0] * n) + settling_time = _fill_in_defaults(params.settling_time, [1.0] * n) + transport_air_volume = _fill_in_defaults(params.transport_air_volume, [5.0] * n) + pre_wetting_volume = _fill_in_defaults(params.pre_wetting_volume, [0.0] * n) + swap_speed = _fill_in_defaults(params.swap_speed, [20.0] * n) + mix_position_from_liquid_surface = _fill_in_defaults( + params.mix_position_from_liquid_surface, [0.0] * n + ) + limit_curve_index = _fill_in_defaults(params.limit_curve_index, [0] * n) + + # Unit conversions + aspirate_volumes = [round(vol * 10) for vol in volumes] + blow_out_air_volumes_units = [round(vol * 10) for vol in blow_out_air_volumes] + aspiration_speeds = [round(fr * 10) for fr in flow_rates] + lld_search_height_units = [round(h * 100) for h in lld_search_height] + liquid_height_units = [round(h * 100) for h in liquid_heights_mm] + immersion_depth_units = [round(d * 100) for d in immersion_depth] + surface_following_distance_units = [round(d * 100) for d in surface_following_distance] + minimum_height_units = [round(z * 100) for z in minimum_heights_mm] + settling_time_units = [round(t * 10) for t in settling_time] + transport_air_volume_units = [round(v * 10) for v in transport_air_volume] + pre_wetting_volume_units = [round(v * 10) for v in pre_wetting_volume] + swap_speed_units = [round(s * 10) for s in swap_speed] + mix_volume_units = [round(v * 10) for v in mix_volume] + mix_speed_units = [round(s * 10) for s in mix_speed] + mix_position_from_liquid_surface_units = [ + round(p * 100) for p in mix_position_from_liquid_surface + ] + + # Build full-channel arrays + aspirate_volumes_full = self._fill_by_channels(aspirate_volumes, use_channels, default=0) + blow_out_air_volumes_full = self._fill_by_channels( + blow_out_air_volumes_units, use_channels, default=0 + ) + aspiration_speeds_full = self._fill_by_channels(aspiration_speeds, use_channels, default=0) + lld_search_height_full = self._fill_by_channels( + lld_search_height_units, use_channels, default=0 + ) + liquid_height_full = self._fill_by_channels(liquid_height_units, use_channels, default=0) + immersion_depth_full = self._fill_by_channels(immersion_depth_units, use_channels, default=0) + surface_following_distance_full = self._fill_by_channels( + surface_following_distance_units, use_channels, default=0 + ) + minimum_height_full = self._fill_by_channels(minimum_height_units, use_channels, default=0) + settling_time_full = self._fill_by_channels(settling_time_units, use_channels, default=0) + transport_air_volume_full = self._fill_by_channels( + transport_air_volume_units, use_channels, default=0 + ) + pre_wetting_volume_full = self._fill_by_channels( + pre_wetting_volume_units, use_channels, default=0 + ) + swap_speed_full = self._fill_by_channels(swap_speed_units, use_channels, default=0) + mix_volume_full = self._fill_by_channels(mix_volume_units, use_channels, default=0) + mix_cycles_full = self._fill_by_channels(mix_cycles, use_channels, default=0) + mix_speed_full = self._fill_by_channels(mix_speed_units, use_channels, default=0) + mix_position_from_liquid_surface_full = self._fill_by_channels( + mix_position_from_liquid_surface_units, use_channels, default=0 + ) + gamma_lld_sensitivity_full = self._fill_by_channels( + gamma_lld_sensitivity, use_channels, default=0 + ) + dp_lld_sensitivity_full = self._fill_by_channels(dp_lld_sensitivity, use_channels, default=0) + limit_curve_index_full = self._fill_by_channels(limit_curve_index, use_channels, default=0) + lld_mode_full = self._fill_by_channels(lld_mode, use_channels, default=0) + + # Default values for remaining parameters + aspirate_type = [0] * self.num_channels + clot_detection_height = [0] * self.num_channels + min_z_endpos = traverse_height_units + mix_surface_following_distance = [0] * self.num_channels + tube_section_height = [0] * self.num_channels + tube_section_ratio = [0] * self.num_channels + lld_height_difference = [0] * self.num_channels + recording_mode = 0 + + command = Aspirate( + dest=self.pipette_address, + aspirate_type=aspirate_type, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + lld_search_height=lld_search_height_full, + liquid_height=liquid_height_full, + immersion_depth=immersion_depth_full, + surface_following_distance=surface_following_distance_full, + minimum_height=minimum_height_full, + clot_detection_height=clot_detection_height, + min_z_endpos=min_z_endpos, + swap_speed=swap_speed_full, + blow_out_air_volume=blow_out_air_volumes_full, + pre_wetting_volume=pre_wetting_volume_full, + aspirate_volume=aspirate_volumes_full, + transport_air_volume=transport_air_volume_full, + aspiration_speed=aspiration_speeds_full, + settling_time=settling_time_full, + mix_volume=mix_volume_full, + mix_cycles=mix_cycles_full, + mix_position_from_liquid_surface=mix_position_from_liquid_surface_full, + mix_surface_following_distance=mix_surface_following_distance, + mix_speed=mix_speed_full, + tube_section_height=tube_section_height, + tube_section_ratio=tube_section_ratio, + lld_mode=lld_mode_full, + gamma_lld_sensitivity=gamma_lld_sensitivity_full, + dp_lld_sensitivity=dp_lld_sensitivity_full, + lld_height_difference=lld_height_difference, + tadm_enabled=params.tadm_enabled, + limit_curve_index=limit_curve_index_full, + recording_mode=recording_mode, + ) + + await self.driver.send_command(command) + logger.info(f"Aspirated on channels {use_channels}") + + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Dispense liquid to the specified resource. + + Volumes, flow rates, blow-out air volumes, and mix parameters are taken from the + ``Dispense`` operations. Hardware-level parameters are set via ``backend_params``. + + Args: + ops: List of Dispense operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPDispenseParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``). + - adc_enabled: Enable Automatic Drip Control (default: False). + - lld_mode: LLD mode per channel -- 0=OFF, 1=cLLD, 2=pLLD, 3=DUAL (default: [0]*n). + - lld_search_height: **Relative offset** from well bottom (mm) where LLD search + starts. If None, defaults to the well's size_z. + - immersion_depth: Depth to submerge below liquid surface (mm, default: [0.0]*n). + - surface_following_distance: Distance to follow liquid surface during dispense + (mm, default: [0.0]*n). + - gamma_lld_sensitivity: Gamma LLD sensitivity, 1-4 (default: [0]*n). + - settling_time: Settling time after dispense (s, default: [1.0]*n). + - transport_air_volume: Transport air volume (uL, default: [5.0]*n). + - swap_speed: Swap speed on leaving liquid (uL/s, default: [20.0]*n). + - mix_position_from_liquid_surface: Mix position offset from liquid surface + (mm, default: [0.0]*n). + - limit_curve_index: Limit curve index (default: [0]*n). + - tadm_enabled: Enable TADM (default: False). + - cut_off_speed: Cut-off speed at end of dispense (uL/s, default: [25.0]*n). + - stop_back_volume: Stop-back volume to prevent dripping (uL, default: [0.0]*n). + - side_touch_off_distance: Side touch-off distance (mm, default: 0.0). + - dispense_offset: Dispense Z offset (mm, default: [0.0]*n). + """ + if not ops: + return + params = ( + backend_params + if isinstance(backend_params, NimbusPIPDispenseParams) + else NimbusPIPDispenseParams() + ) + + n = len(ops) + + channels_involved = [0] * self.num_channels + for channel_idx in use_channels: + channels_involved[channel_idx] = 1 + + # ADC control + if params.adc_enabled: + await self.driver.send_command(EnableADC(self.pipette_address, channels_involved)) + else: + await self.driver.send_command(DisableADC(self.pipette_address, channels_involved)) + + # Query channel configurations + if self._channel_configurations is None: + self._channel_configurations = {} + for channel_idx in use_channels: + channel_num = channel_idx + 1 + try: + config = await self.driver.send_command( + GetChannelConfiguration(self.pipette_address, channel=channel_num, indexes=[2]) + ) + assert config is not None + enabled = config["enabled"][0] if config["enabled"] else False + if channel_num not in self._channel_configurations: + self._channel_configurations[channel_num] = {} + self._channel_configurations[channel_num][2] = enabled + except Exception as e: + logger.warning(f"Failed to get channel config for channel {channel_num}: {e}") + + # Compute XY positions + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + + # Traverse height + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + deck = self._ensure_deck() + + # Well bottoms + well_bottoms = [] + for op in ops: + abs_location = op.resource.get_location_wrt(deck) + op.offset + if isinstance(op.resource, Container): + abs_location.z += op.resource.material_z_thickness + hamilton_coord = deck.to_hamilton_coordinate(abs_location) + well_bottoms.append(hamilton_coord.z) + + liquid_heights_mm = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] + + lld_search_height = params.lld_search_height + if lld_search_height is None: + lld_search_height = [op.resource.get_absolute_size_z() for op in ops] + + minimum_heights_mm = well_bottoms.copy() + + volumes = [op.volume for op in ops] + flow_rates: List[float] = [ + op.flow_rate + if op.flow_rate is not None + else _get_default_flow_rate(op.tip, is_aspirate=False) + for op in ops + ] + blow_out_air_volumes = [ + op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops + ] + + mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_speed: List[float] = [ + op.mix.flow_rate + if op.mix is not None + else ( + op.flow_rate + if op.flow_rate is not None + else _get_default_flow_rate(op.tip, is_aspirate=False) + ) + for op in ops + ] + + # Advanced parameters + lld_mode = _fill_in_defaults(params.lld_mode, [0] * n) + immersion_depth = _fill_in_defaults(params.immersion_depth, [0.0] * n) + surface_following_distance = _fill_in_defaults(params.surface_following_distance, [0.0] * n) + gamma_lld_sensitivity = _fill_in_defaults(params.gamma_lld_sensitivity, [0] * n) + settling_time = _fill_in_defaults(params.settling_time, [1.0] * n) + transport_air_volume = _fill_in_defaults(params.transport_air_volume, [5.0] * n) + swap_speed = _fill_in_defaults(params.swap_speed, [20.0] * n) + mix_position_from_liquid_surface = _fill_in_defaults( + params.mix_position_from_liquid_surface, [0.0] * n + ) + limit_curve_index = _fill_in_defaults(params.limit_curve_index, [0] * n) + cut_off_speed = _fill_in_defaults(params.cut_off_speed, [25.0] * n) + stop_back_volume = _fill_in_defaults(params.stop_back_volume, [0.0] * n) + dispense_offset = _fill_in_defaults(params.dispense_offset, [0.0] * n) + + # Unit conversions + dispense_volumes = [round(vol * 10) for vol in volumes] + blow_out_air_volumes_units = [round(vol * 10) for vol in blow_out_air_volumes] + dispense_speeds = [round(fr * 10) for fr in flow_rates] + lld_search_height_units = [round(h * 100) for h in lld_search_height] + liquid_height_units = [round(h * 100) for h in liquid_heights_mm] + immersion_depth_units = [round(d * 100) for d in immersion_depth] + surface_following_distance_units = [round(d * 100) for d in surface_following_distance] + minimum_height_units = [round(z * 100) for z in minimum_heights_mm] + settling_time_units = [round(t * 10) for t in settling_time] + transport_air_volume_units = [round(v * 10) for v in transport_air_volume] + swap_speed_units = [round(s * 10) for s in swap_speed] + mix_volume_units = [round(v * 10) for v in mix_volume] + mix_speed_units = [round(s * 10) for s in mix_speed] + mix_position_from_liquid_surface_units = [ + round(p * 100) for p in mix_position_from_liquid_surface + ] + cut_off_speed_units = [round(s * 10) for s in cut_off_speed] + stop_back_volume_units = [round(v * 10) for v in stop_back_volume] + dispense_offset_units = [round(o * 100) for o in dispense_offset] + side_touch_off_distance_units = round(params.side_touch_off_distance * 100) + + # Build full-channel arrays + dispense_volumes_full = self._fill_by_channels(dispense_volumes, use_channels, default=0) + blow_out_air_volumes_full = self._fill_by_channels( + blow_out_air_volumes_units, use_channels, default=0 + ) + dispense_speeds_full = self._fill_by_channels(dispense_speeds, use_channels, default=0) + lld_search_height_full = self._fill_by_channels( + lld_search_height_units, use_channels, default=0 + ) + liquid_height_full = self._fill_by_channels(liquid_height_units, use_channels, default=0) + immersion_depth_full = self._fill_by_channels(immersion_depth_units, use_channels, default=0) + surface_following_distance_full = self._fill_by_channels( + surface_following_distance_units, use_channels, default=0 + ) + minimum_height_full = self._fill_by_channels(minimum_height_units, use_channels, default=0) + settling_time_full = self._fill_by_channels(settling_time_units, use_channels, default=0) + transport_air_volume_full = self._fill_by_channels( + transport_air_volume_units, use_channels, default=0 + ) + swap_speed_full = self._fill_by_channels(swap_speed_units, use_channels, default=0) + mix_volume_full = self._fill_by_channels(mix_volume_units, use_channels, default=0) + mix_cycles_full = self._fill_by_channels(mix_cycles, use_channels, default=0) + mix_speed_full = self._fill_by_channels(mix_speed_units, use_channels, default=0) + mix_position_from_liquid_surface_full = self._fill_by_channels( + mix_position_from_liquid_surface_units, use_channels, default=0 + ) + gamma_lld_sensitivity_full = self._fill_by_channels( + gamma_lld_sensitivity, use_channels, default=0 + ) + limit_curve_index_full = self._fill_by_channels(limit_curve_index, use_channels, default=0) + lld_mode_full = self._fill_by_channels(lld_mode, use_channels, default=0) + cut_off_speed_full = self._fill_by_channels(cut_off_speed_units, use_channels, default=0) + stop_back_volume_full = self._fill_by_channels(stop_back_volume_units, use_channels, default=0) + dispense_offset_full = self._fill_by_channels(dispense_offset_units, use_channels, default=0) + + # Default values + dispense_type = [0] * self.num_channels + min_z_endpos = traverse_height_units + mix_surface_following_distance = [0] * self.num_channels + tube_section_height = [0] * self.num_channels + tube_section_ratio = [0] * self.num_channels + recording_mode = 0 + + command = DispenseCommand( + dest=self.pipette_address, + dispense_type=dispense_type, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + lld_search_height=lld_search_height_full, + liquid_height=liquid_height_full, + immersion_depth=immersion_depth_full, + surface_following_distance=surface_following_distance_full, + minimum_height=minimum_height_full, + min_z_endpos=min_z_endpos, + swap_speed=swap_speed_full, + transport_air_volume=transport_air_volume_full, + dispense_volume=dispense_volumes_full, + stop_back_volume=stop_back_volume_full, + blow_out_air_volume=blow_out_air_volumes_full, + dispense_speed=dispense_speeds_full, + cut_off_speed=cut_off_speed_full, + settling_time=settling_time_full, + mix_volume=mix_volume_full, + mix_cycles=mix_cycles_full, + mix_position_from_liquid_surface=mix_position_from_liquid_surface_full, + mix_surface_following_distance=mix_surface_following_distance, + mix_speed=mix_speed_full, + side_touch_off_distance=side_touch_off_distance_units, + dispense_offset=dispense_offset_full, + tube_section_height=tube_section_height, + tube_section_ratio=tube_section_ratio, + lld_mode=lld_mode_full, + gamma_lld_sensitivity=gamma_lld_sensitivity_full, + tadm_enabled=params.tadm_enabled, + limit_curve_index=limit_curve_index_full, + recording_mode=recording_mode, + ) + + await self.driver.send_command(command) + logger.info(f"Dispensed on channels {use_channels}") diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/liquid_handlers/tcp_base.py b/pylabrobot/hamilton/liquid_handlers/tcp_base.py new file mode 100644 index 00000000000..abf0b7f9053 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/tcp_base.py @@ -0,0 +1,585 @@ +"""Hamilton TCP Handler base class for TCP-based instruments (Nimbus, Prep, etc.).""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Dict, Optional, Union + +from pylabrobot.device import Driver +from pylabrobot.io.binary import Reader +from pylabrobot.io.socket import Socket +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.messages import ( + CommandResponse, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, +) +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.hamilton.tcp.protocol import ( + Hoi2Action, + HoiRequestId, + RegistrationActionCode, + RegistrationOptionType, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class HamiltonError: + """Hamilton error response.""" + + error_code: int + error_message: str + interface_id: int + action_id: int + + +class ErrorParser: + """Parse Hamilton error responses.""" + + @staticmethod + def parse_error(data: bytes) -> HamiltonError: + """Parse error response from Hamilton instrument.""" + # Error responses have a specific format + # This is a simplified implementation - real errors may vary + if len(data) < 8: + raise ValueError("Error response too short") + + # Parse error structure (simplified) + error_code = Reader(data).u32() + error_message = data[4:].decode("utf-8", errors="replace") + + return HamiltonError( + error_code=error_code, error_message=error_message, interface_id=0, action_id=0 + ) + + +class HamiltonTCPHandler(Driver): + """Base driver for all Hamilton TCP instruments. + + Hamilton TCP instruments include the Nimbus and the Prep, using Hoi and Harp. + STAR and Vantage use the other Hamilton protocol that works over USB. + + This class provides: + - Connection management via Socket (wrapped with state tracking) + - Protocol 7 initialization + - Protocol 3 registration + - Generic command execution + - Object discovery via introspection + + Hamilton uses strict request-response protocol (no unsolicited messages), + so we use simple direct read/write instead of complex routing. + """ + + def __init__( + self, + host: str, + port: int, + read_timeout: float = 30.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + ): + """Initialize Hamilton TCP handler. + + Args: + host: Hamilton instrument IP address + port: Hamilton instrument port + read_timeout: Read timeout in seconds + write_timeout: Write timeout in seconds + auto_reconnect: Enable automatic reconnection + max_reconnect_attempts: Maximum reconnection attempts + """ + + super().__init__() + + self.io = Socket( + human_readable_device_name="Hamilton Liquid Handler", + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + + # Connection state tracking (wrapping Socket) + self._connected = False + self._reconnect_attempts = 0 + self.auto_reconnect = auto_reconnect + self.max_reconnect_attempts = max_reconnect_attempts + + # Hamilton-specific state + self._client_id: Optional[int] = None + self.client_address: Optional[Address] = None + self._sequence_numbers: Dict[Address, int] = {} + self._discovered_objects: Dict[str, list[Address]] = {} + + # Instrument-specific addresses (set by subclasses) + self._instrument_addresses: Dict[str, Address] = {} + + async def _ensure_connected(self): + """Ensure connection is healthy before operations.""" + if not self._connected: + if not self.auto_reconnect: + raise ConnectionError( + f"{self.io._unique_id} Connection not established and auto-reconnect disabled" + ) + logger.info(f"{self.io._unique_id} Connection not established, attempting to reconnect...") + await self._reconnect() + + async def _reconnect(self): + """Attempt to reconnect with exponential backoff.""" + if not self.auto_reconnect: + raise ConnectionError(f"{self.io._unique_id} Auto-reconnect disabled") + + for attempt in range(self.max_reconnect_attempts): + try: + logger.info( + f"{self.io._unique_id} Reconnection attempt {attempt + 1}/{self.max_reconnect_attempts}" + ) + + # Clean up existing connection + try: + await self.stop() + except Exception: + pass + + # Wait before reconnecting (exponential backoff) + if attempt > 0: + wait_time = 1.0 * (2 ** (attempt - 1)) # 1s, 2s, 4s, etc. + await asyncio.sleep(wait_time) + + # Attempt to reconnect + await self.setup() + self._reconnect_attempts = 0 + logger.info(f"{self.io._unique_id} Reconnection successful") + return + + except Exception as e: + logger.warning(f"{self.io._unique_id} Reconnection attempt {attempt + 1} failed: {e}") + + # All reconnection attempts failed + self._connected = False + raise ConnectionError( + f"{self.io._unique_id} Failed to reconnect after {self.max_reconnect_attempts} attempts" + ) + + async def write(self, data: bytes, timeout: Optional[float] = None): + """Write data to the socket with connection state tracking. + + Args: + data: The data to write. + timeout: The timeout for writing to the server in seconds. If `None`, use the default timeout. + """ + await self._ensure_connected() + + try: + await self.io.write(data, timeout=timeout) + self._connected = True + except (ConnectionError, OSError, TimeoutError): + self._connected = False + raise + + async def read(self, num_bytes: int = 128, timeout: Optional[float] = None) -> bytes: + """Read data from the socket with connection state tracking. + + Args: + num_bytes: Maximum number of bytes to read. Defaults to 128. + timeout: The timeout for reading from the server in seconds. If `None`, use the default + timeout. + + Returns: + The data read from the socket. + """ + await self._ensure_connected() + + try: + data = await self.io.read(num_bytes, timeout=timeout) + self._connected = True + return data + except (ConnectionError, OSError, TimeoutError): + self._connected = False + raise + + async def read_exact(self, num_bytes: int, timeout: Optional[float] = None) -> bytes: + """Read exactly num_bytes with connection state tracking. + + Args: + num_bytes: The exact number of bytes to read. + timeout: The timeout for reading from the server in seconds. If `None`, use the default + timeout. + + Returns: + Exactly num_bytes of data. + + Raises: + ConnectionError: If the connection is closed before num_bytes are read. + """ + await self._ensure_connected() + + try: + data = await self.io.read_exact(num_bytes, timeout=timeout) + self._connected = True + return data + except (ConnectionError, OSError, TimeoutError): + self._connected = False + raise + + @property + def is_connected(self) -> bool: + """Check if the connection is currently established.""" + return self._connected + + async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse]: + """Read one complete Hamilton packet and parse based on protocol. + + Hamilton packets are length-prefixed: + - First 2 bytes: packet size (little-endian) + - Next packet_size bytes: packet payload + + The method inspects the IP protocol field and, for Protocol 6 (HARP), + also checks the HARP protocol field to dispatch correctly. + + Returns: + Union[RegistrationResponse, CommandResponse]: Parsed response + + Raises: + ConnectionError: If connection is lost + TimeoutError: If no message received within timeout + ValueError: If protocol type is unknown + """ + + # Read packet size (2 bytes, little-endian) + size_data = await self.read_exact(2) + packet_size = Reader(size_data).u16() + + # Read packet payload + payload_data = await self.read_exact(packet_size) + complete_data = size_data + payload_data + + # Parse IP packet to get protocol field (byte 2) + # Format: [size:2][ip_protocol:1][version:1][options_len:2][options:x][payload:n] + ip_protocol = complete_data[2] + + # Dispatch based on IP protocol + if ip_protocol == 6: + # Protocol 6: HARP wrapper - need to check HARP protocol field + # IP header: [size:2][protocol:1][version:1][options_len:2] + ip_options_len = int.from_bytes(complete_data[4:6], "little") + harp_start = 6 + ip_options_len + + # HARP header: [src:6][dst:6][seq:1][unk:1][harp_protocol:1][action:1]... + # HARP protocol is at offset 14 within HARP packet + harp_protocol_offset = harp_start + 14 + harp_protocol = complete_data[harp_protocol_offset] + + if harp_protocol == 2: + # HARP Protocol 2: HOI2 + return CommandResponse.from_bytes(complete_data) + if harp_protocol == 3: + # HARP Protocol 3: Registration2 + return RegistrationResponse.from_bytes(complete_data) + logger.warning(f"Unknown HARP protocol: {harp_protocol}, attempting CommandResponse parse") + return CommandResponse.from_bytes(complete_data) + + logger.warning(f"Unknown IP protocol: {ip_protocol}, attempting CommandResponse parse") + return CommandResponse.from_bytes(complete_data) + + async def setup(self): + """Initialize Hamilton connection and discover objects. + + Hamilton uses strict request-response protocol: + 1. Establish TCP connection + 2. Protocol 7 initialization (get client ID) + 3. Protocol 3 registration + 4. Discover objects via Protocol 3 introspection + """ + + # Step 1: Establish TCP connection + await self.io.setup() + + # Set connection state after successful connection + self._connected = True + self._reconnect_attempts = 0 + + # Step 2: Initialize connection (Protocol 7) + await self._initialize_connection() + + # Step 3: Register client (Protocol 3) + await self._register_client() + + # Step 4: Discover root objects + await self._discover_root() + + logger.info(f"Hamilton handler setup complete. Client ID: {self._client_id}") + + async def _initialize_connection(self): + """Initialize connection using Protocol 7 (ConnectionPacket). + + Note: Protocol 7 doesn't have sequence numbers, so we send the packet + and read the response directly (blocking) rather than using the + normal routing mechanism. + """ + logger.info("Initializing Hamilton connection...") + + # Build Protocol 7 ConnectionPacket using new InitMessage + packet = InitMessage(timeout=30).build() + + logger.info("[INIT] Sending Protocol 7 initialization packet:") + logger.info(f"[INIT] Length: {len(packet)} bytes") + logger.info(f"[INIT] Hex: {packet.hex(' ')}") + + # Send packet + await self.write(packet) + + # Read response directly (blocking - safe because this is first communication) + # Read packet size (2 bytes, little-endian) + size_data = await self.read_exact(2) + packet_size = Reader(size_data).u16() + + # Read packet payload + payload_data = await self.read_exact(packet_size) + response_bytes = size_data + payload_data + + logger.info("[INIT] Received response:") + logger.info(f"[INIT] Length: {len(response_bytes)} bytes") + logger.info(f"[INIT] Hex: {response_bytes.hex(' ')}") + + # Parse response using InitResponse + response = InitResponse.from_bytes(response_bytes) + + self._client_id = response.client_id + # Controller module is 2, node is client_id, object 65535 for general addressing + self.client_address = Address(2, response.client_id, 65535) + + logger.info(f"[INIT] Client ID: {self._client_id}, Address: {self.client_address}") + + async def _register_client(self): + """Register client using Protocol 3.""" + logger.info("Registering Hamilton client...") + + # Registration service address (DLL uses 0:0:65534, Piglet comment confirms) + registration_service = Address(0, 0, 65534) + + # Step 1: Initial registration (action_code=0) + reg_msg = RegistrationMessage( + dest=registration_service, action_code=RegistrationActionCode.REGISTRATION_REQUEST + ) + + # Ensure client is initialized + if self.client_address is None or self._client_id is None: + raise RuntimeError("Client not initialized - call _initialize_connection() first") + + # Build and send registration packet + seq = self._allocate_sequence_number(registration_service) + packet = reg_msg.build( + src=self.client_address, + req_addr=Address(2, self._client_id, 65535), # C# DLL: 2:{client_id}:65535 + res_addr=Address(0, 0, 0), # C# DLL: 0:0:0 + seq=seq, + harp_action_code=3, # COMMAND_REQUEST + harp_response_required=False, # DLL uses 0x03 (no response flag) + ) + + logger.info("[REGISTER] Sending registration packet:") + logger.info(f"[REGISTER] Length: {len(packet)} bytes, Seq: {seq}") + logger.info(f"[REGISTER] Hex: {packet.hex(' ')}") + logger.info(f"[REGISTER] Src: {self.client_address}, Dst: {registration_service}") + + # Send registration packet + await self.write(packet) + + # Read response + response = await self._read_one_message() + + logger.info("[REGISTER] Received response:") + logger.info(f"[REGISTER] Length: {len(response.raw_bytes)} bytes") + logger.debug(f"[REGISTER] Hex: {response.raw_bytes.hex(' ')}") + + logger.info("[REGISTER] Registration complete") + + async def _discover_root(self): + """Discover root objects via Protocol 3 HARP_PROTOCOL_REQUEST""" + logger.info("Discovering Hamilton root objects...") + + registration_service = Address(0, 0, 65534) + + # Request root objects (request_id=1) + root_msg = RegistrationMessage( + dest=registration_service, action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST + ) + root_msg.add_registration_option( + RegistrationOptionType.HARP_PROTOCOL_REQUEST, + protocol=2, + request_id=HoiRequestId.ROOT_OBJECT_OBJECT_ID, + ) + + # Ensure client is initialized + if self.client_address is None or self._client_id is None: + raise RuntimeError("Client not initialized - call _initialize_connection() first") + + seq = self._allocate_sequence_number(registration_service) + packet = root_msg.build( + src=self.client_address, + req_addr=Address(0, 0, 0), + res_addr=Address(0, 0, 0), + seq=seq, + harp_action_code=3, # COMMAND_REQUEST + harp_response_required=True, # Request with response + ) + + logger.info("[DISCOVER_ROOT] Sending root object discovery:") + logger.info(f"[DISCOVER_ROOT] Length: {len(packet)} bytes, Seq: {seq}") + logger.info(f"[DISCOVER_ROOT] Hex: {packet.hex(' ')}") + + # Send request + await self.write(packet) + + # Read response + response = await self._read_one_message() + assert isinstance(response, RegistrationResponse) + + logger.debug(f"[DISCOVER_ROOT] Received response: {len(response.raw_bytes)} bytes") + + # Parse registration response to extract root object IDs + root_objects = self._parse_registration_response(response) + logger.info(f"[DISCOVER_ROOT] Found {len(root_objects)} root objects") + + # Store discovered root objects + self._discovered_objects["root"] = root_objects + + logger.info(f"Discovery complete: {len(root_objects)} root objects") + + def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: + """Parse registration response options to extract object addresses. + + From Piglet: Option type 6 (HARP_PROTOCOL_RESPONSE) contains object IDs + as a packed list of u16 values. + + Args: + response: Parsed RegistrationResponse + + Returns: + List of discovered object addresses + """ + objects: list[Address] = [] + options_data = response.registration.options + + if not options_data: + logger.debug("No options in registration response (no objects found)") + return objects + + # Parse options: [option_id:1][length:1][data:x] + reader = Reader(options_data) + + while reader.has_remaining(): + option_id = reader.u8() + length = reader.u8() + + if option_id == RegistrationOptionType.HARP_PROTOCOL_RESPONSE: + if length > 0: + # Skip padding u16 + _ = reader.u16() + + # Read object IDs (u16 each) + num_objects = (length - 2) // 2 + for _ in range(num_objects): + object_id = reader.u16() + # Objects are at Address(1, 1, object_id) + objects.append(Address(1, 1, object_id)) + else: + logger.warning(f"Unknown registration option ID: {option_id}, skipping {length} bytes") + # Skip unknown option data + reader.raw_bytes(length) + + return objects + + def _allocate_sequence_number(self, dest_address: Address) -> int: + """Allocate next sequence number for destination. + + Args: + dest_address: Destination object address + + Returns: + Next sequence number for this destination + """ + current = self._sequence_numbers.get(dest_address, 0) + next_seq = (current + 1) % 256 # Wrap at 8 bits (1 byte) + self._sequence_numbers[dest_address] = next_seq + return next_seq + + async def send_command(self, command: HamiltonCommand, timeout: float = 10.0) -> Optional[dict]: + """Send Hamilton command and wait for response. + + Sets source_address if not already set by caller (for testing). + Uses handler's client_address assigned during Protocol 7 initialization. + + Args: + command: Hamilton command to execute + timeout: Maximum time to wait for response + + Returns: + Parsed response dictionary, or None if command has no information to extract + + Raises: + TimeoutError: If no response received within timeout + HamiltonError: If command returned an error + """ + # Set source address with smart fallback + if command.source_address is None: + if self.client_address is None: + raise RuntimeError("Handler not initialized - call setup() first to assign client_address") + command.source_address = self.client_address + + # Allocate sequence number for this command + command.sequence_number = self._allocate_sequence_number(command.dest_address) + + # Build command message + message = command.build() + + # Log command parameters for debugging + log_params = command.get_log_params() + logger.info(f"{command.__class__.__name__} parameters:") + for key, value in log_params.items(): + # Format arrays nicely if very long + if isinstance(value, list) and len(value) > 8: + logger.info(f" {key}: {value[:4]}... ({len(value)} items)") + else: + logger.info(f" {key}: {value}") + + # Send command + await self.write(message) + + # Read response, honoring the per-call timeout when provided. + if timeout is None: + response_message = await self._read_one_message() + else: + response_message = await asyncio.wait_for(self._read_one_message(), timeout) + assert isinstance(response_message, CommandResponse) + + # Check for error actions + action = Hoi2Action(response_message.hoi.action_code) + if action in ( + Hoi2Action.STATUS_EXCEPTION, + Hoi2Action.COMMAND_EXCEPTION, + Hoi2Action.INVALID_ACTION_RESPONSE, + ): + error_message = f"Error response (action={action:#x}): {response_message.hoi.params.hex()}" + logger.error(f"Hamilton error {action}: {error_message}") + raise RuntimeError(f"Hamilton error {action}: {error_message}") + + return command.interpret_response(response_message) + + async def stop(self): + """Stop the handler and close connection.""" + try: + await self.io.stop() + except Exception as e: + logger.warning(f"Error during stop: {e}") + finally: + self._connected = False + logger.info("Hamilton handler stopped") diff --git a/pylabrobot/hamilton/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py new file mode 100644 index 00000000000..35658d754b5 --- /dev/null +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -0,0 +1,24 @@ +"""Shared Hamilton TCP protocol layer for TCP-based instruments (Nimbus, Prep, etc.).""" + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection +from pylabrobot.hamilton.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParams, + HoiParamsParser, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, +) +from pylabrobot.hamilton.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket +from pylabrobot.hamilton.tcp.protocol import ( + Hoi2Action, + HamiltonDataType, + HamiltonProtocol, + HarpTransportableProtocol, + HoiRequestId, + RegistrationActionCode, + RegistrationOptionType, +) diff --git a/pylabrobot/hamilton/tcp/commands.py b/pylabrobot/hamilton/tcp/commands.py new file mode 100644 index 00000000000..1867d2149ca --- /dev/null +++ b/pylabrobot/hamilton/tcp/commands.py @@ -0,0 +1,179 @@ +"""Hamilton command architecture using new simplified TCP stack. + +This module provides the HamiltonCommand base class that uses the new refactored +architecture: Wire -> HoiParams -> Packets -> Messages -> Commands. +""" + +from __future__ import annotations + +import inspect +from typing import Optional + +from pylabrobot.hamilton.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParams, +) +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.hamilton.tcp.protocol import HamiltonProtocol + + +class HamiltonCommand: + """Base class for Hamilton commands using new simplified architecture. + + This replaces the old HamiltonCommand from tcp_codec.py with a cleaner design: + - Explicitly uses CommandMessage for building packets + - build_parameters() returns HoiParams object (not bytes) + - Uses Address instead of ObjectAddress + - Cleaner separation of concerns + + Example: + class MyCommand(HamiltonCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 42 + + def __init__(self, dest: Address, value: int): + super().__init__(dest) + self.value = value + + def build_parameters(self) -> HoiParams: + return HoiParams().i32(self.value) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + parser = HoiParamsParser(data) + _, result = parser.parse_next() + return {'result': result} + """ + + # Class-level attributes that subclasses must override + protocol: Optional[HamiltonProtocol] = None + interface_id: Optional[int] = None + command_id: Optional[int] = None + + # Action configuration (can be overridden by subclasses) + action_code: int = 3 # Default: COMMAND_REQUEST + harp_protocol: int = 2 # Default: HOI2 + ip_protocol: int = 6 # Default: OBJECT_DISCOVERY + + def __init__(self, dest: Address): + """Initialize Hamilton command. + + Args: + dest: Destination address for this command + """ + if self.protocol is None: + raise ValueError(f"{self.__class__.__name__} must define protocol") + if self.interface_id is None: + raise ValueError(f"{self.__class__.__name__} must define interface_id") + if self.command_id is None: + raise ValueError(f"{self.__class__.__name__} must define command_id") + + self.dest = dest + self.dest_address = dest # Alias for compatibility + self.sequence_number = 0 + self.source_address: Optional[Address] = None + + def build_parameters(self) -> HoiParams: + """Build HOI parameters for this command. + + Override this method in subclasses to provide command-specific parameters. + Return a HoiParams object (not bytes!). + + Returns: + HoiParams object with command parameters + """ + return HoiParams() + + def get_log_params(self) -> dict: + """Get parameters to log for this command. + + Lazily computes the parameters by inspecting the __init__ signature + and reading current attribute values from self. + + Subclasses can override to customize formatting (e.g., unit conversions, + array truncation). + + Returns: + Dictionary of parameter names to values + """ + exclude = {"self", "dest"} + sig = inspect.signature(type(self).__init__) + params = {} + for param_name in sig.parameters: + if param_name not in exclude and hasattr(self, param_name): + params[param_name] = getattr(self, param_name) + return params + + def build( + self, src: Optional[Address] = None, seq: Optional[int] = None, response_required: bool = True + ) -> bytes: + """Build complete Hamilton message using CommandMessage. + + Args: + src: Source address (uses self.source_address if None) + seq: Sequence number (uses self.sequence_number if None) + response_required: Whether a response is expected + + Returns: + Complete packet bytes ready to send over TCP + """ + # Use instance attributes if not provided + source = src if src is not None else self.source_address + sequence = seq if seq is not None else self.sequence_number + + if source is None: + raise ValueError("Source address not set - backend should set this before building") + + # Ensure required attributes are set (they should be by subclasses) + if self.interface_id is None: + raise ValueError(f"{self.__class__.__name__} must define interface_id") + if self.command_id is None: + raise ValueError(f"{self.__class__.__name__} must define command_id") + + # Build parameters using command-specific logic + params = self.build_parameters() + + # Create CommandMessage and set parameters directly + # This avoids wasteful serialization/parsing round-trip + msg = CommandMessage( + dest=self.dest, + interface_id=self.interface_id, + method_id=self.command_id, + params=params, + action_code=self.action_code, + harp_protocol=self.harp_protocol, + ip_protocol=self.ip_protocol, + ) + + # Build final packet + return msg.build(source, sequence, harp_response_required=response_required) + + def interpret_response(self, response: CommandResponse) -> Optional[dict]: + """Interpret success response. + + This is the new interface used by the backend. Default implementation + directly calls parse_response_parameters for efficiency. + + Args: + response: CommandResponse from network + + Returns: + Dictionary with parsed response data, or None if no data to extract + """ + return self.parse_response_parameters(response.hoi.params) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> Optional[dict]: + """Parse response parameters from HOI payload. + + Override this method in subclasses to parse command-specific responses. + + Args: + data: Raw bytes from HOI fragments field + + Returns: + Dictionary with parsed response data, or None if no data to extract + """ + return None diff --git a/pylabrobot/hamilton/tcp/introspection.py b/pylabrobot/hamilton/tcp/introspection.py new file mode 100644 index 00000000000..fd6fedb04ab --- /dev/null +++ b/pylabrobot/hamilton/tcp/introspection.py @@ -0,0 +1,832 @@ +"""Hamilton TCP Introspection API. + +This module provides dynamic discovery of Hamilton instrument capabilities +using Interface 0 introspection methods. It allows discovering available +objects, methods, interfaces, enums, and structs at runtime. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.messages import ( + HoiParams, + HoiParamsParser, +) +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.hamilton.tcp.protocol import ( + HamiltonDataType, + HamiltonProtocol, +) + +logger = logging.getLogger(__name__) + +# ============================================================================ +# TYPE RESOLUTION HELPERS +# ============================================================================ + + +def resolve_type_id(type_id: int) -> str: + """Resolve Hamilton type ID to readable name. + + Args: + type_id: Hamilton data type ID + + Returns: + Human-readable type name + """ + try: + return HamiltonDataType(type_id).name + except ValueError: + return f"UNKNOWN_TYPE_{type_id}" + + +def resolve_type_ids(type_ids: List[int]) -> List[str]: + """Resolve list of Hamilton type IDs to readable names. + + Args: + type_ids: List of Hamilton data type IDs + + Returns: + List of human-readable type names + """ + return [resolve_type_id(tid) for tid in type_ids] + + +# ============================================================================ +# INTROSPECTION TYPE MAPPING +# ============================================================================ +# Introspection type IDs are separate from HamiltonDataType wire encoding types. +# These are used for method signature display/metadata, not binary encoding. + +# Type ID ranges for categorization: +# - Argument types: Method parameters (input) +# - ReturnElement types: Multiple return values (struct fields) +# - ReturnValue types: Single return value + +_INTROSPECTION_TYPE_NAMES: dict[int, str] = { + # Argument types (1-8, 33, 41, 45, 49, 53, 61, 66, 82, 102) + 1: "i8", + 2: "u8", + 3: "i16", + 4: "u16", + 5: "i32", + 6: "u32", + 7: "str", + 8: "bytes", + 33: "bool", + 41: "List[i16]", + 45: "List[u16]", + 49: "List[i32]", + 53: "List[u32]", + 61: "List[struct]", # Complex type, needs source_id + struct_id + 66: "List[bool]", + 82: "List[enum]", # Complex type, needs source_id + enum_id + 102: "f32", + # ReturnElement types (18-24, 35, 43, 47, 51, 55, 68, 76) + 18: "u8", + 19: "i16", + 20: "u16", + 21: "i32", + 22: "u32", + 23: "str", + 24: "bytes", + 35: "bool", + 43: "List[i16]", + 47: "List[u16]", + 51: "List[i32]", + 55: "List[u32]", + 68: "List[bool]", + 76: "List[str]", + # ReturnValue types (25-32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105) + 25: "i8", + 26: "u8", + 27: "i16", + 28: "u16", + 29: "i32", + 30: "u32", + 31: "str", + 32: "bytes", + 36: "bool", + 44: "List[i16]", + 48: "List[u16]", + 52: "List[i32]", + 56: "List[u32]", + 69: "List[bool]", + 81: "enum", # Complex type, needs source_id + enum_id + 85: "enum", # Complex type, needs source_id + enum_id + 104: "f32", + 105: "f32", + # Complex types (60, 64, 78) - these need source_id + id + 60: "struct", # ReturnValue, needs source_id + struct_id + 64: "struct", # ReturnValue, needs source_id + struct_id + 78: "enum", # Argument, needs source_id + enum_id +} + +# Type ID sets for categorization +_ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 61, 66, 82, 102} +_RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 68, 76} +_RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105} +_COMPLEX_TYPE_IDS = {60, 61, 64, 78, 81, 82, 85} # Types that need additional bytes + + +def get_introspection_type_category(type_id: int) -> str: + """Get category for introspection type ID. + + Args: + type_id: Introspection type ID + + Returns: + Category: "Argument", "ReturnElement", "ReturnValue", or "Unknown" + """ + if type_id in _ARGUMENT_TYPE_IDS: + return "Argument" + elif type_id in _RETURN_ELEMENT_TYPE_IDS: + return "ReturnElement" + elif type_id in _RETURN_VALUE_TYPE_IDS: + return "ReturnValue" + else: + return "Unknown" + + +def resolve_introspection_type_name(type_id: int) -> str: + """Resolve introspection type ID to readable name. + + Args: + type_id: Introspection type ID + + Returns: + Human-readable type name + """ + return _INTROSPECTION_TYPE_NAMES.get(type_id, f"UNKNOWN_TYPE_{type_id}") + + +def is_complex_introspection_type(type_id: int) -> bool: + """Check if introspection type is complex (needs additional bytes). + + Complex types require 3 bytes total: type_id, source_id, struct_id/enum_id + + Args: + type_id: Introspection type ID + + Returns: + True if type is complex + """ + return type_id in _COMPLEX_TYPE_IDS + + +# ============================================================================ +# DATA STRUCTURES +# ============================================================================ + + +@dataclass +class ObjectInfo: + """Object metadata from introspection.""" + + name: str + version: str + method_count: int + subobject_count: int + address: Address + + +@dataclass +class MethodInfo: + """Method signature from introspection.""" + + interface_id: int + call_type: int + method_id: int + name: str + parameter_types: list[int] = field( + default_factory=list + ) # Decoded parameter type IDs (Argument category) + parameter_labels: list[str] = field(default_factory=list) # Parameter names (if available) + return_types: list[int] = field( + default_factory=list + ) # Decoded return type IDs (ReturnElement/ReturnValue category) + return_labels: list[str] = field(default_factory=list) # Return names (if available) + + def get_signature_string(self) -> str: + """Get method signature as a readable string.""" + # Decode parameter types to readable names + if self.parameter_types: + param_type_names = [resolve_introspection_type_name(tid) for tid in self.parameter_types] + + # If we have labels, use them; otherwise just show types + if self.parameter_labels and len(self.parameter_labels) == len(param_type_names): + # Format as "param1: type1, param2: type2" + params = [ + f"{label}: {type_name}" + for label, type_name in zip(self.parameter_labels, param_type_names) + ] + param_str = ", ".join(params) + else: + # Just show types + param_str = ", ".join(param_type_names) + else: + param_str = "void" + + # Decode return types to readable names + if self.return_types: + return_type_names = [resolve_introspection_type_name(tid) for tid in self.return_types] + return_categories = [get_introspection_type_category(tid) for tid in self.return_types] + + # Format return based on category + if any(cat == "ReturnElement" for cat in return_categories): + # Multiple return values -> struct format + if self.return_labels and len(self.return_labels) == len(return_type_names): + # Format as "{ label1: type1, label2: type2 }" + returns = [ + f"{label}: {type_name}" + for label, type_name in zip(self.return_labels, return_type_names) + ] + return_str = f"{{ {', '.join(returns)} }}" + else: + # Just show types + return_str = f"{{ {', '.join(return_type_names)} }}" + elif len(return_type_names) == 1: + # Single return value + if self.return_labels and len(self.return_labels) == 1: + return_str = f"{self.return_labels[0]}: {return_type_names[0]}" + else: + return_str = return_type_names[0] + else: + return_str = "void" + else: + return_str = "void" + + return f"{self.name}({param_str}) -> {return_str}" + + +@dataclass +class InterfaceInfo: + """Interface metadata from introspection.""" + + interface_id: int + name: str + version: str + + +@dataclass +class EnumInfo: + """Enum definition from introspection.""" + + enum_id: int + name: str + values: Dict[str, int] + + +@dataclass +class StructInfo: + """Struct definition from introspection.""" + + struct_id: int + name: str + fields: Dict[str, int] # field_name -> type_id + + @property + def field_type_names(self) -> Dict[str, str]: + """Get human-readable field type names.""" + return {field_name: resolve_type_id(type_id) for field_name, type_id in self.fields.items()} + + def get_struct_string(self) -> str: + """Get struct definition as a readable string.""" + field_strs = [ + f"{field_name}: {resolve_type_id(type_id)}" for field_name, type_id in self.fields.items() + ] + fields_str = "\n ".join(field_strs) if field_strs else " (empty)" + return f"struct {self.name} {{\n {fields_str}\n}}" + + +# ============================================================================ +# INTROSPECTION COMMAND CLASSES +# ============================================================================ + + +class GetObjectCommand(HamiltonCommand): + """Get object metadata (command_id=1).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 1 + action_code = 0 # QUERY + + def __init__(self, object_address: Address): + super().__init__(object_address) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_object response.""" + # Parse HOI2 DataFragments + parser = HoiParamsParser(data) + + _, name = parser.parse_next() + _, version = parser.parse_next() + _, method_count = parser.parse_next() + _, subobject_count = parser.parse_next() + + return { + "name": name, + "version": version, + "method_count": method_count, + "subobject_count": subobject_count, + } + + +class GetMethodCommand(HamiltonCommand): + """Get method signature (command_id=2).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 2 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, method_index: int): + super().__init__(object_address) + self.method_index = method_index + + def build_parameters(self) -> HoiParams: + """Build parameters for get_method command.""" + return HoiParams().u32(self.method_index) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_method response.""" + parser = HoiParamsParser(data) + + _, interface_id = parser.parse_next() + _, call_type = parser.parse_next() + _, method_id = parser.parse_next() + _, name = parser.parse_next() + + # The remaining fragments are STRING types containing type IDs as bytes + # Hamilton sends ONE combined list where type IDs encode category (Argument/ReturnElement/ReturnValue) + # First STRING after method name is parameter_types (each byte is a type ID - can be Argument or Return) + # Second STRING (if present) is parameter_labels (comma-separated names - includes both params and returns) + parameter_types_str = None + parameter_labels_str = None + + if parser.has_remaining(): + _, parameter_types_str = parser.parse_next() + + if parser.has_remaining(): + _, parameter_labels_str = parser.parse_next() + + # Decode string bytes to type IDs (like piglet does: .as_bytes().to_vec()) + all_type_ids: list[int] = [] + if parameter_types_str: + all_type_ids = [ord(c) for c in parameter_types_str] + + # Parse all labels (comma-separated - includes both parameters and returns) + all_labels: list[str] = [] + if parameter_labels_str: + all_labels = [label.strip() for label in parameter_labels_str.split(",") if label.strip()] + + # Categorize by type ID ranges (like piglet does) + # Split into arguments vs returns based on type ID category + parameter_types: list[int] = [] + parameter_labels: list[str] = [] + return_types: list[int] = [] + return_labels: list[str] = [] + + for i, type_id in enumerate(all_type_ids): + category = get_introspection_type_category(type_id) + label = all_labels[i] if i < len(all_labels) else None + + if category == "Argument": + parameter_types.append(type_id) + if label: + parameter_labels.append(label) + elif category in ("ReturnElement", "ReturnValue"): + return_types.append(type_id) + if label: + return_labels.append(label) + # Unknown types - could be parameters or returns, default to parameters + else: + parameter_types.append(type_id) + if label: + parameter_labels.append(label) + + return { + "interface_id": interface_id, + "call_type": call_type, + "method_id": method_id, + "name": name, + "parameter_types": parameter_types, # Decoded type IDs (Argument category only) + "parameter_labels": parameter_labels, # Parameter names only + "return_types": return_types, # Decoded type IDs (ReturnElement/ReturnValue only) + "return_labels": return_labels, # Return names only + } + + +class GetSubobjectAddressCommand(HamiltonCommand): + """Get subobject address (command_id=3).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 3 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, subobject_index: int): + super().__init__(object_address) + self.subobject_index = subobject_index + + def build_parameters(self) -> HoiParams: + """Build parameters for get_subobject_address command.""" + return HoiParams().u16(self.subobject_index) # Use u16, not u32 + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_subobject_address response.""" + parser = HoiParamsParser(data) + + _, module_id = parser.parse_next() + _, node_id = parser.parse_next() + _, object_id = parser.parse_next() + + return {"address": Address(module_id, node_id, object_id)} + + +class GetInterfacesCommand(HamiltonCommand): + """Get available interfaces (command_id=4).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 4 + action_code = 0 # QUERY + + def __init__(self, object_address: Address): + super().__init__(object_address) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_interfaces response.""" + parser = HoiParamsParser(data) + + interfaces = [] + _, interface_count = parser.parse_next() + + for _ in range(interface_count): + _, interface_id = parser.parse_next() + _, name = parser.parse_next() + _, version = parser.parse_next() + interfaces.append({"interface_id": interface_id, "name": name, "version": version}) + + return {"interfaces": interfaces} + + +class GetEnumsCommand(HamiltonCommand): + """Get enum definitions (command_id=5).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 5 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, target_interface_id: int): + super().__init__(object_address) + self.target_interface_id = target_interface_id + + def build_parameters(self) -> HoiParams: + """Build parameters for get_enums command.""" + return HoiParams().u8(self.target_interface_id) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_enums response.""" + parser = HoiParamsParser(data) + + enums = [] + _, enum_count = parser.parse_next() + + for _ in range(enum_count): + _, enum_id = parser.parse_next() + _, name = parser.parse_next() + + # Parse enum values + _, value_count = parser.parse_next() + values = {} + for _ in range(value_count): + _, value_name = parser.parse_next() + _, value_value = parser.parse_next() + values[value_name] = value_value + + enums.append({"enum_id": enum_id, "name": name, "values": values}) + + return {"enums": enums} + + +class GetStructsCommand(HamiltonCommand): + """Get struct definitions (command_id=6).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 6 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, target_interface_id: int): + super().__init__(object_address) + self.target_interface_id = target_interface_id + + def build_parameters(self) -> HoiParams: + """Build parameters for get_structs command.""" + return HoiParams().u8(self.target_interface_id) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_structs response.""" + parser = HoiParamsParser(data) + + structs = [] + _, struct_count = parser.parse_next() + + for _ in range(struct_count): + _, struct_id = parser.parse_next() + _, name = parser.parse_next() + + # Parse struct fields + _, field_count = parser.parse_next() + fields = {} + for _ in range(field_count): + _, field_name = parser.parse_next() + _, field_type = parser.parse_next() + fields[field_name] = field_type + + structs.append({"struct_id": struct_id, "name": name, "fields": fields}) + + return {"structs": structs} + + +# ============================================================================ +# HIGH-LEVEL INTROSPECTION API +# ============================================================================ + + +class HamiltonIntrospection: + """High-level API for Hamilton introspection.""" + + def __init__(self, backend): + """Initialize introspection API. + + Args: + backend: TCPBackend instance + """ + self.backend = backend + + async def get_object(self, address: Address) -> ObjectInfo: + """Get object metadata. + + Args: + address: Object address to query + + Returns: + Object metadata + """ + command = GetObjectCommand(address) + response = await self.backend.send_command(command) + + return ObjectInfo( + name=response["name"], + version=response["version"], + method_count=response["method_count"], + subobject_count=response["subobject_count"], + address=address, + ) + + async def get_method(self, address: Address, method_index: int) -> MethodInfo: + """Get method signature. + + Args: + address: Object address + method_index: Method index to query + + Returns: + Method signature + """ + command = GetMethodCommand(address, method_index) + response = await self.backend.send_command(command) + + return MethodInfo( + interface_id=response["interface_id"], + call_type=response["call_type"], + method_id=response["method_id"], + name=response["name"], + parameter_types=response.get("parameter_types", []), + parameter_labels=response.get("parameter_labels", []), + return_types=response.get("return_types", []), + return_labels=response.get("return_labels", []), + ) + + async def get_subobject_address(self, address: Address, subobject_index: int) -> Address: + """Get subobject address. + + Args: + address: Parent object address + subobject_index: Subobject index + + Returns: + Subobject address + """ + command = GetSubobjectAddressCommand(address, subobject_index) + response = await self.backend.send_command(command) + + # Type: ignore needed because response dict is typed as dict[str, Any] + # but we know 'address' key contains Address object + return response["address"] # type: ignore[no-any-return, return-value] + + async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: + """Get available interfaces. + + Args: + address: Object address + + Returns: + List of interface information + """ + command = GetInterfacesCommand(address) + response = await self.backend.send_command(command) + + return [ + InterfaceInfo( + interface_id=iface["interface_id"], name=iface["name"], version=iface["version"] + ) + for iface in response["interfaces"] + ] + + async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo]: + """Get enum definitions. + + Args: + address: Object address + interface_id: Interface ID + + Returns: + List of enum definitions + """ + command = GetEnumsCommand(address, interface_id) + response = await self.backend.send_command(command) + + return [ + EnumInfo(enum_id=enum_def["enum_id"], name=enum_def["name"], values=enum_def["values"]) + for enum_def in response["enums"] + ] + + async def get_structs(self, address: Address, interface_id: int) -> List[StructInfo]: + """Get struct definitions. + + Args: + address: Object address + interface_id: Interface ID + + Returns: + List of struct definitions + """ + command = GetStructsCommand(address, interface_id) + response = await self.backend.send_command(command) + + return [ + StructInfo( + struct_id=struct_def["struct_id"], name=struct_def["name"], fields=struct_def["fields"] + ) + for struct_def in response["structs"] + ] + + async def get_all_methods(self, address: Address) -> List[MethodInfo]: + """Get all methods for an object. + + Args: + address: Object address + + Returns: + List of all method signatures + """ + # First get object info to know how many methods there are + object_info = await self.get_object(address) + + methods = [] + for i in range(object_info.method_count): + try: + method = await self.get_method(address, i) + methods.append(method) + except Exception as e: + logger.warning(f"Failed to get method {i} for {address}: {e}") + + return methods + + async def discover_hierarchy(self, root_address: Address) -> Dict[str, Any]: + """Recursively discover object hierarchy. + + Args: + root_address: Root object address + + Returns: + Nested dictionary of discovered objects + """ + hierarchy = {} + + try: + # Get root object info + root_info = await self.get_object(root_address) + # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility + hierarchy["info"] = root_info # type: ignore[assignment] + + # Discover subobjects + subobjects = {} + for i in range(root_info.subobject_count): + try: + subaddress = await self.get_subobject_address(root_address, i) + subobjects[f"subobject_{i}"] = await self.discover_hierarchy(subaddress) + except Exception as e: + logger.warning(f"Failed to discover subobject {i}: {e}") + + # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility + hierarchy["subobjects"] = subobjects # type: ignore[assignment] + + # Discover methods + methods = await self.get_all_methods(root_address) + # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility + hierarchy["methods"] = methods # type: ignore[assignment] + + except Exception as e: + logger.error(f"Failed to discover hierarchy for {root_address}: {e}") + # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility + hierarchy["error"] = str(e) # type: ignore[assignment] + + return hierarchy + + async def discover_all_objects(self, root_addresses: List[Address]) -> Dict[str, Any]: + """Discover all objects starting from root addresses. + + Args: + root_addresses: List of root addresses to start discovery from + + Returns: + Dictionary mapping address strings to discovered hierarchies + """ + all_objects = {} + + for root_address in root_addresses: + try: + hierarchy = await self.discover_hierarchy(root_address) + all_objects[str(root_address)] = hierarchy + except Exception as e: + logger.error(f"Failed to discover objects from {root_address}: {e}") + all_objects[str(root_address)] = {"error": str(e)} + + return all_objects + + def print_method_signatures(self, methods: List[MethodInfo]) -> None: + """Print method signatures in a readable format. + + Args: + methods: List of MethodInfo objects to print + """ + print("Method Signatures:") + print("=" * 50) + for method in methods: + print(f" {method.get_signature_string()}") + print(f" Interface: {method.interface_id}, Method ID: {method.method_id}") + print() + + def print_struct_definitions(self, structs: List[StructInfo]) -> None: + """Print struct definitions in a readable format. + + Args: + structs: List of StructInfo objects to print + """ + print("Struct Definitions:") + print("=" * 50) + for struct in structs: + print(struct.get_struct_string()) + print() + + def get_methods_by_name(self, methods: List[MethodInfo], name_pattern: str) -> List[MethodInfo]: + """Filter methods by name pattern. + + Args: + methods: List of MethodInfo objects to filter + name_pattern: Name pattern to search for (case-insensitive) + + Returns: + List of methods matching the name pattern + """ + return [method for method in methods if name_pattern.lower() in method.name.lower()] + + def get_methods_by_interface( + self, methods: List[MethodInfo], interface_id: int + ) -> List[MethodInfo]: + """Filter methods by interface ID. + + Args: + methods: List of MethodInfo objects to filter + interface_id: Interface ID to filter by + + Returns: + List of methods from the specified interface + """ + return [method for method in methods if method.interface_id == interface_id] diff --git a/pylabrobot/hamilton/tcp/messages.py b/pylabrobot/hamilton/tcp/messages.py new file mode 100644 index 00000000000..75fe0fb974a --- /dev/null +++ b/pylabrobot/hamilton/tcp/messages.py @@ -0,0 +1,857 @@ +"""High-level Hamilton message builders and response parsers. + +This module provides user-facing message builders and their corresponding +response parsers. Each message type is paired with its response type: + +Request Builders: +- InitMessage: Builds IP[Connection] for initialization +- RegistrationMessage: Builds IP[HARP[Registration]] for discovery +- CommandMessage: Builds IP[HARP[HOI]] for method calls + +Response Parsers: +- InitResponse: Parses initialization responses +- RegistrationResponse: Parses registration responses +- CommandResponse: Parses command responses + +This pairing creates symmetry and makes correlation explicit. + +Architectural Note: +Parameter encoding (HoiParams/HoiParamsParser) is conceptually a separate layer +in the Hamilton protocol architecture (per documented architecture), but is +implemented here for efficiency since it's exclusively used by HOI messages. +This preserves the conceptual separation while optimizing implementation. + +Example: + # Build and send + msg = CommandMessage(dest, interface_id=0, method_id=42) + msg.add_i32(100) + packet_bytes = msg.build(src, seq=1) + + # Parse response + response = CommandResponse.from_bytes(received_bytes) + params = response.hoi.params +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.hamilton.tcp.packets import ( + Address, + HarpPacket, + HoiPacket, + IpPacket, + RegistrationPacket, +) +from pylabrobot.hamilton.tcp.protocol import ( + HamiltonDataType, + HarpTransportableProtocol, + RegistrationOptionType, +) + +# ============================================================================ +# HOI PARAMETER ENCODING - DataFragment wrapping for HOI protocol +# ============================================================================ +# +# Note: This is conceptually a separate layer in the Hamilton protocol +# architecture, but implemented here for efficiency since it's exclusively +# used by HOI messages (CommandMessage). +# ============================================================================ + + +class HoiParams: + """Builder for HOI parameters with automatic DataFragment wrapping. + + Each parameter is wrapped with DataFragment header before being added: + [type_id:1][flags:1][length:2][data:n] + + This ensures HOI parameters are always correctly formatted and eliminates + the possibility of forgetting to add DataFragment headers. + + Example: + Creates concatenated DataFragments: + [0x03|0x00|0x04|0x00|100][0x0F|0x00|0x05|0x00|"test\0"][0x1C|0x00|...array...] + + params = (HoiParams() + .i32(100) + .string("test") + .u32_array([1, 2, 3]) + .build()) + """ + + def __init__(self): + self._fragments: list[bytes] = [] + + def _add_fragment(self, type_id: int, data: bytes, flags: int = 0) -> "HoiParams": + """Add a DataFragment with the given type_id and data. + + Creates: [type_id:1][flags:1][length:2][data:n] + + Args: + type_id: Data type ID + data: Fragment data bytes + flags: Fragment flags (default: 0, but BOOL_ARRAY uses 0x01) + """ + fragment = Writer().u8(type_id).u8(flags).u16(len(data)).raw_bytes(data).finish() + self._fragments.append(fragment) + return self + + # Scalar integer types + def i8(self, value: int) -> "HoiParams": + """Add signed 8-bit integer parameter.""" + data = Writer().i8(value).finish() + return self._add_fragment(HamiltonDataType.I8, data) + + def i16(self, value: int) -> "HoiParams": + """Add signed 16-bit integer parameter.""" + data = Writer().i16(value).finish() + return self._add_fragment(HamiltonDataType.I16, data) + + def i32(self, value: int) -> "HoiParams": + """Add signed 32-bit integer parameter.""" + data = Writer().i32(value).finish() + return self._add_fragment(HamiltonDataType.I32, data) + + def i64(self, value: int) -> "HoiParams": + """Add signed 64-bit integer parameter.""" + data = Writer().i64(value).finish() + return self._add_fragment(HamiltonDataType.I64, data) + + def u8(self, value: int) -> "HoiParams": + """Add unsigned 8-bit integer parameter.""" + data = Writer().u8(value).finish() + return self._add_fragment(HamiltonDataType.U8, data) + + def u16(self, value: int) -> "HoiParams": + """Add unsigned 16-bit integer parameter.""" + data = Writer().u16(value).finish() + return self._add_fragment(HamiltonDataType.U16, data) + + def u32(self, value: int) -> "HoiParams": + """Add unsigned 32-bit integer parameter.""" + data = Writer().u32(value).finish() + return self._add_fragment(HamiltonDataType.U32, data) + + def u64(self, value: int) -> "HoiParams": + """Add unsigned 64-bit integer parameter.""" + data = Writer().u64(value).finish() + return self._add_fragment(HamiltonDataType.U64, data) + + # Floating-point types + def f32(self, value: float) -> "HoiParams": + """Add 32-bit float parameter.""" + data = Writer().f32(value).finish() + return self._add_fragment(HamiltonDataType.F32, data) + + def f64(self, value: float) -> "HoiParams": + """Add 64-bit double parameter.""" + data = Writer().f64(value).finish() + return self._add_fragment(HamiltonDataType.F64, data) + + # String and bool + def string(self, value: str) -> "HoiParams": + """Add null-terminated string parameter.""" + data = Writer().string(value).finish() + return self._add_fragment(HamiltonDataType.STRING, data) + + def bool_value(self, value: bool) -> "HoiParams": + """Add boolean parameter.""" + data = Writer().u8(1 if value else 0).finish() + return self._add_fragment(HamiltonDataType.BOOL, data) + + # Array types + def i8_array(self, values: list[int]) -> "HoiParams": + """Add array of signed 8-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.i8(val) + return self._add_fragment(HamiltonDataType.I8_ARRAY, writer.finish()) + + def i16_array(self, values: list[int]) -> "HoiParams": + """Add array of signed 16-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.i16(val) + return self._add_fragment(HamiltonDataType.I16_ARRAY, writer.finish()) + + def i32_array(self, values: list[int]) -> "HoiParams": + """Add array of signed 32-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.i32(val) + return self._add_fragment(HamiltonDataType.I32_ARRAY, writer.finish()) + + def i64_array(self, values: list[int]) -> "HoiParams": + """Add array of signed 64-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.i64(val) + return self._add_fragment(HamiltonDataType.I64_ARRAY, writer.finish()) + + def u8_array(self, values: list[int]) -> "HoiParams": + """Add array of unsigned 8-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.u8(val) + return self._add_fragment(HamiltonDataType.U8_ARRAY, writer.finish()) + + def u16_array(self, values: list[int]) -> "HoiParams": + """Add array of unsigned 16-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.u16(val) + return self._add_fragment(HamiltonDataType.U16_ARRAY, writer.finish()) + + def u32_array(self, values: list[int]) -> "HoiParams": + """Add array of unsigned 32-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.u32(val) + return self._add_fragment(HamiltonDataType.U32_ARRAY, writer.finish()) + + def u64_array(self, values: list[int]) -> "HoiParams": + """Add array of unsigned 64-bit integers. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.u64(val) + return self._add_fragment(HamiltonDataType.U64_ARRAY, writer.finish()) + + def f32_array(self, values: list[float]) -> "HoiParams": + """Add array of 32-bit floats. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.f32(val) + return self._add_fragment(HamiltonDataType.F32_ARRAY, writer.finish()) + + def f64_array(self, values: list[float]) -> "HoiParams": + """Add array of 64-bit doubles. + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + """ + writer = Writer() + for val in values: + writer.f64(val) + return self._add_fragment(HamiltonDataType.F64_ARRAY, writer.finish()) + + def bool_array(self, values: list[bool]) -> "HoiParams": + """Add array of booleans (stored as u8: 0 or 1). + + Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + + Note: BOOL_ARRAY uses flags=0x01 in the DataFragment header (unlike other types which use 0x00). + """ + writer = Writer() + for val in values: + writer.u8(1 if val else 0) + return self._add_fragment(HamiltonDataType.BOOL_ARRAY, writer.finish(), flags=0x01) + + def string_array(self, values: list[str]) -> "HoiParams": + """Add array of null-terminated strings. + + Format: [count:4][str0\0][str1\0]... + """ + writer = Writer().u32(len(values)) + for val in values: + writer.string(val) + return self._add_fragment(HamiltonDataType.STRING_ARRAY, writer.finish()) + + def build(self) -> bytes: + """Return concatenated DataFragments.""" + return b"".join(self._fragments) + + def count(self) -> int: + """Return number of fragments (parameters).""" + return len(self._fragments) + + +class HoiParamsParser: + """Parser for HOI DataFragment parameters. + + Parses DataFragment-wrapped values from HOI response payloads. + """ + + def __init__(self, data: bytes): + self._data = data + self._offset = 0 + + def parse_next(self) -> tuple[int, Any]: + """Parse the next DataFragment and return (type_id, value). + + Returns: + Tuple of (type_id, parsed_value) + + Raises: + ValueError: If data is malformed or insufficient + """ + if self._offset + 4 > len(self._data): + raise ValueError(f"Insufficient data for DataFragment header at offset {self._offset}") + + # Parse DataFragment header + reader = Reader(self._data[self._offset :]) + type_id = reader.u8() + _flags = reader.u8() # Read but unused + length = reader.u16() + + data_start = self._offset + 4 + data_end = data_start + length + + if data_end > len(self._data): + raise ValueError( + f"DataFragment data extends beyond buffer: need {data_end}, have {len(self._data)}" + ) + + # Extract data payload + fragment_data = self._data[data_start:data_end] + value = self._parse_value(type_id, fragment_data) + + # Move offset past this fragment + self._offset = data_end + + return (type_id, value) + + def _parse_value(self, type_id: int, data: bytes) -> Any: + """Parse value based on type_id using dispatch table.""" + reader = Reader(data) + + # Dispatch table for scalar types + scalar_parsers = { + HamiltonDataType.I8: reader.i8, + HamiltonDataType.I16: reader.i16, + HamiltonDataType.I32: reader.i32, + HamiltonDataType.I64: reader.i64, + HamiltonDataType.U8: reader.u8, + HamiltonDataType.U16: reader.u16, + HamiltonDataType.U32: reader.u32, + HamiltonDataType.U64: reader.u64, + HamiltonDataType.F32: reader.f32, + HamiltonDataType.F64: reader.f64, + HamiltonDataType.STRING: reader.string, + } + + # Check scalar types first + # Cast int to HamiltonDataType enum for dict lookup + try: + data_type = HamiltonDataType(type_id) + if data_type in scalar_parsers: + return scalar_parsers[data_type]() + except ValueError: + pass # Not a valid enum value, continue to other checks + + # Special case: bool + if type_id == HamiltonDataType.BOOL: + return reader.u8() == 1 + + # Dispatch table for array element parsers + array_element_parsers = { + HamiltonDataType.I8_ARRAY: reader.i8, + HamiltonDataType.I16_ARRAY: reader.i16, + HamiltonDataType.I32_ARRAY: reader.i32, + HamiltonDataType.I64_ARRAY: reader.i64, + HamiltonDataType.U8_ARRAY: reader.u8, + HamiltonDataType.U16_ARRAY: reader.u16, + HamiltonDataType.U32_ARRAY: reader.u32, + HamiltonDataType.U64_ARRAY: reader.u64, + HamiltonDataType.F32_ARRAY: reader.f32, + HamiltonDataType.F64_ARRAY: reader.f64, + HamiltonDataType.STRING_ARRAY: reader.string, + } + + # Handle arrays + # Arrays don't have a count prefix - count is derived from DataFragment length + # Calculate element size based on type + element_sizes = { + HamiltonDataType.I8_ARRAY: 1, + HamiltonDataType.I16_ARRAY: 2, + HamiltonDataType.I32_ARRAY: 4, + HamiltonDataType.I64_ARRAY: 8, + HamiltonDataType.U8_ARRAY: 1, + HamiltonDataType.U16_ARRAY: 2, + HamiltonDataType.U32_ARRAY: 4, + HamiltonDataType.U64_ARRAY: 8, + HamiltonDataType.F32_ARRAY: 4, + HamiltonDataType.F64_ARRAY: 8, + HamiltonDataType.STRING_ARRAY: None, # Variable length, handled separately + } + + # Cast int to HamiltonDataType enum for dict lookup + try: + data_type = HamiltonDataType(type_id) + if data_type in array_element_parsers: + element_size = element_sizes.get(data_type) + if element_size is not None: + # Fixed-size elements: calculate count from data length + count = len(data) // element_size + return [array_element_parsers[data_type]() for _ in range(count)] + elif data_type == HamiltonDataType.STRING_ARRAY: + # String arrays: [count:4][str0\0][str1\0]... + count = Reader(data[:4]).u32() + strings = [] + offset = 4 + for _ in range(count): + end = data.index(0, offset) + strings.append(data[offset:end].decode("utf-8", errors="replace")) + offset = end + 1 + return strings + except ValueError: + # Not a valid enum value, continue to other checks + # This shouldn't happen for valid Hamilton types, but we continue anyway + pass + + # Special case: bool array (1 byte per element) + if type_id == HamiltonDataType.BOOL_ARRAY: + count = len(data) // 1 # Each bool is 1 byte + return [reader.u8() == 1 for _ in range(count)] + + # Unknown type + raise ValueError(f"Unknown or unsupported type_id: {type_id}") + + def has_remaining(self) -> bool: + """Check if there are more DataFragments to parse.""" + return self._offset < len(self._data) + + def parse_all(self) -> list[tuple[int, Any]]: + """Parse all remaining DataFragments. + + Returns: + List of (type_id, value) tuples + """ + results = [] + while self.has_remaining(): + results.append(self.parse_next()) + return results + + +# ============================================================================ +# MESSAGE BUILDERS +# ============================================================================ + + +class CommandMessage: + """Build HOI command messages for method calls. + + Creates complete IP[HARP[HOI]] packets with proper protocols and actions. + Parameters are automatically wrapped with DataFragment headers via HoiParams. + + Example: + msg = CommandMessage(dest, interface_id=0, method_id=42) + msg.add_i32(100).add_string("test") + packet_bytes = msg.build(src, seq=1) + """ + + def __init__( + self, + dest: Address, + interface_id: int, + method_id: int, + params: HoiParams, + action_code: int = 3, # Default: COMMAND_REQUEST + harp_protocol: int = 2, # Default: HOI2 + ip_protocol: int = 6, # Default: OBJECT_DISCOVERY + ): + """Initialize command message. + + Args: + dest: Destination object address + interface_id: Interface ID (typically 0 for main interface, 1 for extended) + method_id: Method/action ID to invoke + action_code: HOI action code (default 3=COMMAND_REQUEST) + harp_protocol: HARP protocol identifier (default 2=HOI2) + ip_protocol: IP protocol identifier (default 6=OBJECT_DISCOVERY) + """ + self.dest = dest + self.interface_id = interface_id + self.method_id = method_id + self.params = params + self.action_code = action_code + self.harp_protocol = harp_protocol + self.ip_protocol = ip_protocol + + def build( + self, + src: Address, + seq: int, + harp_response_required: bool = True, + hoi_response_required: bool = False, + ) -> bytes: + """Build complete IP[HARP[HOI]] packet. + + Args: + src: Source address (client address) + seq: Sequence number for this request + harp_response_required: Set bit 4 in HARP action byte (default True) + hoi_response_required: Set bit 4 in HOI action byte (default False) + + Returns: + Complete packet bytes ready to send over TCP + """ + # Build HOI - it handles its own action byte construction + hoi = HoiPacket( + interface_id=self.interface_id, + action_code=self.action_code, + action_id=self.method_id, + params=self.params.build(), + response_required=hoi_response_required, + ) + + # Build HARP - it handles its own action byte construction + harp = HarpPacket( + src=src, + dst=self.dest, + seq=seq, + protocol=self.harp_protocol, + action_code=self.action_code, + payload=hoi.pack(), + response_required=harp_response_required, + ) + + # Wrap in IP packet + ip = IpPacket(protocol=self.ip_protocol, payload=harp.pack()) + + return ip.pack() + + +class RegistrationMessage: + """Build Registration messages for object discovery. + + Creates complete IP[HARP[Registration]] packets for discovering modules, + objects, and capabilities on the Hamilton instrument. + + Example: + msg = RegistrationMessage(dest, action_code=12) + msg.add_registration_option(RegistrationOptionType.HARP_PROTOCOL_REQUEST, protocol=2, request_id=1) + packet_bytes = msg.build(src, req_addr, res_addr, seq=1) + """ + + def __init__( + self, + dest: Address, + action_code: int, + response_code: int = 0, # Default: no error + harp_protocol: int = 3, # Default: Registration + ip_protocol: int = 6, # Default: OBJECT_DISCOVERY + ): + """Initialize registration message. + + Args: + dest: Destination address (typically 0:0:65534 for registration service) + action_code: Registration action code (e.g., 12=HARP_PROTOCOL_REQUEST) + response_code: Response code (default 0=no error) + harp_protocol: HARP protocol identifier (default 3=Registration) + ip_protocol: IP protocol identifier (default 6=OBJECT_DISCOVERY) + """ + self.dest = dest + self.action_code = action_code + self.response_code = response_code + self.harp_protocol = harp_protocol + self.ip_protocol = ip_protocol + self.options = bytearray() + + def add_registration_option( + self, option_type: RegistrationOptionType, protocol: int = 2, request_id: int = 1 + ) -> "RegistrationMessage": + """Add a registration packet option. + + Args: + option_type: Type of registration option (from RegistrationOptionType enum) + protocol: For HARP_PROTOCOL_REQUEST: protocol type (2=HOI, default) + request_id: For HARP_PROTOCOL_REQUEST: what to discover (1=root, 2=global) + + Returns: + Self for method chaining + """ + # Registration option format: [option_id:1][length:1][data...] + # For HARP_PROTOCOL_REQUEST (option 5): data is [protocol:1][request_id:1] + data = Writer().u8(protocol).u8(request_id).finish() + option = Writer().u8(option_type).u8(len(data)).raw_bytes(data).finish() + self.options.extend(option) + return self + + def build( + self, + src: Address, + req_addr: Address, + res_addr: Address, + seq: int, + harp_action_code: int = 3, # Default: COMMAND_REQUEST + harp_response_required: bool = True, # Default: request with response + ) -> bytes: + """Build complete IP[HARP[Registration]] packet. + + Args: + src: Source address (client address) + req_addr: Request address (for registration context) + res_addr: Response address (for registration context) + seq: Sequence number for this request + harp_action_code: HARP action code (default 3=COMMAND_REQUEST) + harp_response_required: Whether response required (default True) + + Returns: + Complete packet bytes ready to send over TCP + """ + # Build Registration packet + reg = RegistrationPacket( + action_code=self.action_code, + response_code=self.response_code, + req_address=req_addr, + res_address=res_addr, + options=bytes(self.options), + ) + + # Wrap in HARP packet + harp = HarpPacket( + src=src, + dst=self.dest, + seq=seq, + protocol=self.harp_protocol, + action_code=harp_action_code, + payload=reg.pack(), + response_required=harp_response_required, + ) + + # Wrap in IP packet + ip = IpPacket(protocol=self.ip_protocol, payload=harp.pack()) + + return ip.pack() + + +class InitMessage: + """Build Connection initialization messages. + + Creates complete IP[Connection] packets for establishing a connection + with the Hamilton instrument. Uses Protocol 7 (INITIALIZATION) which + has a different structure than HARP-based messages. + + Example: + msg = InitMessage(timeout=30) + packet_bytes = msg.build() + """ + + def __init__( + self, + timeout: int = 30, + connection_type: int = 1, # Default: standard connection + protocol_version: int = 0x30, # Default: 3.0 + ip_protocol: int = 7, # Default: INITIALIZATION + ): + """Initialize connection message. + + Args: + timeout: Connection timeout in seconds (default 30) + connection_type: Connection type (default 1=standard) + protocol_version: Protocol version byte (default 0x30=3.0) + ip_protocol: IP protocol identifier (default 7=INITIALIZATION) + """ + self.timeout = timeout + self.connection_type = connection_type + self.protocol_version = protocol_version + self.ip_protocol = ip_protocol + + def build(self) -> bytes: + """Build complete IP[Connection] packet. + + Returns: + Complete packet bytes ready to send over TCP + """ + # Build raw connection parameters (NOT DataFragments) + # Frame: [version:1][message_id:1][count:1][unknown:1] + # Parameters: [id:1][type:1][reserved:2][value:2] repeated + params = ( + Writer() + # Frame + .u8(0) # version + .u8(0) # message_id + .u8(3) # count (3 parameters) + .u8(0) # unknown + # Parameter 1: connection_id (request allocation) + .u8(1) # param id + .u8(16) # param type + .u16(0) # reserved + .u16(0) # value (0 = request allocation) + # Parameter 2: connection_type + .u8(2) # param id + .u8(16) # param type + .u16(0) # reserved + .u16(self.connection_type) # value + # Parameter 3: timeout + .u8(4) # param id + .u8(16) # param type + .u16(0) # reserved + .u16(self.timeout) # value + .finish() + ) + + # Build IP packet + packet_size = 1 + 1 + 2 + len(params) # protocol + version + opts_len + params + + return ( + Writer() + .u16(packet_size) + .u8(self.ip_protocol) + .u8(self.protocol_version) + .u16(0) # options_length + .raw_bytes(params) + .finish() + ) + + +# ============================================================================ +# RESPONSE PARSERS - Paired with message builders above +# ============================================================================ + + +@dataclass +class InitResponse: + """Parsed initialization response. + + Pairs with InitMessage - parses Protocol 7 (INITIALIZATION) responses. + """ + + raw_bytes: bytes + client_id: int + connection_type: int + timeout: int + + @classmethod + def from_bytes(cls, data: bytes) -> "InitResponse": + """Parse initialization response. + + Args: + data: Raw bytes from TCP socket + + Returns: + Parsed InitResponse with connection parameters + """ + # Skip IP header (size + protocol + version + opts_len = 6 bytes) + parser = Reader(data[6:]) + + # Parse frame + _version = parser.u8() # Read but unused + _message_id = parser.u8() # Read but unused + _count = parser.u8() # Read but unused + _unknown = parser.u8() # Read but unused + + # Parse parameter 1 (client_id) + _param1_id = parser.u8() # Read but unused + _param1_type = parser.u8() # Read but unused + _param1_reserved = parser.u16() # Read but unused + client_id = parser.u16() + + # Parse parameter 2 (connection_type) + _param2_id = parser.u8() # Read but unused + _param2_type = parser.u8() # Read but unused + _param2_reserved = parser.u16() # Read but unused + connection_type = parser.u16() + + # Parse parameter 4 (timeout) + _param4_id = parser.u8() # Read but unused + _param4_type = parser.u8() # Read but unused + _param4_reserved = parser.u16() # Read but unused + timeout = parser.u16() + + return cls( + raw_bytes=data, client_id=client_id, connection_type=connection_type, timeout=timeout + ) + + +@dataclass +class RegistrationResponse: + """Parsed registration response. + + Pairs with RegistrationMessage - parses IP[HARP[Registration]] responses. + """ + + raw_bytes: bytes + ip: IpPacket + harp: HarpPacket + registration: RegistrationPacket + + @classmethod + def from_bytes(cls, data: bytes) -> "RegistrationResponse": + """Parse registration response. + + Args: + data: Raw bytes from TCP socket + + Returns: + Parsed RegistrationResponse with all layers + """ + ip = IpPacket.unpack(data) + harp = HarpPacket.unpack(ip.payload) + registration = RegistrationPacket.unpack(harp.payload) + + return cls(raw_bytes=data, ip=ip, harp=harp, registration=registration) + + @property + def sequence_number(self) -> int: + """Get sequence number from HARP layer.""" + return self.harp.seq + + +@dataclass +class CommandResponse: + """Parsed command response. + + Pairs with CommandMessage - parses IP[HARP[HOI]] responses. + """ + + raw_bytes: bytes + ip: IpPacket + harp: HarpPacket + hoi: HoiPacket + + @classmethod + def from_bytes(cls, data: bytes) -> "CommandResponse": + """Parse command response. + + Args: + data: Raw bytes from TCP socket + + Returns: + Parsed CommandResponse with all layers + + Raises: + ValueError: If response is not HOI protocol + """ + ip = IpPacket.unpack(data) + harp = HarpPacket.unpack(ip.payload) + + if harp.protocol != HarpTransportableProtocol.HOI2: + raise ValueError(f"Expected HOI2 protocol, got {harp.protocol}") + + hoi = HoiPacket.unpack(harp.payload) + + return cls(raw_bytes=data, ip=ip, harp=harp, hoi=hoi) + + @property + def sequence_number(self) -> int: + """Get sequence number from HARP layer.""" + return self.harp.seq diff --git a/pylabrobot/hamilton/tcp/packets.py b/pylabrobot/hamilton/tcp/packets.py new file mode 100644 index 00000000000..42d308f1c48 --- /dev/null +++ b/pylabrobot/hamilton/tcp/packets.py @@ -0,0 +1,427 @@ +"""Hamilton TCP packet structures. + +This module defines the packet layer of the Hamilton protocol stack: +- IpPacket: Transport layer (size, protocol, version, payload) +- HarpPacket: Protocol layer (addressing, sequence, action, payload) +- HoiPacket: HOI application layer (interface_id, action_id, DataFragment params) +- RegistrationPacket: Registration protocol payload +- ConnectionPacket: Connection initialization payload + +Each packet knows how to pack/unpack itself using the Wire serialization layer. +""" + +from __future__ import annotations + +import logging +import struct +from dataclasses import dataclass + +from pylabrobot.io.binary import Reader, Writer + +logger = logging.getLogger(__name__) + +# Hamilton protocol version +HAMILTON_PROTOCOL_VERSION_MAJOR = 3 +HAMILTON_PROTOCOL_VERSION_MINOR = 0 + + +def encode_version_byte(major: int, minor: int) -> int: + """Pack Hamilton version byte (two 4-bit fields packed into one byte). + + Args: + major: Major version (0-15, stored in upper 4 bits) + minor: Minor version (0-15, stored in lower 4 bits) + """ + if not 0 <= major <= 15: + raise ValueError(f"major version must be 0-15, got {major}") + if not 0 <= minor <= 15: + raise ValueError(f"minor version must be 0-15, got {minor}") + version_byte = (minor & 0xF) | ((major & 0xF) << 4) + return version_byte + + +def decode_version_byte(version_byte: int) -> tuple[int, int]: + """Decode Hamilton version byte and return (major, minor). + + Returns: + Tuple of (major_version, minor_version), each 0-15 + """ + minor = version_byte & 0xF + major = (version_byte >> 4) & 0xF + return (major, minor) + + +@dataclass(frozen=True) +class Address: + """Hamilton network address (module_id, node_id, object_id).""" + + module: int # u16 + node: int # u16 + object: int # u16 + + def pack(self) -> bytes: + """Serialize address to 6 bytes.""" + return Writer().u16(self.module).u16(self.node).u16(self.object).finish() + + @classmethod + def unpack(cls, data: bytes) -> "Address": + """Deserialize address from bytes.""" + r = Reader(data) + return cls(module=r.u16(), node=r.u16(), object=r.u16()) + + def __str__(self) -> str: + return f"{self.module}:{self.node}:{self.object}" + + +@dataclass +class IpPacket: + """Hamilton IpPacket2 - Transport layer. + + Structure: + Bytes 00-01: size (2) + Bytes 02: protocol (1) + Bytes 03: version byte (major.minor) + Bytes 04-05: options_length (2) + Bytes 06+: options (x bytes) + Bytes: payload + """ + + protocol: int # Protocol identifier (6=OBJECT_DISCOVERY, 7=INITIALIZATION) + payload: bytes + options: bytes = b"" + + def pack(self) -> bytes: + """Serialize IP packet.""" + # Calculate size: protocol(1) + version(1) + opts_len(2) + options + payload + packet_size = 1 + 1 + 2 + len(self.options) + len(self.payload) + + return ( + Writer() + .u16(packet_size) + .u8(self.protocol) + .u8(encode_version_byte(HAMILTON_PROTOCOL_VERSION_MAJOR, HAMILTON_PROTOCOL_VERSION_MINOR)) + .u16(len(self.options)) + .raw_bytes(self.options) + .raw_bytes(self.payload) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "IpPacket": + """Deserialize IP packet.""" + r = Reader(data) + _size = r.u16() # Read but unused + protocol = r.u8() + major, minor = decode_version_byte(r.u8()) + + # Validate version + if major != HAMILTON_PROTOCOL_VERSION_MAJOR or minor != HAMILTON_PROTOCOL_VERSION_MINOR: + logger.warning( + "Hamilton protocol version mismatch: expected %d.%d, got %d.%d", + HAMILTON_PROTOCOL_VERSION_MAJOR, + HAMILTON_PROTOCOL_VERSION_MINOR, + major, + minor, + ) + + opts_len = r.u16() + options = r.raw_bytes(opts_len) if opts_len > 0 else b"" + payload = r.remaining() + + return cls(protocol=protocol, payload=payload, options=options) + + +@dataclass +class HarpPacket: + """Hamilton HarpPacket2 - Protocol layer. + + Structure: + Bytes 00-05: src address (module, node, object) + Bytes 06-11: dst address (module, node, object) + Byte 12: sequence number + Byte 13: reserved + Byte 14: protocol (2=HOI, 3=Registration) + Byte 15: action + Bytes 16-17: message length + Bytes 18-19: options length + Bytes 20+: options + Bytes: version byte (major.minor) + Byte: reserved2 + Bytes: payload + """ + + src: Address + dst: Address + seq: int + protocol: int # 2=HOI, 3=Registration + action_code: int # Base action code (0-15) + payload: bytes + options: bytes = b"" + response_required: bool = True # Controls bit 4 of action byte + + @property + def action(self) -> int: + """Compute action byte from action_code and response_required flag. + + Returns: + Action byte with bit 4 set if response required + """ + return self.action_code | (0x10 if self.response_required else 0x00) + + def pack(self) -> bytes: + """Serialize HARP packet.""" + # Message length includes: src(6) + dst(6) + seq(1) + reserved(1) + protocol(1) + + # action(1) + msg_len(2) + opts_len(2) + options + version(1) + reserved2(1) + payload + # = 20 (fixed header) + options + version + reserved2 + payload + msg_len = 20 + len(self.options) + 1 + 1 + len(self.payload) + + return ( + Writer() + .raw_bytes(self.src.pack()) + .raw_bytes(self.dst.pack()) + .u8(self.seq) + .u8(0) # reserved + .u8(self.protocol) + .u8(self.action) # Uses computed property + .u16(msg_len) + .u16(len(self.options)) + .raw_bytes(self.options) + .u8(0) # version byte - C# DLL uses 0, not 3.0 + .u8(0) # reserved2 + .raw_bytes(self.payload) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "HarpPacket": + """Deserialize HARP packet.""" + r = Reader(data) + + # Parse addresses + src = Address.unpack(r.raw_bytes(6)) + dst = Address.unpack(r.raw_bytes(6)) + + seq = r.u8() + _reserved = r.u8() # Read but unused + protocol = r.u8() + action_byte = r.u8() + _msg_len = r.u16() # Read but unused + opts_len = r.u16() + + options = r.raw_bytes(opts_len) if opts_len > 0 else b"" + _version = r.u8() # version byte (C# DLL uses 0) - Read but unused + _reserved2 = r.u8() # Read but unused + payload = r.remaining() + + # Decompose action byte into action_code and response_required flag + action_code = action_byte & 0x0F + response_required = bool(action_byte & 0x10) + + return cls( + src=src, + dst=dst, + seq=seq, + protocol=protocol, + action_code=action_code, + payload=payload, + options=options, + response_required=response_required, + ) + + +@dataclass +class HoiPacket: + """Hamilton HoiPacket2 - HOI application layer. + + Structure: + Byte 00: interface_id + Byte 01: action + Bytes 02-03: action_id + Byte 04: version byte (major.minor) + Byte 05: number of fragments + Bytes 06+: DataFragments + + Note: params must be DataFragment-wrapped (use HoiParams to build). + """ + + interface_id: int + action_code: int # Base action code (0-15) + action_id: int + params: bytes # Already DataFragment-wrapped via HoiParams + response_required: bool = False # Controls bit 4 of action byte + + @property + def action(self) -> int: + """Compute action byte from action_code and response_required flag. + + Returns: + Action byte with bit 4 set if response required + """ + return self.action_code | (0x10 if self.response_required else 0x00) + + def pack(self) -> bytes: + """Serialize HOI packet.""" + num_fragments = self._count_fragments(self.params) + + return ( + Writer() + .u8(self.interface_id) + .u8(self.action) # Uses computed property + .u16(self.action_id) + .u8(0) # version byte - always 0 for HOI packets (not 0x30!) + .u8(num_fragments) + .raw_bytes(self.params) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "HoiPacket": + """Deserialize HOI packet.""" + r = Reader(data) + + interface_id = r.u8() + action_byte = r.u8() + action_id = r.u16() + major, minor = decode_version_byte(r.u8()) + _num_fragments = r.u8() # Read but unused + params = r.remaining() + + # Decompose action byte into action_code and response_required flag + action_code = action_byte & 0x0F + response_required = bool(action_byte & 0x10) + + return cls( + interface_id=interface_id, + action_code=action_code, + action_id=action_id, + params=params, + response_required=response_required, + ) + + @staticmethod + def _count_fragments(data: bytes) -> int: + """Count DataFragments in params. + + Each DataFragment has format: [type_id:1][flags:1][length:2][data:n] + """ + if len(data) == 0: + return 0 + + count = 0 + offset = 0 + + while offset < len(data): + if offset + 4 > len(data): + break # Not enough bytes for a fragment header + + # Read fragment length + fragment_length = struct.unpack(" bytes: + """Serialize Registration packet.""" + return ( + Writer() + .u16(self.action_code) + .u16(self.response_code) + .u8(0) # version byte - DLL uses 0.0, not 3.0 + .u8(0) # reserved + .raw_bytes(self.req_address.pack()) + .raw_bytes(self.res_address.pack()) + .u16(len(self.options)) + .raw_bytes(self.options) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "RegistrationPacket": + """Deserialize Registration packet.""" + r = Reader(data) + + action_code = r.u16() + response_code = r.u16() + _version = r.u8() # version byte (DLL uses 0, not packed 3.0) - Read but unused + _reserved = r.u8() # Read but unused + req_address = Address.unpack(r.raw_bytes(6)) + res_address = Address.unpack(r.raw_bytes(6)) + opts_len = r.u16() + options = r.raw_bytes(opts_len) if opts_len > 0 else b"" + + return cls( + action_code=action_code, + response_code=response_code, + req_address=req_address, + res_address=res_address, + options=options, + ) + + +@dataclass +class ConnectionPacket: + """Hamilton ConnectionPacket - Connection initialization payload. + + Used for Protocol 7 (INITIALIZATION). Has a different structure than + HARP-based packets - uses raw parameter encoding, NOT DataFragments. + + Structure: + Byte 00: version + Byte 01: message_id + Byte 02: count (number of parameters) + Byte 03: unknown + Bytes 04+: raw parameters [id|type|reserved|value] repeated + """ + + params: bytes # Raw format (NOT DataFragments) + + def pack_into_ip(self) -> bytes: + """Build complete IP packet for connection initialization. + + Returns full IP packet with protocol=7. + """ + # Connection packet size: just the params (frame is included in params) + packet_size = 1 + 1 + 2 + len(self.params) + + return ( + Writer() + .u16(packet_size) + .u8(7) # INITIALIZATION protocol + .u8(encode_version_byte(HAMILTON_PROTOCOL_VERSION_MAJOR, HAMILTON_PROTOCOL_VERSION_MINOR)) + .u16(0) # options_length + .raw_bytes(self.params) + .finish() + ) + + @classmethod + def unpack_from_ip_payload(cls, data: bytes) -> "ConnectionPacket": + """Extract ConnectionPacket from IP packet payload. + + Assumes IP header has already been parsed. + """ + return cls(params=data) diff --git a/pylabrobot/hamilton/tcp/protocol.py b/pylabrobot/hamilton/tcp/protocol.py new file mode 100644 index 00000000000..9e916e91db3 --- /dev/null +++ b/pylabrobot/hamilton/tcp/protocol.py @@ -0,0 +1,178 @@ +"""Hamilton TCP protocol constants and enumerations. + +This module contains all protocol-level constants, enumerations, and type definitions +used throughout the Hamilton TCP communication stack. +""" + +from __future__ import annotations + +from enum import IntEnum + +# Hamilton protocol version (from Piglet: version byte 0x30 = major 3, minor 0) +HAMILTON_PROTOCOL_VERSION_MAJOR = 3 +HAMILTON_PROTOCOL_VERSION_MINOR = 0 + + +class HamiltonProtocol(IntEnum): + """Hamilton protocol identifiers. + + These values are derived from the piglet Rust implementation: + - Protocol 2: PIPETTE - pipette-specific operations + - Protocol 3: REGISTRATION - object registration and discovery + - Protocol 6: OBJECT_DISCOVERY - general object discovery and method calls + - Protocol 7: INITIALIZATION - connection initialization and client ID negotiation + """ + + PIPETTE = 0x02 + REGISTRATION = 0x03 + OBJECT_DISCOVERY = 0x06 + INITIALIZATION = 0x07 + + +class Hoi2Action(IntEnum): + """HOI2/HARP2 action codes (bits 0-3 of action field). + + Values from Hamilton.Components.TransportLayer.Protocols.HoiPacket2Constants.Hoi2Action + + The action byte combines the action code (lower 4 bits) with the response_required flag (bit 4): + - action_byte = action_code | (0x10 if response_required else 0x00) + - Example: COMMAND_REQUEST with response = 3 | 0x10 = 0x13 + - Example: STATUS_REQUEST without response = 0 | 0x00 = 0x00 + + Common action codes: + - COMMAND_REQUEST (3): Send a command to an object (most common for method calls) + - STATUS_REQUEST (0): Request status information + - COMMAND_RESPONSE (4): Response to a command + - STATUS_RESPONSE (1): Response with status information + + NOTE: According to Hamilton documentation, both HARP2 and HOI2 use the same action + enumeration values. This needs verification through TCP introspection. + """ + + STATUS_REQUEST = 0 + STATUS_RESPONSE = 1 + STATUS_EXCEPTION = 2 + COMMAND_REQUEST = 3 + COMMAND_RESPONSE = 4 + COMMAND_EXCEPTION = 5 + COMMAND_ACK = 6 + UPSTREAM_SYSTEM_EVENT = 7 + DOWNSTREAM_SYSTEM_EVENT = 8 + EVENT = 9 + INVALID_ACTION_RESPONSE = 10 + STATUS_WARNING = 11 + COMMAND_WARNING = 12 + + +class HarpTransportableProtocol(IntEnum): + """HARP2 protocol field values - determines payload type. + + From Hamilton.Components.TransportLayer.Protocols.HarpTransportableProtocol. + The protocol field at byte 14 in HARP2 tells which payload parser to use. + """ + + HOI2 = 2 # Payload is Hoi2 structure (Protocol 2) + REGISTRATION2 = 3 # Payload is Registration2 structure (Protocol 3) + NOT_DEFINED = 0xFF # Invalid/unknown protocol + + +class RegistrationActionCode(IntEnum): + """Registration2 action codes (bytes 0-1 in Registration2 packet). + + From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.RegistrationActionCode2. + + Note: HARP action values for Registration packets are different from HOI action codes: + - 0x13 (19): Request with response required (typical for HARP_PROTOCOL_REQUEST) + - 0x14 (20): Response with data (typical for HARP_PROTOCOL_RESPONSE) + - 0x03 (3): Request without response + """ + + REGISTRATION_REQUEST = 0 # Initial registration handshake + REGISTRATION_RESPONSE = 1 # Response to registration + DEREGISTRATION_REQUEST = 2 # Cleanup on disconnect + DEREGISTRATION_RESPONSE = 3 # Deregistration acknowledgment + NODE_RESET_INDICATION = 4 # Node will reset + BRIDGE_REGISTRATION_REQUEST = 5 # Bridge registration + START_NODE_IDENTIFICATION = 6 # Start identification + START_NODE_IDENTIFICATION_RESPONSE = 7 + STOP_NODE_IDENTIFICATION = 8 # Stop identification + STOP_NODE_IDENTIFICATION_RESPONSE = 9 + LIST_OF_REGISTERED_MODULES_REQUEST = 10 # Request registered modules + LIST_OF_REGISTERED_MODULES_RESPONSE = 11 + HARP_PROTOCOL_REQUEST = 12 # Request objects (most important!) + HARP_PROTOCOL_RESPONSE = 13 # Response with object list + HARP_NODE_REMOVED_FROM_NETWORK = 14 + LIST_OF_REGISTERED_NODES_REQUEST = 15 + LIST_OF_REGISTERED_NODES_RESPONSE = 16 + + +class RegistrationOptionType(IntEnum): + """Registration2 option types (byte 0 of each option). + + From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.Option. + + These are semantic labels for the TYPE of information (what it means), while the + actual data inside uses Hamilton type_ids (how it's encoded). + """ + + RESERVED = 0 # Padding for 16-bit alignment when odd number of unsupported options + INCOMPATIBLE_VERSION = 1 # Version mismatch error (HARP version too high) + UNSUPPORTED_OPTIONS = 2 # Unknown options error + START_NODE_IDENTIFICATION = 3 # Identification timeout (seconds) + HARP_NETWORK_ADDRESS = 4 # Registered module/node IDs + HARP_PROTOCOL_REQUEST = 5 # Protocol request + HARP_PROTOCOL_RESPONSE = 6 # PRIMARY: Contains object ID lists (most commonly used) + + +class HamiltonDataType(IntEnum): + """Hamilton parameter data types for wire encoding in DataFragments. + + These constants represent the type identifiers used in Hamilton DataFragments + for HOI2 command parameters. Each type ID corresponds to a specific data format + and encoding scheme used on the wire. + + From Hamilton.Components.TransportLayer.Protocols.Parameter.ParameterTypes. + """ + + # Scalar integer types + I8 = 1 + I16 = 2 + I32 = 3 + U8 = 4 + U16 = 5 + U32 = 6 + I64 = 36 + U64 = 37 + + # Floating-point types + F32 = 40 + F64 = 41 + + # String and boolean + STRING = 15 + BOOL = 23 + + # Array types + U8_ARRAY = 22 + I8_ARRAY = 24 + I16_ARRAY = 25 + U16_ARRAY = 26 + I32_ARRAY = 27 + U32_ARRAY = 28 + BOOL_ARRAY = 29 + STRING_ARRAY = 34 + I64_ARRAY = 38 + U64_ARRAY = 39 + F32_ARRAY = 42 + F64_ARRAY = 43 + + +class HoiRequestId(IntEnum): + """Request types for HarpProtocolRequest (byte 3 in command_data). + + From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.HarpProtocolRequest.HoiRequestId. + """ + + ROOT_OBJECT_OBJECT_ID = 1 # Request root objects (pipette, deck, etc.) + GLOBAL_OBJECT_ADDRESS = 2 # Request global objects + CPU_OBJECT_ADDRESS = 3 # Request CPU objects diff --git a/pylabrobot/hamilton/tcp/tests/__init__.py b/pylabrobot/hamilton/tcp/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py index 8536e33d939..7b8c7391169 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py @@ -1,28 +1,53 @@ -"""Hamilton Nimbus backend implementation. +"""Hamilton Nimbus backend implementation (legacy wrapper). This module provides the NimbusBackend class for controlling Hamilton Nimbus instruments via TCP communication using the Hamilton protocol. + +The implementation delegates to the v1b1 modules: +- Command classes: pylabrobot.hamilton.liquid_handlers.nimbus.commands +- PIP operations: pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend +- Door control: pylabrobot.hamilton.liquid_handlers.nimbus.door """ from __future__ import annotations -import enum import logging -from typing import Dict, List, Optional, Sequence, Tuple, TypeVar, Union - -from pylabrobot.legacy.liquid_handling.backends.hamilton.common import fill_in_defaults -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.introspection import ( - HamiltonIntrospection, +from typing import Dict, List, Optional + +# Re-exported for backward compatibility (tests import these from this module) +from pylabrobot.hamilton.liquid_handlers.nimbus.commands import ( # noqa: F401 + Aspirate, + Dispense, + DisableADC, + DropTips, + DropTipsRoll, + EnableADC, + GetChannelConfiguration, + GetChannelConfiguration_1, + InitializeSmartRoll, + IsDoorLocked, + IsInitialized, + IsTipPresent, + LockDoor, + NimbusTipType, + Park, + PickupTips, + PreInitializeSmart, + SetChannelConfiguration, + UnlockDoor, + _get_default_flow_rate, + _get_tip_type_from_tip, ) -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( - HoiParams, - HoiParamsParser, -) -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( - HamiltonProtocol, +from pylabrobot.hamilton.liquid_handlers.nimbus.door import NimbusDoor +from pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend import ( + NimbusPIPAspirateParams, + NimbusPIPBackend, + NimbusPIPDispenseParams, + NimbusPIPDropTipsParams, + NimbusPIPPickUpTipsParams, ) +from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection +from pylabrobot.hamilton.tcp.packets import Address from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend from pylabrobot.legacy.liquid_handling.standard import ( Drop, @@ -33,7 +58,6 @@ MultiHeadDispensePlate, Pickup, PickupTipRack, - PipettingOp, ResourceDrop, ResourceMove, ResourcePickup, @@ -41,877 +65,18 @@ SingleChannelDispense, ) from pylabrobot.resources import Tip -from pylabrobot.resources.container import Container -from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.hamilton import HamiltonTip, TipSize # noqa: F401 from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck -from pylabrobot.resources.trash import Trash logger = logging.getLogger(__name__) -T = TypeVar("T") - - -# ============================================================================ -# TIP TYPE ENUM -# ============================================================================ - - -class NimbusTipType(enum.IntEnum): - """Hamilton Nimbus tip type enumeration. - - Maps tip type names to their integer values used in Hamilton protocol commands. - """ - - STANDARD_300UL = 0 # "300ul Standard Volume Tip" - STANDARD_300UL_FILTER = 1 # "300ul Standard Volume Tip with filter" - LOW_VOLUME_10UL = 2 # "10ul Low Volume Tip" - LOW_VOLUME_10UL_FILTER = 3 # "10ul Low Volume Tip with filter" - HIGH_VOLUME_1000UL = 4 # "1000ul High Volume Tip" - HIGH_VOLUME_1000UL_FILTER = 5 # "1000ul High Volume Tip with filter" - TIP_50UL = 22 # "50ul Tip" - TIP_50UL_FILTER = 23 # "50ul Tip with filter" - SLIM_CORE_300UL = 36 # "SLIM CO-RE Tip 300ul" - - -def _get_tip_type_from_tip(tip: Tip) -> int: - """Map Tip object characteristics to Hamilton tip type integer. - - Args: - tip: Tip object with volume and filter information. Must be a HamiltonTip. - - Returns: - Hamilton tip type integer value. - - Raises: - ValueError: If tip characteristics don't match any known tip type. - """ - - if not isinstance(tip, HamiltonTip): - raise ValueError("Tip must be a HamiltonTip to determine tip type.") - - if tip.tip_size == TipSize.LOW_VOLUME: # 10ul tip - return NimbusTipType.LOW_VOLUME_10UL_FILTER if tip.has_filter else NimbusTipType.LOW_VOLUME_10UL - - if tip.tip_size == TipSize.STANDARD_VOLUME and tip.maximal_volume < 60: # 50ul tip - return NimbusTipType.TIP_50UL_FILTER if tip.has_filter else NimbusTipType.TIP_50UL - - if tip.tip_size == TipSize.STANDARD_VOLUME: # 300ul tip - return NimbusTipType.STANDARD_300UL_FILTER if tip.has_filter else NimbusTipType.STANDARD_300UL - - if tip.tip_size == TipSize.HIGH_VOLUME: # 1000ul tip - return ( - NimbusTipType.HIGH_VOLUME_1000UL_FILTER - if tip.has_filter - else NimbusTipType.HIGH_VOLUME_1000UL - ) - - raise ValueError( - f"Cannot determine tip type for tip with volume {tip.maximal_volume}uL " - f"and filter={tip.has_filter}. No matching Hamilton tip type found." - ) - - -def _get_default_flow_rate(tip: Tip, is_aspirate: bool) -> float: - """Get default flow rate based on tip type. - - Defaults from Hamilton Nimbus: - - 1000 ul tip: 250 asp / 400 disp - - 300 and 50 ul tip: 100 asp / 180 disp - - 10 ul tip: 100 asp / 75 disp - - Args: - tip: Tip object to determine default flow rate for. - is_aspirate: True for aspirate, False for dispense. - - Returns: - Default flow rate in uL/s. - """ - tip_type = _get_tip_type_from_tip(tip) - - if tip_type in (NimbusTipType.HIGH_VOLUME_1000UL, NimbusTipType.HIGH_VOLUME_1000UL_FILTER): - return 250.0 if is_aspirate else 400.0 - - if tip_type in (NimbusTipType.LOW_VOLUME_10UL, NimbusTipType.LOW_VOLUME_10UL_FILTER): - return 100.0 if is_aspirate else 75.0 - - # 50 and 300 ul tips - return 100.0 if is_aspirate else 180.0 - - -# ============================================================================ -# COMMAND CLASSES -# ============================================================================ - - -class LockDoor(HamiltonCommand): - """Lock door command (DoorLock at 1:1:268, interface_id=1, command_id=1).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 1 - - -class UnlockDoor(HamiltonCommand): - """Unlock door command (DoorLock at 1:1:268, interface_id=1, command_id=2).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 2 - - -class IsDoorLocked(HamiltonCommand): - """Check if door is locked (DoorLock at 1:1:268, interface_id=1, command_id=3).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 3 - action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsDoorLocked response.""" - parser = HoiParamsParser(data) - _, locked = parser.parse_next() - return {"locked": bool(locked)} - - -class PreInitializeSmart(HamiltonCommand): - """Pre-initialize smart command (Pipette at 1:1:257, interface_id=1, command_id=32).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 32 - - -class InitializeSmartRoll(HamiltonCommand): - """Initialize smart roll command (NimbusCore at 1:1:48896, interface_id=1, command_id=29).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 29 - - def __init__( - self, - dest: Address, - x_positions: List[int], - y_positions: List[int], - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - roll_distances: List[int], - ): - """Initialize InitializeSmartRoll command. - - Args: - dest: Destination address (NimbusCore) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - roll_distances: Roll distances in 0.01mm units - """ - super().__init__(dest) - self.x_positions = x_positions - self.y_positions = y_positions - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.roll_distances = roll_distances - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .i32_array(self.roll_distances) - ) - - -class IsInitialized(HamiltonCommand): - """Check if instrument is initialized (NimbusCore at 1:1:48896, interface_id=1, command_id=14).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 14 - action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsInitialized response.""" - parser = HoiParamsParser(data) - _, initialized = parser.parse_next() - return {"initialized": bool(initialized)} - - -class IsTipPresent(HamiltonCommand): - """Check tip presence (Pipette at 1:1:257, interface_id=1, command_id=16).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 16 - action_code = 0 - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsTipPresent response - returns List[i16].""" - parser = HoiParamsParser(data) - # Parse array of i16 values representing tip presence per channel - _, tip_presence = parser.parse_next() - return {"tip_present": tip_presence} - - -class GetChannelConfiguration_1(HamiltonCommand): - """Get channel configuration (NimbusCore root, interface_id=1, command_id=15).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 15 - action_code = 0 - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetChannelConfiguration_1 response. - - Returns: (channels: u16, channel_types: List[i16]) - """ - parser = HoiParamsParser(data) - _, channels = parser.parse_next() - _, channel_types = parser.parse_next() - return {"channels": channels, "channel_types": channel_types} - - -class SetChannelConfiguration(HamiltonCommand): - """Set channel configuration (Pipette at 1:1:257, interface_id=1, command_id=67).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 67 - - def __init__( - self, - dest: Address, - channel: int, - indexes: List[int], - enables: List[bool], - ): - """Initialize SetChannelConfiguration command. - - Args: - dest: Destination address (Pipette) - channel: Channel number (1-based) - indexes: List of configuration indexes (e.g., [1, 3, 4]) - 1: Tip Recognition, 2: Aspirate and clot monitoring pLLD, - 3: Aspirate monitoring with cLLD, 4: Clot monitoring with cLLD - enables: List of enable flags (e.g., [True, False, False, False]) - """ - super().__init__(dest) - self.channel = channel - self.indexes = indexes - self.enables = enables - - def build_parameters(self) -> HoiParams: - return HoiParams().u16(self.channel).i16_array(self.indexes).bool_array(self.enables) - - -class Park(HamiltonCommand): - """Park command (NimbusCore at 1:1:48896, interface_id=1, command_id=3).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 3 - - -class PickupTips(HamiltonCommand): - """Pick up tips command (Pipette at 1:1:257, interface_id=1, command_id=4).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 4 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_pick_up_process: List[int], - end_tip_pick_up_process: List[int], - tip_types: List[int], - ): - """Initialize PickupTips command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_pick_up_process: Z start positions in 0.01mm units - end_tip_pick_up_process: Z stop positions in 0.01mm units - tip_types: Tip type integers for each channel - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_pick_up_process = begin_tip_pick_up_process - self.end_tip_pick_up_process = end_tip_pick_up_process - self.tip_types = tip_types - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_pick_up_process) - .i32_array(self.end_tip_pick_up_process) - .u16_array(self.tip_types) - ) - - -class DropTips(HamiltonCommand): - """Drop tips command (Pipette at 1:1:257, interface_id=1, command_id=5).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 5 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - default_waste: bool, - ): - """Initialize DropTips command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - default_waste: If True, drop to default waste (positions may be ignored) - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.default_waste = default_waste - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .bool_value(self.default_waste) - ) - - -class DropTipsRoll(HamiltonCommand): - """Drop tips with roll command (Pipette at 1:1:257, interface_id=1, command_id=82).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 82 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - roll_distances: List[int], - ): - """Initialize DropTipsRoll command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - roll_distances: Roll distance for each channel in 0.01mm units - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.roll_distances = roll_distances - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .i32_array(self.roll_distances) - ) - - -class EnableADC(HamiltonCommand): - """Enable ADC command (Pipette at 1:1:257, interface_id=1, command_id=43).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 43 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - ): - """Initialize EnableADC command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - """ - super().__init__(dest) - self.channels_involved = channels_involved - - def build_parameters(self) -> HoiParams: - return HoiParams().u16_array(self.channels_involved) - - -class DisableADC(HamiltonCommand): - """Disable ADC command (Pipette at 1:1:257, interface_id=1, command_id=44).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 44 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - ): - """Initialize DisableADC command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - """ - super().__init__(dest) - self.channels_involved = channels_involved - - def build_parameters(self) -> HoiParams: - return HoiParams().u16_array(self.channels_involved) - - -class GetChannelConfiguration(HamiltonCommand): - """Get channel configuration command (Pipette at 1:1:257, interface_id=1, command_id=66).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 66 - action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) - - def __init__( - self, - dest: Address, - channel: int, - indexes: List[int], - ): - """Initialize GetChannelConfiguration command. - - Args: - dest: Destination address (Pipette) - channel: Channel number (1-based) - indexes: List of configuration indexes (e.g., [2] for "Aspirate monitoring with cLLD") - """ - super().__init__(dest) - self.channel = channel - self.indexes = indexes - - def build_parameters(self) -> HoiParams: - return HoiParams().u16(self.channel).i16_array(self.indexes) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetChannelConfiguration response. - - Returns: { enabled: List[bool] } - """ - parser = HoiParamsParser(data) - _, enabled = parser.parse_next() - return {"enabled": enabled} - - -class Aspirate(HamiltonCommand): - """Aspirate command (Pipette at 1:1:257, interface_id=1, command_id=6).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 6 - - def __init__( - self, - dest: Address, - aspirate_type: List[int], - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - lld_search_height: List[int], - liquid_height: List[int], - immersion_depth: List[int], - surface_following_distance: List[int], - minimum_height: List[int], - clot_detection_height: List[int], - min_z_endpos: int, - swap_speed: List[int], - blow_out_air_volume: List[int], - pre_wetting_volume: List[int], - aspirate_volume: List[int], - transport_air_volume: List[int], - aspiration_speed: List[int], - settling_time: List[int], - mix_volume: List[int], - mix_cycles: List[int], - mix_position_from_liquid_surface: List[int], - mix_surface_following_distance: List[int], - mix_speed: List[int], - tube_section_height: List[int], - tube_section_ratio: List[int], - lld_mode: List[int], - gamma_lld_sensitivity: List[int], - dp_lld_sensitivity: List[int], - lld_height_difference: List[int], - tadm_enabled: bool, - limit_curve_index: List[int], - recording_mode: int, - ): - """Initialize Aspirate command. - - Args: - dest: Destination address (Pipette) - aspirate_type: Aspirate type for each channel (List[i16]) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - lld_search_height: LLD search height for each channel in 0.01mm units - liquid_height: Liquid height for each channel in 0.01mm units - immersion_depth: Immersion depth for each channel in 0.01mm units - surface_following_distance: Surface following distance for each channel in 0.01mm units - minimum_height: Minimum height for each channel in 0.01mm units - clot_detection_height: Clot detection height for each channel in 0.01mm units - min_z_endpos: Minimum Z end position in 0.01mm units - swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units - blow_out_air_volume: Blowout volume for each channel in 0.1uL units - pre_wetting_volume: Pre-wetting volume for each channel in 0.1uL units - aspirate_volume: Aspirate volume for each channel in 0.1uL units - transport_air_volume: Transport air volume for each channel in 0.1uL units - aspiration_speed: Aspirate speed for each channel in 0.1uL/s units - settling_time: Settling time for each channel in 0.1s units - mix_volume: Mix volume for each channel in 0.1uL units - mix_cycles: Mix cycles for each channel - mix_position_from_liquid_surface: Mix position from liquid surface for each channel in 0.01mm units - mix_surface_following_distance: Mix follow distance for each channel in 0.01mm units - mix_speed: Mix speed for each channel in 0.1uL/s units - tube_section_height: Tube section height for each channel in 0.01mm units - tube_section_ratio: Tube section ratio for each channel - lld_mode: LLD mode for each channel (List[i16]) - gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) - dp_lld_sensitivity: DP LLD sensitivity for each channel (List[i16]) - lld_height_difference: LLD height difference for each channel in 0.01mm units - tadm_enabled: TADM enabled flag - limit_curve_index: Limit curve index for each channel - recording_mode: Recording mode (u16) - """ - super().__init__(dest) - self.aspirate_type = aspirate_type - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.lld_search_height = lld_search_height - self.liquid_height = liquid_height - self.immersion_depth = immersion_depth - self.surface_following_distance = surface_following_distance - self.minimum_height = minimum_height - self.clot_detection_height = clot_detection_height - self.min_z_endpos = min_z_endpos - self.swap_speed = swap_speed - self.blow_out_air_volume = blow_out_air_volume - self.pre_wetting_volume = pre_wetting_volume - self.aspirate_volume = aspirate_volume - self.transport_air_volume = transport_air_volume - self.aspiration_speed = aspiration_speed - self.settling_time = settling_time - self.mix_volume = mix_volume - self.mix_cycles = mix_cycles - self.mix_position_from_liquid_surface = mix_position_from_liquid_surface - self.mix_surface_following_distance = mix_surface_following_distance - self.mix_speed = mix_speed - self.tube_section_height = tube_section_height - self.tube_section_ratio = tube_section_ratio - self.lld_mode = lld_mode - self.gamma_lld_sensitivity = gamma_lld_sensitivity - self.dp_lld_sensitivity = dp_lld_sensitivity - self.lld_height_difference = lld_height_difference - self.tadm_enabled = tadm_enabled - self.limit_curve_index = limit_curve_index - self.recording_mode = recording_mode - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i16_array(self.aspirate_type) - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.lld_search_height) - .i32_array(self.liquid_height) - .i32_array(self.immersion_depth) - .i32_array(self.surface_following_distance) - .i32_array(self.minimum_height) - .i32_array(self.clot_detection_height) - .i32(self.min_z_endpos) - .u32_array(self.swap_speed) - .u32_array(self.blow_out_air_volume) - .u32_array(self.pre_wetting_volume) - .u32_array(self.aspirate_volume) - .u32_array(self.transport_air_volume) - .u32_array(self.aspiration_speed) - .u32_array(self.settling_time) - .u32_array(self.mix_volume) - .u32_array(self.mix_cycles) - .i32_array(self.mix_position_from_liquid_surface) - .i32_array(self.mix_surface_following_distance) - .u32_array(self.mix_speed) - .i32_array(self.tube_section_height) - .i32_array(self.tube_section_ratio) - .i16_array(self.lld_mode) - .i16_array(self.gamma_lld_sensitivity) - .i16_array(self.dp_lld_sensitivity) - .i32_array(self.lld_height_difference) - .bool_value(self.tadm_enabled) - .u32_array(self.limit_curve_index) - .u16(self.recording_mode) - ) - - -class Dispense(HamiltonCommand): - """Dispense command (Pipette at 1:1:257, interface_id=1, command_id=7).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 7 - - def __init__( - self, - dest: Address, - dispense_type: List[int], - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - lld_search_height: List[int], - liquid_height: List[int], - immersion_depth: List[int], - surface_following_distance: List[int], - minimum_height: List[int], - min_z_endpos: int, - swap_speed: List[int], - transport_air_volume: List[int], - dispense_volume: List[int], - stop_back_volume: List[int], - blow_out_air_volume: List[int], - dispense_speed: List[int], - cut_off_speed: List[int], - settling_time: List[int], - mix_volume: List[int], - mix_cycles: List[int], - mix_position_from_liquid_surface: List[int], - mix_surface_following_distance: List[int], - mix_speed: List[int], - side_touch_off_distance: int, - dispense_offset: List[int], - tube_section_height: List[int], - tube_section_ratio: List[int], - lld_mode: List[int], - gamma_lld_sensitivity: List[int], - tadm_enabled: bool, - limit_curve_index: List[int], - recording_mode: int, - ): - """Initialize Dispense command. - - Args: - dest: Destination address (Pipette) - dispense_type: Dispense type for each channel (List[i16]) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - lld_search_height: LLD search height for each channel in 0.01mm units - liquid_height: Liquid height for each channel in 0.01mm units - immersion_depth: Immersion depth for each channel in 0.01mm units - surface_following_distance: Surface following distance for each channel in 0.01mm units - minimum_height: Minimum height for each channel in 0.01mm units - min_z_endpos: Minimum Z end position in 0.01mm units - swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units - transport_air_volume: Transport air volume for each channel in 0.1uL units - dispense_volume: Dispense volume for each channel in 0.1uL units - stop_back_volume: Stop back volume for each channel in 0.1uL units - blow_out_air_volume: Blowout volume for each channel in 0.1uL units - dispense_speed: Dispense speed for each channel in 0.1uL/s units - cut_off_speed: Cut off speed for each channel in 0.1uL/s units - settling_time: Settling time for each channel in 0.1s units - mix_volume: Mix volume for each channel in 0.1uL units - mix_cycles: Mix cycles for each channel - mix_position_from_liquid_surface: Mix position from liquid surface for each channel in 0.01mm units - mix_surface_following_distance: Mix follow distance for each channel in 0.01mm units - mix_speed: Mix speed for each channel in 0.1uL/s units - side_touch_off_distance: Side touch off distance in 0.01mm units - dispense_offset: Dispense offset for each channel in 0.01mm units - tube_section_height: Tube section height for each channel in 0.01mm units - tube_section_ratio: Tube section ratio for each channel - lld_mode: LLD mode for each channel (List[i16]) - gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) - tadm_enabled: TADM enabled flag - limit_curve_index: Limit curve index for each channel - recording_mode: Recording mode (u16) - """ - super().__init__(dest) - self.dispense_type = dispense_type - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.lld_search_height = lld_search_height - self.liquid_height = liquid_height - self.immersion_depth = immersion_depth - self.surface_following_distance = surface_following_distance - self.minimum_height = minimum_height - self.min_z_endpos = min_z_endpos - self.swap_speed = swap_speed - self.transport_air_volume = transport_air_volume - self.dispense_volume = dispense_volume - self.stop_back_volume = stop_back_volume - self.blow_out_air_volume = blow_out_air_volume - self.dispense_speed = dispense_speed - self.cut_off_speed = cut_off_speed - self.settling_time = settling_time - self.mix_volume = mix_volume - self.mix_cycles = mix_cycles - self.mix_position_from_liquid_surface = mix_position_from_liquid_surface - self.mix_surface_following_distance = mix_surface_following_distance - self.mix_speed = mix_speed - self.side_touch_off_distance = side_touch_off_distance - self.dispense_offset = dispense_offset - self.tube_section_height = tube_section_height - self.tube_section_ratio = tube_section_ratio - self.lld_mode = lld_mode - self.gamma_lld_sensitivity = gamma_lld_sensitivity - self.tadm_enabled = tadm_enabled - self.limit_curve_index = limit_curve_index - self.recording_mode = recording_mode - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i16_array(self.dispense_type) - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.lld_search_height) - .i32_array(self.liquid_height) - .i32_array(self.immersion_depth) - .i32_array(self.surface_following_distance) - .i32_array(self.minimum_height) - .i32(self.min_z_endpos) - .u32_array(self.swap_speed) - .u32_array(self.transport_air_volume) - .u32_array(self.dispense_volume) - .u32_array(self.stop_back_volume) - .u32_array(self.blow_out_air_volume) - .u32_array(self.dispense_speed) - .u32_array(self.cut_off_speed) - .u32_array(self.settling_time) - .u32_array(self.mix_volume) - .u32_array(self.mix_cycles) - .i32_array(self.mix_position_from_liquid_surface) - .i32_array(self.mix_surface_following_distance) - .u32_array(self.mix_speed) - .i32(self.side_touch_off_distance) - .i32_array(self.dispense_offset) - .i32_array(self.tube_section_height) - .i32_array(self.tube_section_ratio) - .i16_array(self.lld_mode) - .i16_array(self.gamma_lld_sensitivity) - .bool_value(self.tadm_enabled) - .u32_array(self.limit_curve_index) - .u16(self.recording_mode) - ) - - -# ============================================================================ -# MAIN BACKEND CLASS -# ============================================================================ - - class NimbusBackend(HamiltonTCPBackend): """Backend for Hamilton Nimbus liquid handling instruments. This backend uses TCP communication with the Hamilton protocol to control - Nimbus instruments. It inherits from both TCPBackend (for communication) - and LiquidHandlerBackend (for liquid handling interface). + Nimbus instruments. It delegates pipetting operations and door control to + the v1b1 implementation while maintaining the legacy API. Attributes: _door_lock_available: Whether door lock is available on this instrument. @@ -954,6 +119,10 @@ def __init__( self._channel_traversal_height: float = 146.0 # Default traversal height in mm + # v1b1 delegates (created in setup) + self._pip_backend: Optional[NimbusPIPBackend] = None + self._door: Optional[NimbusDoor] = None + async def setup(self, unlock_door: bool = False, force_initialize: bool = False): """Set up the Nimbus backend. @@ -983,7 +152,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) if self._nimbus_core_address is None: raise RuntimeError("NimbusCore root object not discovered. Cannot proceed with setup.") - # Query channel configuration to get num_channels (use discovered address only) + # Query channel configuration to get num_channels try: config = await self.send_command(GetChannelConfiguration_1(self._nimbus_core_address)) assert config is not None, "GetChannelConfiguration_1 command returned None" @@ -993,14 +162,30 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) logger.error(f"Failed to query channel configuration: {e}") raise - # Query tip presence (use discovered address only) + # Create v1b1 PIP backend delegate + self._pip_backend = NimbusPIPBackend( + driver=self, # type: ignore[arg-type] # legacy backend duck-types as driver + deck=self.deck if isinstance(self.deck, NimbusDeck) else None, + address=self._pipette_address, + num_channels=self._num_channels, + traversal_height=self._channel_traversal_height, + ) + + # Create v1b1 door delegate + if self._door_lock_address is not None: + self._door = NimbusDoor( + driver=self, # type: ignore[arg-type] + address=self._door_lock_address, + ) + + # Query tip presence try: tip_present = await self.request_tip_presence() logger.info(f"Tip presence: {tip_present}") except Exception as e: logger.warning(f"Failed to query tip presence: {e}") - # Query initialization status (use discovered address only) + # Query initialization status try: init_status = await self.send_command(IsInitialized(self._nimbus_core_address)) assert init_status is not None, "IsInitialized command returned None" @@ -1010,26 +195,22 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) logger.error(f"Failed to query initialization status: {e}") raise - # Lock door if available (optional - no error if not found) - # This happens before initialization - if self._door_lock_address is not None: + # Lock door if available + if self._door is not None: try: if not await self.is_door_locked(): await self.lock_door() else: logger.info("Door already locked") except RuntimeError: - # Door lock not available or not set up - this is okay logger.warning("Door lock operations skipped (not available or not set up)") except Exception as e: logger.warning(f"Failed to lock door: {e}") # Conditional initialization - only if not already initialized if not self._is_initialized or force_initialize: - # Set channel configuration for each channel (required before InitializeSmartRoll) + # Set channel configuration for each channel try: - # Configure all channels (1 to num_channels) - one SetChannelConfiguration call per channel - # Parameters: channel (1-based), indexes=[1, 3, 4], enables=[True, False, False, False] for channel in range(1, self.num_channels + 1): await self.send_command( SetChannelConfiguration( @@ -1046,11 +227,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) # Initialize NimbusCore with InitializeSmartRoll using waste positions try: - # Build waste position parameters using helper method - # Use all channels (0 to num_channels-1) for setup all_channels = list(range(self.num_channels)) - - # Use same logic as DropTipsRoll: z_start = waste_z + 4.0mm, z_stop = waste_z, z_position_at_end = minimum_traverse_height_at_beginning_of_a_command ( x_positions_full, y_positions_full, @@ -1058,11 +235,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) end_tip_deposit_process_full, z_position_at_end_of_a_command_full, roll_distances_full, - ) = self._build_waste_position_params( - use_channels=all_channels, - z_position_at_end_of_a_command=None, # Will default to minimum_traverse_height_at_beginning_of_a_command - roll_distance=None, # Will default to 9.0mm - ) + ) = self._pip_backend._build_waste_position_params(use_channels=all_channels) await self.send_command( InitializeSmartRoll( @@ -1083,12 +256,11 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) else: logger.info("Instrument already initialized, skipping initialization") - # Unlock door if requested (optional - no error if not found) - if unlock_door and self._door_lock_address is not None: + # Unlock door if requested + if unlock_door and self._door is not None: try: await self.unlock_door() except RuntimeError: - # Door lock not available or not set up - this is okay logger.warning("Door unlock requested but not available or not set up") except Exception as e: logger.warning(f"Failed to unlock door: {e}") @@ -1097,32 +269,26 @@ async def _discover_instrument_objects(self): """Discover instrument-specific objects using introspection.""" introspection = HamiltonIntrospection(self) - # Get root objects (already discovered in setup) root_objects = self._discovered_objects.get("root", []) if not root_objects: logger.warning("No root objects discovered") return - # Use first root object as NimbusCore nimbus_core_addr = root_objects[0] self._nimbus_core_address = nimbus_core_addr try: - # Get NimbusCore object info core_info = await introspection.get_object(nimbus_core_addr) - # Discover subobjects to find Pipette and DoorLock for i in range(core_info.subobject_count): try: sub_addr = await introspection.get_subobject_address(nimbus_core_addr, i) sub_info = await introspection.get_object(sub_addr) - # Check if this is the Pipette by interface name if sub_info.name == "Pipette": self._pipette_address = sub_addr logger.info(f"Found Pipette at {sub_addr}") - # Check if this is the DoorLock by interface name if sub_info.name == "DoorLock": self._door_lock_address = sub_addr logger.info(f"Found DoorLock at {sub_addr}") @@ -1133,22 +299,13 @@ async def _discover_instrument_objects(self): except Exception as e: logger.warning(f"Failed to discover instrument objects: {e}") - # If door lock not found via introspection, it's not available if self._door_lock_address is None: logger.info("DoorLock not available on this instrument") - def _fill_by_channels(self, values: List[T], use_channels: List[int], default: T) -> List[T]: - """Returns a full-length list of size `num_channels` where positions in `channels` - are filled from `values` in order; all others are `default`. Similar to one-hot encoding.""" - if len(values) != len(use_channels): - raise ValueError( - f"values and channels must have same length (got {len(values)} vs {len(use_channels)})" - ) - - out = [default] * self.num_channels - for ch, v in zip(use_channels, values): - out[ch] = v - return out + def _fill_by_channels(self, values, use_channels, default): + """Delegate to PIP backend.""" + assert self._pip_backend is not None, "Call setup() first." + return self._pip_backend._fill_by_channels(values, use_channels, default) @property def num_channels(self) -> int: @@ -1158,27 +315,17 @@ def num_channels(self) -> int: return self._num_channels def set_minimum_channel_traversal_height(self, traversal_height: float): - """Set the minimum traversal height for the channels. - - This value will be used as the default value for the - `minimal_traverse_height_at_begin_of_command` and `minimal_height_at_command_end` parameters - for all commands, unless they are explicitly set in the command call. - """ - + """Set the minimum traversal height for the channels.""" if not 0 < traversal_height < 146: raise ValueError(f"Traversal height must be between 0 and 146 mm (got {traversal_height})") - self._channel_traversal_height = traversal_height + if self._pip_backend is not None: + self._pip_backend.traversal_height = traversal_height async def park(self): - """Park the instrument. - - Raises: - RuntimeError: If NimbusCore address was not discovered during setup. - """ + """Park the instrument.""" if self._nimbus_core_address is None: raise RuntimeError("NimbusCore address not discovered. Call setup() first.") - try: await self.send_command(Park(self._nimbus_core_address)) logger.info("Instrument parked successfully") @@ -1186,264 +333,45 @@ async def park(self): logger.error(f"Failed to park instrument: {e}") raise - async def is_door_locked(self) -> bool: - """Check if the door is locked. - - Returns: - True if door is locked, False if unlocked. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. - """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) + def _ensure_door(self) -> NimbusDoor: + """Get or lazily create the door delegate.""" + if self._door is not None: + return self._door + if self._door_lock_address is not None: + self._door = NimbusDoor(driver=self, address=self._door_lock_address) # type: ignore[arg-type] + return self._door + raise RuntimeError( + "Door lock is not available on this instrument or setup() has not been called." + ) - try: - status = await self.send_command(IsDoorLocked(self._door_lock_address)) - assert status is not None, "IsDoorLocked command returned None" - return bool(status["locked"]) - except Exception as e: - logger.error(f"Failed to check door lock status: {e}") - raise + async def is_door_locked(self) -> bool: + """Check if the door is locked.""" + return await self._ensure_door().is_locked() async def lock_door(self) -> None: - """Lock the door. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. - """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) - - try: - await self.send_command(LockDoor(self._door_lock_address)) - logger.info("Door locked successfully") - except Exception as e: - logger.error(f"Failed to lock door: {e}") - raise + """Lock the door.""" + await self._ensure_door().lock() async def unlock_door(self) -> None: - """Unlock the door. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. - """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) - - try: - await self.send_command(UnlockDoor(self._door_lock_address)) - logger.info("Door unlocked successfully") - except Exception as e: - logger.error(f"Failed to unlock door: {e}") - raise + """Unlock the door.""" + await self._ensure_door().unlock() async def stop(self): """Stop the backend and close connection.""" await HamiltonTCPBackend.stop(self) async def request_tip_presence(self) -> List[Optional[bool]]: - """Request tip presence on each channel. - - Returns: - A list of length `num_channels` where each element is `True` if a tip is mounted, - `False` if not, or `None` if unknown. - """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - tip_status = await self.send_command(IsTipPresent(self._pipette_address)) - assert tip_status is not None, "IsTipPresent command returned None" - tip_present = tip_status.get("tip_present", []) - return [bool(v) for v in tip_present] - - def _build_waste_position_params( - self, - use_channels: List[int], - z_position_at_end_of_a_command: Optional[float] = None, - roll_distance: Optional[float] = None, - ) -> Tuple[List[int], List[int], List[int], List[int], List[int], List[int]]: - """Build waste position parameters for InitializeSmartRoll or DropTipsRoll. - - Args: - use_channels: List of channel indices to use - z_position_at_end_of_a_command: Z final position in mm (absolute, optional, defaults to minimum_traverse_height_at_beginning_of_a_command) - roll_distance: Roll distance in mm (optional, defaults to 9.0 mm) - - Returns: - x_positions, y_positions, begin_tip_deposit_process_full, end_tip_deposit_process_full, z_position_at_end_of_a_command, roll_distances (all in 0.01mm units as lists matching num_channels) - - Raises: - RuntimeError: If deck is not set or waste position not found - """ - - # Validate we have a NimbusDeck for coordinate conversion - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - # Extract coordinates for each channel - x_positions_mm: List[float] = [] - y_positions_mm: List[float] = [] - z_positions_mm: List[float] = [] - - for channel_idx in use_channels: - # Get waste position from deck based on channel index - # Use waste_type attribute from deck to construct waste position name - if not hasattr(self.deck, "waste_type") or self.deck.waste_type is None: - raise RuntimeError( - f"Deck does not have waste_type attribute or waste_type is None. " - f"Cannot determine waste position name for channel {channel_idx}." - ) - waste_pos_name = f"{self.deck.waste_type}_{channel_idx + 1}" - try: - waste_pos = self.deck.get_resource(waste_pos_name) - abs_location = waste_pos.get_location_wrt(self.deck) - except Exception as e: - raise RuntimeError( - f"Failed to get waste position {waste_pos_name} for channel {channel_idx}: {e}" - ) - - # Convert to Hamilton coordinates (returns in mm) - hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) - - x_positions_mm.append(hamilton_coord.x) - y_positions_mm.append(hamilton_coord.y) - z_positions_mm.append(hamilton_coord.z) - - # Convert positions to 0.01mm units (multiply by 100) - x_positions = [round(x * 100) for x in x_positions_mm] - y_positions = [round(y * 100) for y in y_positions_mm] - - # Calculate Z positions from waste position coordinates - max_z_hamilton = max(z_positions_mm) # Highest waste position Z in Hamilton coordinates - waste_z_hamilton = max_z_hamilton - - # Calculate from waste position: start above waste position - z_start_absolute_mm = waste_z_hamilton + 4.0 # Start 4mm above waste position - - # Calculate from waste position: stop at waste position - z_stop_absolute_mm = waste_z_hamilton # Stop at waste position - - if z_position_at_end_of_a_command is None: - z_position_at_end_of_a_command = ( - self._channel_traversal_height - ) # Use traverse height as final position - - if roll_distance is None: - roll_distance = 9.0 # Default roll distance from log - - # Use absolute Z positions (same for all channels) - begin_tip_deposit_process = [round(z_start_absolute_mm * 100)] * len(use_channels) - end_tip_deposit_process = [round(z_stop_absolute_mm * 100)] * len(use_channels) - z_position_at_end_of_a_command_list = [round(z_position_at_end_of_a_command * 100)] * len( - use_channels - ) - roll_distances = [round(roll_distance * 100)] * len(use_channels) - - # Ensure arrays match num_channels length (with zeros for inactive channels) - x_positions_full = self._fill_by_channels(x_positions, use_channels, default=0) - y_positions_full = self._fill_by_channels(y_positions, use_channels, default=0) - begin_tip_deposit_process_full = self._fill_by_channels( - begin_tip_deposit_process, use_channels, default=0 - ) - end_tip_deposit_process_full = self._fill_by_channels( - end_tip_deposit_process, use_channels, default=0 - ) - z_position_at_end_of_a_command_full = self._fill_by_channels( - z_position_at_end_of_a_command_list, use_channels, default=0 - ) - roll_distances_full = self._fill_by_channels(roll_distances, use_channels, default=0) - - return ( - x_positions_full, - y_positions_full, - begin_tip_deposit_process_full, - end_tip_deposit_process_full, - z_position_at_end_of_a_command_full, - roll_distances_full, - ) - - # ============== Abstract methods from LiquidHandlerBackend ============== - - def _compute_ops_xy_locations( - self, ops: Sequence[PipettingOp], use_channels: List[int] - ) -> Tuple[List[int], List[int]]: - """Compute X and Y positions in Hamilton coordinates for the given operations.""" - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - x_positions_mm: List[float] = [] - y_positions_mm: List[float] = [] - - for op in ops: - abs_location = op.resource.get_location_wrt(self.deck) - final_location = abs_location + op.offset - hamilton_coord = self.deck.to_hamilton_coordinate(final_location) - - x_positions_mm.append(hamilton_coord.x) - y_positions_mm.append(hamilton_coord.y) - - # Convert positions to 0.01mm units (multiply by 100) - x_positions = [round(x * 100) for x in x_positions_mm] - y_positions = [round(y * 100) for y in y_positions_mm] - - x_positions_full = self._fill_by_channels(x_positions, use_channels, default=0) - y_positions_full = self._fill_by_channels(y_positions, use_channels, default=0) - - return x_positions_full, y_positions_full - - def _compute_tip_handling_parameters( - self, - ops: Sequence[Union[Pickup, Drop]], - use_channels: List[int], - use_fixed_offset: bool = False, - fixed_offset_mm: float = 10.0, - ): - """Calculate Z positions for tip pickup/drop operations. - - Pickup (use_fixed_offset=False): Z based on tip length - z_start = max_z + max_total_tip_length, z_stop = max_z + max_tip_length - Drop (use_fixed_offset=True): Z based on fixed offset (matches VantageBackend default) - z_start = max_z + fixed_offset_mm (default 10.0mm), z_stop = max_z - - Returns: (begin_position, end_position) in 0.01mm units - """ - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - z_positions_mm: List[float] = [] - for op in ops: - abs_location = op.resource.get_location_wrt(self.deck) + op.offset - hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) - z_positions_mm.append(hamilton_coord.z) - - max_z_hamilton = max(z_positions_mm) # Highest resource Z in Hamilton coordinates - - if use_fixed_offset: - # For drop operations: use fixed offsets relative to resource surface - begin_position_mm = max_z_hamilton + fixed_offset_mm - end_position_mm = max_z_hamilton - else: - # For pickup operations: use tip length - # Similar to STAR backend: z_start = max_z + max_total_tip_length, z_stop = max_z + max_tip_length - max_total_tip_length = max(op.tip.total_tip_length for op in ops) - max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) - begin_position_mm = max_z_hamilton + max_total_tip_length - end_position_mm = max_z_hamilton + max_tip_length - - # Convert to 0.01mm units - begin_position = [round(begin_position_mm * 100)] * len(ops) - end_position = [round(end_position_mm * 100)] * len(ops) - - begin_position_full = self._fill_by_channels(begin_position, use_channels, default=0) - end_position_full = self._fill_by_channels(end_position, use_channels, default=0) - - return begin_position_full, end_position_full + """Request tip presence on each channel.""" + if self._pip_backend is None: + # Fallback for calls during setup before pip_backend is created + if self._pipette_address is None: + raise RuntimeError("Pipette address not discovered. Call setup() first.") + tip_status = await self.send_command(IsTipPresent(self._pipette_address)) + assert tip_status is not None, "IsTipPresent command returned None" + return [bool(v) for v in tip_status.get("tip_present", [])] + return await self._pip_backend.request_tip_presence() + + # -- Pipetting operations: delegate to NimbusPIPBackend --------------------- async def pick_up_tips( self, @@ -1453,84 +381,21 @@ async def pick_up_tips( ): """Pick up tips from the specified resource. - TODO: evaluate this doc: - Z positions and traverse height are calculated from the resource locations and tip - properties if not explicitly provided: - - minimum_traverse_height_at_beginning_of_a_command: Uses deck z_max if not provided - - z_start_offset: Calculated as max(resource Z) + max(tip total_tip_length) - - z_stop_offset: Calculated as max(resource Z) + max(tip total_tip_length - tip fitting_depth) - Args: ops: List of Pickup operations, one per channel use_channels: List of channel indices to use - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm (optional, defaults to _channel_traversal_height) - - Raises: - RuntimeError: If pipette address or deck is not set - ValueError: If deck is not a NimbusDeck and minimum_traverse_height_at_beginning_of_a_command is not provided + minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (optional, defaults to _channel_traversal_height) """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - - # Validate we have a NimbusDeck for coordinate conversion - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - # Check tip presence before picking up tips - try: - tip_present = await self.request_tip_presence() - channels_with_tips = [ - i for i, present in enumerate(tip_present) if i in use_channels and present - ] - if channels_with_tips: - raise RuntimeError( - f"Cannot pick up tips: channels {channels_with_tips} already have tips mounted. " - f"Drop existing tips first." - ) - except RuntimeError: - raise - except Exception as e: - # If tip presence check fails, log warning but continue - logger.warning(f"Could not check tip presence before pickup: {e}") - - x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) - begin_tip_pick_up_process, end_tip_pick_up_process = self._compute_tip_handling_parameters( - ops, use_channels - ) - - # Build tip pattern array (True for active channels, False for inactive) - channels_involved = [int(ch in use_channels) for ch in range(self.num_channels)] - - # Ensure arrays match num_channels length (pad with 0s for inactive channels) - tip_types = [_get_tip_type_from_tip(op.tip) for op in ops] - tip_types_full = self._fill_by_channels(tip_types, use_channels, default=0) - - # Traverse height: use default value - if minimum_traverse_height_at_beginning_of_a_command is None: - minimum_traverse_height_at_beginning_of_a_command = self._channel_traversal_height - minimum_traverse_height_at_beginning_of_a_command_units = round( - minimum_traverse_height_at_beginning_of_a_command * 100 - ) # Convert to 0.01mm units - - # Create and send command - command = PickupTips( - dest=self._pipette_address, - channels_involved=channels_involved, - x_positions=x_positions_full, - y_positions=y_positions_full, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command_units, - begin_tip_pick_up_process=begin_tip_pick_up_process, - end_tip_pick_up_process=end_tip_pick_up_process, - tip_types=tip_types_full, + assert self._pip_backend is not None, "Call setup() first." + await self._pip_backend.pick_up_tips( + ops=ops, # type: ignore[arg-type] + use_channels=use_channels, + backend_params=NimbusPIPPickUpTipsParams( + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + ), ) - try: - await self.send_command(command) - logger.info(f"Picked up tips on channels {use_channels}") - except Exception as e: - logger.error(f"Failed to pick up tips: {e}") - raise - async def drop_tips( self, ops: List[Drop], @@ -1542,128 +407,25 @@ async def drop_tips( ): """Drop tips to the specified resource. - Auto-detects waste positions and uses appropriate command: - - If resource is a waste position (Trash with category="waste_position"), uses DropTipsRoll - - Otherwise, uses DropTips command - - Z positions are calculated from resource locations: - - For waste positions: Fixed Z positions (135.39 mm start, 131.39 mm stop) via _build_waste_position_params - - For regular resources: Fixed offsets relative to resource surface (max_z + 10mm start, max_z stop) - Note: Z positions use fixed offsets, NOT tip length, because the tip is already mounted on the pipette. - This works for all tip sizes (300ul, 1000ul, etc.) without additional configuration. - - z_position_at_end_of_a_command: Calculated from resources (defaults to minimum_traverse_height_at_beginning_of_a_command) - - roll_distance: Defaults to 9.0 mm for waste positions - Args: ops: List of Drop operations, one per channel use_channels: List of channel indices to use - default_waste: For DropTips command, if True, drop to default waste (positions may be ignored) - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm (optional, defaults to self._channel_traversal_height) - z_position_at_end_of_a_command: Z final position in mm (absolute, optional, calculated from resources) - roll_distance: Roll distance in mm (optional, defaults to 9.0 mm for waste positions) - - Raises: - RuntimeError: If pipette address or deck is not set - ValueError: If operations mix waste and regular resources + default_waste: For DropTips command, if True, drop to default waste + minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + z_position_at_end_of_a_command: Z final position in mm (absolute) + roll_distance: Roll distance in mm (defaults to 9.0 mm for waste positions) """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - - # Validate we have a NimbusDeck for coordinate conversion - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - # Check if resources are waste positions (Trash objects) - is_waste_positions = [isinstance(op.resource, Trash) for op in ops] - all_waste = all(is_waste_positions) - all_regular = not any(is_waste_positions) - - if not (all_waste or all_regular): - raise ValueError( - "Cannot mix waste positions and regular resources in a single drop_tips call. " - "All operations must be either waste positions or regular resources." - ) - - # Build tip pattern array (1 for active channels, 0 for inactive) - channels_involved = [int(ch in use_channels) for ch in range(self.num_channels)] - - # Traverse height: use provided value (defaults to class attribute) - if minimum_traverse_height_at_beginning_of_a_command is None: - minimum_traverse_height_at_beginning_of_a_command = self._channel_traversal_height - minimum_traverse_height_at_beginning_of_a_command_units = round( - minimum_traverse_height_at_beginning_of_a_command * 100 - ) - - # Type annotation for command variable (can be either DropTips or DropTipsRoll) - command: Union[DropTips, DropTipsRoll] - - if all_waste: - # Use DropTipsRoll for waste positions - # Build waste position parameters using helper method - ( - x_positions_full, - y_positions_full, - begin_tip_deposit_process_full, - end_tip_deposit_process_full, - z_position_at_end_of_a_command_full, - roll_distances_full, - ) = self._build_waste_position_params( - use_channels=use_channels, + assert self._pip_backend is not None, "Call setup() first." + await self._pip_backend.drop_tips( + ops=ops, # type: ignore[arg-type] + use_channels=use_channels, + backend_params=NimbusPIPDropTipsParams( + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + default_waste=default_waste, z_position_at_end_of_a_command=z_position_at_end_of_a_command, roll_distance=roll_distance, - ) - - command = DropTipsRoll( - dest=self._pipette_address, - channels_involved=channels_involved, - x_positions=x_positions_full, - y_positions=y_positions_full, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command_units, - begin_tip_deposit_process=begin_tip_deposit_process_full, - end_tip_deposit_process=end_tip_deposit_process_full, - z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, - roll_distances=roll_distances_full, - ) - - else: - # Compute x and y positions for regular resources - x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) - - # Compute Z positions using fixed offsets (not tip length) for drop operations - begin_tip_deposit_process, end_tip_deposit_process = self._compute_tip_handling_parameters( - ops, use_channels, use_fixed_offset=True - ) - - # Compute final Z positions. Use the traverse height if not provided. Fill to num_channels. - if z_position_at_end_of_a_command is None: - z_position_at_end_of_a_command_value = ( - minimum_traverse_height_at_beginning_of_a_command # Use traverse height as final position - ) - z_position_at_end_of_a_command_list = [ - round(z_position_at_end_of_a_command_value * 100) - ] * len(ops) # in 0.01mm units - z_position_at_end_of_a_command_full = self._fill_by_channels( - z_position_at_end_of_a_command_list, use_channels, default=0 - ) - - command = DropTips( - dest=self._pipette_address, - channels_involved=channels_involved, - x_positions=x_positions_full, - y_positions=y_positions_full, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command_units, - begin_tip_deposit_process=begin_tip_deposit_process, - end_tip_deposit_process=end_tip_deposit_process, - z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, - default_waste=default_waste, - ) - - try: - await self.send_command(command) - logger.info(f"Dropped tips on channels {use_channels}") - except Exception as e: - logger.error(f"Failed to drop tips: {e}") - raise + ), + ) async def aspirate( self, @@ -1671,7 +433,6 @@ async def aspirate( use_channels: List[int], minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, adc_enabled: bool = False, - # Advanced kwargs (Optional, default to zeros/nulls) lld_mode: Optional[List[int]] = None, lld_search_height: Optional[List[float]] = None, immersion_depth: Optional[List[float]] = None, @@ -1686,291 +447,56 @@ async def aspirate( limit_curve_index: Optional[List[int]] = None, tadm_enabled: bool = False, ): - """Aspirate liquid from the specified resource using pip. + """Aspirate liquid from the specified resource. Args: ops: List of SingleChannelAspiration operations, one per channel use_channels: List of channel indices to use - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm (optional, defaults to self._channel_traversal_height) - adc_enabled: If True, enable ADC (Automatic Drip Control), else disable (default: False) - lld_mode: LLD mode (0=OFF, 1=cLLD, 2=pLLD, 3=DUAL), default: [0] * n - lld_search_height: Relative offset from well bottom for LLD search start position (mm). - This is a RELATIVE OFFSET, not an absolute coordinate. The instrument adds this to - minimum_height (well bottom) to determine where to start the LLD search. - If None, defaults to the well's size_z (depth), meaning "start search at top of well". - When provided, should be a list of offsets in mm, one per channel. - immersion_depth: Depth to submerge into liquid (mm), default: [0.0] * n - surface_following_distance: Distance to follow liquid surface (mm), default: [0.0] * n - gamma_lld_sensitivity: Gamma LLD sensitivity (1-4), default: [0] * n - dp_lld_sensitivity: DP LLD sensitivity (1-4), default: [0] * n - settling_time: Settling time (s), default: [1.0] * n - transport_air_volume: Transport air volume (uL), default: [5.0] * n - pre_wetting_volume: Pre-wetting volume (uL), default: [0.0] * n - swap_speed: Swap speed on leaving liquid (uL/s), default: [20.0] * n - mix_position_from_liquid_surface: Mix position from liquid surface (mm), default: [0.0] * n - limit_curve_index: Limit curve index, default: [0] * n - tadm_enabled: TADM enabled flag, default: False - - Raises: - RuntimeError: If pipette address or deck is not set + minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + adc_enabled: Enable ADC (Automatic Drip Control) + lld_mode: LLD mode per channel (0=OFF, 1=cLLD, 2=pLLD, 3=DUAL) + lld_search_height: Relative offset from well bottom for LLD search (mm) + immersion_depth: Depth to submerge into liquid (mm) + surface_following_distance: Distance to follow liquid surface (mm) + gamma_lld_sensitivity: Gamma LLD sensitivity per channel (1-4) + dp_lld_sensitivity: DP LLD sensitivity per channel (1-4) + settling_time: Settling time per channel (s), default 1.0 + transport_air_volume: Transport air volume per channel (uL), default 5.0 + pre_wetting_volume: Pre-wetting volume per channel (uL) + swap_speed: Swap speed on leaving liquid per channel (uL/s), default 20.0 + mix_position_from_liquid_surface: Mix position from surface per channel (mm) + limit_curve_index: Limit curve index per channel + tadm_enabled: TADM enabled flag """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - - # Validate we have a NimbusDeck for coordinate conversion - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - n = len(ops) - - # Build tip pattern array (1 for active channels, 0 for inactive) - channels_involved = [0] * self.num_channels - for channel_idx in use_channels: - if channel_idx >= self.num_channels: - raise ValueError(f"Channel index {channel_idx} exceeds num_channels {self.num_channels}") - channels_involved[channel_idx] = 1 - - # Call ADC command (EnableADC or DisableADC) - if adc_enabled: - await self.send_command(EnableADC(self._pipette_address, channels_involved)) - logger.info("Enabled ADC before aspirate") - else: - await self.send_command(DisableADC(self._pipette_address, channels_involved)) - logger.info("Disabled ADC before aspirate") - - # Call GetChannelConfiguration for each active channel (index 2 = "Aspirate monitoring with cLLD") - if self._channel_configurations is None: - self._channel_configurations = {} - for channel_idx in use_channels: - channel_num = channel_idx + 1 # Convert to 1-based - try: - config = await self.send_command( - GetChannelConfiguration( - self._pipette_address, - channel=channel_num, - indexes=[2], # Index 2 = "Aspirate monitoring with cLLD" - ) - ) - assert config is not None, "GetChannelConfiguration returned None" - enabled = config["enabled"][0] if config["enabled"] else False - if channel_num not in self._channel_configurations: - self._channel_configurations[channel_num] = {} - self._channel_configurations[channel_num][2] = enabled - logger.debug(f"Channel {channel_num} configuration (index 2): enabled={enabled}") - except Exception as e: - logger.warning(f"Failed to get channel configuration for channel {channel_num}: {e}") - - # ======================================================================== - # MINIMAL SET: Calculate from resources (NOT kwargs) - # ======================================================================== - - # Extract coordinates and convert to Hamilton coordinates - x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) - - # Traverse height: use provided value or default - if minimum_traverse_height_at_beginning_of_a_command is None: - minimum_traverse_height_at_beginning_of_a_command = self._channel_traversal_height - minimum_traverse_height_at_beginning_of_a_command_units = round( - minimum_traverse_height_at_beginning_of_a_command * 100 + assert self._pip_backend is not None, "Call setup() first." + await self._pip_backend.aspirate( + ops=ops, # type: ignore[arg-type] + use_channels=use_channels, + backend_params=NimbusPIPAspirateParams( + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + adc_enabled=adc_enabled, + lld_mode=lld_mode, + lld_search_height=lld_search_height, + immersion_depth=immersion_depth, + surface_following_distance=surface_following_distance, + gamma_lld_sensitivity=gamma_lld_sensitivity, + dp_lld_sensitivity=dp_lld_sensitivity, + settling_time=settling_time, + transport_air_volume=transport_air_volume, + pre_wetting_volume=pre_wetting_volume, + swap_speed=swap_speed, + mix_position_from_liquid_surface=mix_position_from_liquid_surface, + limit_curve_index=limit_curve_index, + tadm_enabled=tadm_enabled, + ), ) - # Calculate well_bottoms: resource Z + offset Z + material_z_thickness in Hamilton coords - well_bottoms = [] - for op in ops: - abs_location = op.resource.get_location_wrt(self.deck) + op.offset - if isinstance(op.resource, Container): - abs_location.z += op.resource.material_z_thickness - hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) - well_bottoms.append(hamilton_coord.z) - - # Calculate liquid_height: well_bottom + (op.liquid_height or 0) - # This is the fixed Z-height when LLD is OFF - liquid_heights_mm = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] - - # Calculate lld_search_height if not provided as kwarg - # - # IMPORTANT: lld_search_height is a RELATIVE OFFSET (in mm), not an absolute coordinate. - # It represents the height offset from the well bottom where the LLD (Liquid Level Detection) - # search should start. The Hamilton instrument will add this offset to minimum_height - # (well bottom) to determine the absolute Z position where the search begins. - # - # Default behavior: Use the well's size_z (depth) as the offset, which means - # "start the LLD search at the top of the well" (well_bottom + well_size). - # This is a reasonable default since we want to search from the top downward. - # - # When provided as a kwarg, it should be a list of relative offsets in mm. - # The instrument will internally add these to minimum_height to get absolute coordinates. - if lld_search_height is None: - lld_search_height = [op.resource.get_absolute_size_z() for op in ops] - - # Calculate minimum_height: default to well_bottom - minimum_heights_mm = well_bottoms.copy() - - # Extract volumes and speeds from operations - volumes = [op.volume for op in ops] # in uL - flow_rates: List[float] = [ - op.flow_rate if op.flow_rate is not None else _get_default_flow_rate(op.tip, is_aspirate=True) - for op in ops - ] - blow_out_air_volumes = [ - op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops - ] # in uL, default 40 - - # Extract mix parameters from op.mix if available. Otherwise use None. - mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] - mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] - # Default mix_speed to aspirate speed (flow_rates) when no mix operation - # This matches the working version behavior - mix_speed: List[float] = [ - op.mix.flow_rate - if op.mix is not None - else ( - op.flow_rate - if op.flow_rate is not None - else _get_default_flow_rate(op.tip, is_aspirate=True) - ) - for op in ops - ] - - # ======================================================================== - # ADVANCED PARAMETERS: Fill in defaults using fill_in_defaults() - # ======================================================================== - - lld_mode = fill_in_defaults(lld_mode, [0] * n) - immersion_depth = fill_in_defaults(immersion_depth, [0.0] * n) - surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) - gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [0] * n) - dp_lld_sensitivity = fill_in_defaults(dp_lld_sensitivity, [0] * n) - settling_time = fill_in_defaults(settling_time, [1.0] * n) - transport_air_volume = fill_in_defaults(transport_air_volume, [5.0] * n) - pre_wetting_volume = fill_in_defaults(pre_wetting_volume, [0.0] * n) - swap_speed = fill_in_defaults(swap_speed, [20.0] * n) - mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) - limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) - - # ======================================================================== - # CONVERT UNITS AND BUILD FULL ARRAYS - # Hamilton uses units of 0.1uL and 0.1mm and 0.1s etc. for most parameters - # Some are in 0.01. - # PLR units are uL, mm, s etc. - # ======================================================================== - - aspirate_volumes = [round(vol * 10) for vol in volumes] - blow_out_air_volumes_units = [round(vol * 10) for vol in blow_out_air_volumes] - aspiration_speeds = [round(fr * 10) for fr in flow_rates] - lld_search_height_units = [round(h * 100) for h in lld_search_height] - liquid_height_units = [round(h * 100) for h in liquid_heights_mm] - immersion_depth_units = [round(d * 100) for d in immersion_depth] - surface_following_distance_units = [round(d * 100) for d in surface_following_distance] - minimum_height_units = [round(z * 100) for z in minimum_heights_mm] - settling_time_units = [round(t * 10) for t in settling_time] - transport_air_volume_units = [round(v * 10) for v in transport_air_volume] - pre_wetting_volume_units = [round(v * 10) for v in pre_wetting_volume] - swap_speed_units = [round(s * 10) for s in swap_speed] - mix_volume_units = [round(v * 10) for v in mix_volume] - mix_speed_units = [round(s * 10) for s in mix_speed] - mix_position_from_liquid_surface_units = [ - round(p * 100) for p in mix_position_from_liquid_surface - ] - - # Build arrays for all channels (pad with 0s for inactive channels) - aspirate_volumes_full = self._fill_by_channels(aspirate_volumes, use_channels, default=0) - blow_out_air_volumes_full = self._fill_by_channels( - blow_out_air_volumes_units, use_channels, default=0 - ) - aspiration_speeds_full = self._fill_by_channels(aspiration_speeds, use_channels, default=0) - lld_search_height_full = self._fill_by_channels( - lld_search_height_units, use_channels, default=0 - ) - liquid_height_full = self._fill_by_channels(liquid_height_units, use_channels, default=0) - immersion_depth_full = self._fill_by_channels(immersion_depth_units, use_channels, default=0) - surface_following_distance_full = self._fill_by_channels( - surface_following_distance_units, use_channels, default=0 - ) - minimum_height_full = self._fill_by_channels(minimum_height_units, use_channels, default=0) - settling_time_full = self._fill_by_channels(settling_time_units, use_channels, default=0) - transport_air_volume_full = self._fill_by_channels( - transport_air_volume_units, use_channels, default=0 - ) - pre_wetting_volume_full = self._fill_by_channels( - pre_wetting_volume_units, use_channels, default=0 - ) - swap_speed_full = self._fill_by_channels(swap_speed_units, use_channels, default=0) - mix_volume_full = self._fill_by_channels(mix_volume_units, use_channels, default=0) - mix_cycles_full = self._fill_by_channels(mix_cycles, use_channels, default=0) - mix_speed_full = self._fill_by_channels(mix_speed_units, use_channels, default=0) - mix_position_from_liquid_surface_full = self._fill_by_channels( - mix_position_from_liquid_surface_units, use_channels, default=0 - ) - gamma_lld_sensitivity_full = self._fill_by_channels( - gamma_lld_sensitivity, use_channels, default=0 - ) - dp_lld_sensitivity_full = self._fill_by_channels(dp_lld_sensitivity, use_channels, default=0) - limit_curve_index_full = self._fill_by_channels(limit_curve_index, use_channels, default=0) - lld_mode_full = self._fill_by_channels(lld_mode, use_channels, default=0) - - # Default values for remaining parameters - aspirate_type = [0] * self.num_channels - clot_detection_height = [0] * self.num_channels - min_z_endpos = minimum_traverse_height_at_beginning_of_a_command_units - mix_surface_following_distance = [0] * self.num_channels - tube_section_height = [0] * self.num_channels - tube_section_ratio = [0] * self.num_channels - lld_height_difference = [0] * self.num_channels - recording_mode = 0 - - # Create and send Aspirate command - command = Aspirate( - dest=self._pipette_address, - aspirate_type=aspirate_type, - channels_involved=channels_involved, - x_positions=x_positions_full, - y_positions=y_positions_full, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command_units, - lld_search_height=lld_search_height_full, - liquid_height=liquid_height_full, - immersion_depth=immersion_depth_full, - surface_following_distance=surface_following_distance_full, - minimum_height=minimum_height_full, - clot_detection_height=clot_detection_height, - min_z_endpos=min_z_endpos, - swap_speed=swap_speed_full, - blow_out_air_volume=blow_out_air_volumes_full, - pre_wetting_volume=pre_wetting_volume_full, - aspirate_volume=aspirate_volumes_full, - transport_air_volume=transport_air_volume_full, - aspiration_speed=aspiration_speeds_full, - settling_time=settling_time_full, - mix_volume=mix_volume_full, - mix_cycles=mix_cycles_full, - mix_position_from_liquid_surface=mix_position_from_liquid_surface_full, - mix_surface_following_distance=mix_surface_following_distance, - mix_speed=mix_speed_full, - tube_section_height=tube_section_height, - tube_section_ratio=tube_section_ratio, - lld_mode=lld_mode_full, - gamma_lld_sensitivity=gamma_lld_sensitivity_full, - dp_lld_sensitivity=dp_lld_sensitivity_full, - lld_height_difference=lld_height_difference, - tadm_enabled=tadm_enabled, - limit_curve_index=limit_curve_index_full, - recording_mode=recording_mode, - ) - - try: - await self.send_command(command) - logger.info(f"Aspirated on channels {use_channels}") - except Exception as e: - logger.error(f"Failed to aspirate: {e}") - raise - async def dispense( self, ops: List[SingleChannelDispense], use_channels: List[int], minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, adc_enabled: bool = False, - # Advanced kwargs (Optional, default to zeros/nulls) lld_mode: Optional[List[int]] = None, lld_search_height: Optional[List[float]] = None, immersion_depth: Optional[List[float]] = None, @@ -1987,284 +513,55 @@ async def dispense( side_touch_off_distance: float = 0.0, dispense_offset: Optional[List[float]] = None, ): - """Dispense liquid from the specified resource using pip. + """Dispense liquid to the specified resource. Args: ops: List of SingleChannelDispense operations, one per channel use_channels: List of channel indices to use - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm (optional, defaults to self._channel_traversal_height) - adc_enabled: If True, enable ADC (Automatic Drip Control), else disable (default: False) - lld_mode: LLD mode (0=OFF, 1=cLLD, 2=pLLD, 3=DUAL), default: [0] * n - lld_search_height: Override calculated LLD search height (mm). If None, calculated from well_bottom + resource size - immersion_depth: Depth to submerge into liquid (mm), default: [0.0] * n - surface_following_distance: Distance to follow liquid surface (mm), default: [0.0] * n - gamma_lld_sensitivity: Gamma LLD sensitivity (1-4), default: [0] * n - settling_time: Settling time (s), default: [1.0] * n - transport_air_volume: Transport air volume (uL), default: [5.0] * n - swap_speed: Swap speed on leaving liquid (uL/s), default: [20.0] * n - mix_position_from_liquid_surface: Mix position from liquid surface (mm), default: [0.0] * n - limit_curve_index: Limit curve index, default: [0] * n - tadm_enabled: TADM enabled flag, default: False - cut_off_speed: Cut off speed (uL/s), default: [25.0] * n - stop_back_volume: Stop back volume (uL), default: [0.0] * n - side_touch_off_distance: Side touch off distance (mm), default: 0.0 - dispense_offset: Dispense offset (mm), default: [0.0] * n - - Raises: - RuntimeError: If pipette address or deck is not set + minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + adc_enabled: Enable ADC (Automatic Drip Control) + lld_mode: LLD mode per channel (0=OFF, 1=cLLD, 2=pLLD, 3=DUAL) + lld_search_height: Relative offset from well bottom for LLD search (mm) + immersion_depth: Depth to submerge into liquid (mm) + surface_following_distance: Distance to follow liquid surface (mm) + gamma_lld_sensitivity: Gamma LLD sensitivity per channel (1-4) + settling_time: Settling time per channel (s), default 1.0 + transport_air_volume: Transport air volume per channel (uL), default 5.0 + swap_speed: Swap speed on leaving liquid per channel (uL/s), default 20.0 + mix_position_from_liquid_surface: Mix position from surface per channel (mm) + limit_curve_index: Limit curve index per channel + tadm_enabled: TADM enabled flag + cut_off_speed: Cut off speed per channel (uL/s), default 25.0 + stop_back_volume: Stop back volume per channel (uL) + side_touch_off_distance: Side touch off distance (mm) + dispense_offset: Dispense offset per channel (mm) """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - - # Validate we have a NimbusDeck for coordinate conversion - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - n = len(ops) - - # Build tip pattern array (1 for active channels, 0 for inactive) - channels_involved = [0] * self.num_channels - for channel_idx in use_channels: - if channel_idx >= self.num_channels: - raise ValueError(f"Channel index {channel_idx} exceeds num_channels {self.num_channels}") - channels_involved[channel_idx] = 1 - - # Call ADC command (EnableADC or DisableADC) - if adc_enabled: - await self.send_command(EnableADC(self._pipette_address, channels_involved)) - logger.info("Enabled ADC before dispense") - else: - await self.send_command(DisableADC(self._pipette_address, channels_involved)) - logger.info("Disabled ADC before dispense") - - # Call GetChannelConfiguration for each active channel (index 2 = "Aspirate monitoring with cLLD") - if self._channel_configurations is None: - self._channel_configurations = {} - for channel_idx in use_channels: - channel_num = channel_idx + 1 # Convert to 1-based - try: - config = await self.send_command( - GetChannelConfiguration( - self._pipette_address, - channel=channel_num, - indexes=[2], # Index 2 = "Aspirate monitoring with cLLD" - ) - ) - assert config is not None, "GetChannelConfiguration returned None" - enabled = config["enabled"][0] if config["enabled"] else False - if channel_num not in self._channel_configurations: - self._channel_configurations[channel_num] = {} - self._channel_configurations[channel_num][2] = enabled - logger.debug(f"Channel {channel_num} configuration (index 2): enabled={enabled}") - except Exception as e: - logger.warning(f"Failed to get channel configuration for channel {channel_num}: {e}") - - # ======================================================================== - # MINIMAL SET: Calculate from resources (NOT kwargs) - # ======================================================================== - - # Extract coordinates and convert to Hamilton coordinates - x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) - - # Traverse height: use provided value or default - if minimum_traverse_height_at_beginning_of_a_command is None: - minimum_traverse_height_at_beginning_of_a_command = self._channel_traversal_height - minimum_traverse_height_at_beginning_of_a_command_units = round( - minimum_traverse_height_at_beginning_of_a_command * 100 - ) - - # Calculate well_bottoms: resource Z + offset Z + material_z_thickness in Hamilton coords - well_bottoms = [] - for op in ops: - abs_location = op.resource.get_location_wrt(self.deck) + op.offset - if isinstance(op.resource, Container): - abs_location.z += op.resource.material_z_thickness - hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) - well_bottoms.append(hamilton_coord.z) - - # Calculate liquid_height: well_bottom + (op.liquid_height or 0) - # This is the fixed Z-height when LLD is OFF - liquid_heights_mm = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] - - # Calculate lld_search_height if not provided as kwarg - # - # IMPORTANT: lld_search_height is a RELATIVE OFFSET (in mm), not an absolute coordinate. - # It represents the height offset from the well bottom where the LLD (Liquid Level Detection) - # search should start. The Hamilton instrument will add this offset to minimum_height - # (well bottom) to determine the absolute Z position where the search begins. - # - # Default behavior: Use the well's size_z (depth) as the offset, which means - # "start the LLD search at the top of the well" (well_bottom + well_size). - # This is a reasonable default since we want to search from the top downward. - # - # When provided as a kwarg, it should be a list of relative offsets in mm. - # The instrument will internally add these to minimum_height to get absolute coordinates. - if lld_search_height is None: - lld_search_height = [op.resource.get_absolute_size_z() for op in ops] - - # Calculate minimum_height: default to well_bottom - minimum_heights_mm = well_bottoms.copy() - - # Extract volumes and speeds from operations - volumes = [op.volume for op in ops] # in uL - flow_rates: List[float] = [ - op.flow_rate - if op.flow_rate is not None - else _get_default_flow_rate(op.tip, is_aspirate=False) - for op in ops - ] - blow_out_air_volumes = [ - op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops - ] # in uL, default 40 - - # Extract mix parameters from op.mix if available - mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] - mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] - # Default mix_speed to dispense speed (flow_rates) when no mix operation - # This matches the working version behavior - mix_speed: List[float] = [ - op.mix.flow_rate - if op.mix is not None - else ( - op.flow_rate - if op.flow_rate is not None - else _get_default_flow_rate(op.tip, is_aspirate=False) - ) - for op in ops - ] - - # ======================================================================== - # ADVANCED PARAMETERS: Fill in defaults using fill_in_defaults() - # ======================================================================== - - lld_mode = fill_in_defaults(lld_mode, [0] * n) - immersion_depth = fill_in_defaults(immersion_depth, [0.0] * n) - surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) - gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [0] * n) - settling_time = fill_in_defaults(settling_time, [1.0] * n) - transport_air_volume = fill_in_defaults(transport_air_volume, [5.0] * n) - swap_speed = fill_in_defaults(swap_speed, [20.0] * n) - mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) - limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) - cut_off_speed = fill_in_defaults(cut_off_speed, [25.0] * n) - stop_back_volume = fill_in_defaults(stop_back_volume, [0.0] * n) - dispense_offset = fill_in_defaults(dispense_offset, [0.0] * n) - - # ======================================================================== - # CONVERT UNITS AND BUILD FULL ARRAYS - # Hamilton uses units of 0.1uL and 0.1mm and 0.1s etc. for most parameters - # Some are in 0.01. - # PLR units are uL, mm, s etc. - # ======================================================================== - - dispense_volumes = [round(vol * 10) for vol in volumes] - blow_out_air_volumes_units = [round(vol * 10) for vol in blow_out_air_volumes] - dispense_speeds = [round(fr * 10) for fr in flow_rates] - lld_search_height_units = [round(h * 100) for h in lld_search_height] - liquid_height_units = [round(h * 100) for h in liquid_heights_mm] - immersion_depth_units = [round(d * 100) for d in immersion_depth] - surface_following_distance_units = [round(d * 100) for d in surface_following_distance] - minimum_height_units = [round(z * 100) for z in minimum_heights_mm] - settling_time_units = [round(t * 10) for t in settling_time] - transport_air_volume_units = [round(v * 10) for v in transport_air_volume] - swap_speed_units = [round(s * 10) for s in swap_speed] - mix_volume_units = [round(v * 10) for v in mix_volume] - mix_speed_units = [round(s * 10) for s in mix_speed] - mix_position_from_liquid_surface_units = [ - round(p * 100) for p in mix_position_from_liquid_surface - ] - cut_off_speed_units = [round(s * 10) for s in cut_off_speed] - stop_back_volume_units = [round(v * 10) for v in stop_back_volume] - dispense_offset_units = [round(o * 100) for o in dispense_offset] - side_touch_off_distance_units = round(side_touch_off_distance * 100) - - # Build arrays for all channels (pad with 0s for inactive channels) - dispense_volumes_full = self._fill_by_channels(dispense_volumes, use_channels, default=0) - blow_out_air_volumes_full = self._fill_by_channels( - blow_out_air_volumes_units, use_channels, default=0 - ) - dispense_speeds_full = self._fill_by_channels(dispense_speeds, use_channels, default=0) - lld_search_height_full = self._fill_by_channels( - lld_search_height_units, use_channels, default=0 - ) - liquid_height_full = self._fill_by_channels(liquid_height_units, use_channels, default=0) - immersion_depth_full = self._fill_by_channels(immersion_depth_units, use_channels, default=0) - surface_following_distance_full = self._fill_by_channels( - surface_following_distance_units, use_channels, default=0 - ) - minimum_height_full = self._fill_by_channels(minimum_height_units, use_channels, default=0) - settling_time_full = self._fill_by_channels(settling_time_units, use_channels, default=0) - transport_air_volume_full = self._fill_by_channels( - transport_air_volume_units, use_channels, default=0 - ) - swap_speed_full = self._fill_by_channels(swap_speed_units, use_channels, default=0) - mix_volume_full = self._fill_by_channels(mix_volume_units, use_channels, default=0) - mix_cycles_full = self._fill_by_channels(mix_cycles, use_channels, default=0) - mix_speed_full = self._fill_by_channels(mix_speed_units, use_channels, default=0) - mix_position_from_liquid_surface_full = self._fill_by_channels( - mix_position_from_liquid_surface_units, use_channels, default=0 - ) - gamma_lld_sensitivity_full = self._fill_by_channels( - gamma_lld_sensitivity, use_channels, default=0 - ) - limit_curve_index_full = self._fill_by_channels(limit_curve_index, use_channels, default=0) - lld_mode_full = self._fill_by_channels(lld_mode, use_channels, default=0) - cut_off_speed_full = self._fill_by_channels(cut_off_speed_units, use_channels, default=0) - stop_back_volume_full = self._fill_by_channels(stop_back_volume_units, use_channels, default=0) - dispense_offset_full = self._fill_by_channels(dispense_offset_units, use_channels, default=0) - - # Default values for remaining parameters - dispense_type = [0] * self.num_channels - min_z_endpos = minimum_traverse_height_at_beginning_of_a_command_units - mix_surface_following_distance = [0] * self.num_channels - tube_section_height = [0] * self.num_channels - tube_section_ratio = [0] * self.num_channels - recording_mode = 0 - - # Create and send Dispense command - command = Dispense( - dest=self._pipette_address, - dispense_type=dispense_type, - channels_involved=channels_involved, - x_positions=x_positions_full, - y_positions=y_positions_full, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command_units, - lld_search_height=lld_search_height_full, - liquid_height=liquid_height_full, - immersion_depth=immersion_depth_full, - surface_following_distance=surface_following_distance_full, - minimum_height=minimum_height_full, - min_z_endpos=min_z_endpos, - swap_speed=swap_speed_full, - transport_air_volume=transport_air_volume_full, - dispense_volume=dispense_volumes_full, - stop_back_volume=stop_back_volume_full, - blow_out_air_volume=blow_out_air_volumes_full, - dispense_speed=dispense_speeds_full, - cut_off_speed=cut_off_speed_full, - settling_time=settling_time_full, - mix_volume=mix_volume_full, - mix_cycles=mix_cycles_full, - mix_position_from_liquid_surface=mix_position_from_liquid_surface_full, - mix_surface_following_distance=mix_surface_following_distance, - mix_speed=mix_speed_full, - side_touch_off_distance=side_touch_off_distance_units, - dispense_offset=dispense_offset_full, - tube_section_height=tube_section_height, - tube_section_ratio=tube_section_ratio, - lld_mode=lld_mode_full, - gamma_lld_sensitivity=gamma_lld_sensitivity_full, - tadm_enabled=tadm_enabled, - limit_curve_index=limit_curve_index_full, - recording_mode=recording_mode, - ) - - try: - await self.send_command(command) - logger.info(f"Dispensed on channels {use_channels}") - except Exception as e: - logger.error(f"Failed to dispense: {e}") - raise + assert self._pip_backend is not None, "Call setup() first." + await self._pip_backend.dispense( + ops=ops, # type: ignore[arg-type] + use_channels=use_channels, + backend_params=NimbusPIPDispenseParams( + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, + adc_enabled=adc_enabled, + lld_mode=lld_mode, + lld_search_height=lld_search_height, + immersion_depth=immersion_depth, + surface_following_distance=surface_following_distance, + gamma_lld_sensitivity=gamma_lld_sensitivity, + settling_time=settling_time, + transport_air_volume=transport_air_volume, + swap_speed=swap_speed, + mix_position_from_liquid_surface=mix_position_from_liquid_surface, + limit_curve_index=limit_curve_index, + tadm_enabled=tadm_enabled, + cut_off_speed=cut_off_speed, + stop_back_volume=stop_back_volume, + side_touch_off_distance=side_touch_off_distance, + dispense_offset=dispense_offset, + ), + ) + + # -- Unimplemented abstract methods ---------------------------------------- async def pick_up_tips96(self, pickup: PickupTipRack): raise NotImplementedError("pick_up_tips96 not yet implemented") @@ -2288,25 +585,6 @@ async def drop_resource(self, drop: ResourceDrop): raise NotImplementedError("drop_resource not yet implemented") def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: - """Check if the tip can be picked up by the specified channel. - - Args: - channel_idx: Channel index (0-based) - tip: Tip object to check - - Returns: - True if the tip can be picked up, False otherwise - """ - # Only Hamilton tips are supported - if not isinstance(tip, HamiltonTip): - return False - - # XL tips are not supported on Nimbus - if tip.tip_size in {TipSize.XL}: - return False - - # Check if channel index is valid - if self._num_channels is not None and channel_idx >= self._num_channels: - return False - - return True + """Check if the tip can be picked up by the specified channel.""" + assert self._pip_backend is not None, "Call setup() first." + return self._pip_backend.can_pick_up_tip(channel_idx, tip) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py index 5da385713df..9c80bf2d5cc 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -571,8 +571,7 @@ async def test_set_minimum_channel_traversal_height_invalid(self): backend.set_minimum_channel_traversal_height(-10) async def test_fill_by_channels(self): - backend = NimbusBackend(host="192.168.1.100") - backend._num_channels = 8 + backend = _setup_backend() # Test with channels 0, 2, 4 values = [100, 200, 300] @@ -583,8 +582,7 @@ async def test_fill_by_channels(self): self.assertEqual(result, expected) async def test_fill_by_channels_mismatched_lengths(self): - backend = NimbusBackend(host="192.168.1.100") - backend._num_channels = 8 + backend = _setup_backend() with self.assertRaises(ValueError): backend._fill_by_channels([1, 2], [0, 1, 2], default=0) @@ -607,12 +605,24 @@ def _mock_send_command_response(command) -> Optional[dict]: def _setup_backend() -> NimbusBackend: """Create a NimbusBackend with pre-configured state for testing.""" + from pylabrobot.hamilton.liquid_handlers.nimbus.door import NimbusDoor + from pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend import NimbusPIPBackend + backend = NimbusBackend(host="192.168.1.100", port=2000) backend._num_channels = 8 backend._pipette_address = Address(1, 1, 257) backend._door_lock_address = Address(1, 1, 268) backend._nimbus_core_address = Address(1, 1, 48896) backend._is_initialized = True + backend._pip_backend = NimbusPIPBackend( + driver=backend, # type: ignore[arg-type] + address=Address(1, 1, 257), + num_channels=8, + ) + backend._door = NimbusDoor( + driver=backend, # type: ignore[arg-type] + address=Address(1, 1, 268), + ) return backend @@ -620,6 +630,8 @@ def _setup_backend_with_deck(deck: NimbusDeck) -> NimbusBackend: """Create a NimbusBackend with pre-configured state and deck for testing.""" backend = _setup_backend() backend._deck = deck + assert backend._pip_backend is not None + backend._pip_backend.deck = deck return backend @@ -660,6 +672,7 @@ async def test_park(self): async def test_door_methods_without_address_raise(self): self.backend._door_lock_address = None + self.backend._door = None with self.assertRaises(RuntimeError): await self.backend.lock_door() diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/__init__.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/__init__.py index 434f33aa042..19c2516c46a 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/__init__.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/__init__.py @@ -1 +1,31 @@ -"""Shared code for Hamilton TCP-based backends such as the Nimbus and the Prep.""" +"""Compatibility shims — canonical location is pylabrobot.hamilton.tcp.""" + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand as HamiltonCommand +from pylabrobot.hamilton.tcp.introspection import ( + HamiltonIntrospection as HamiltonIntrospection, +) +from pylabrobot.hamilton.tcp.messages import ( + CommandMessage as CommandMessage, + CommandResponse as CommandResponse, + HoiParams as HoiParams, + HoiParamsParser as HoiParamsParser, + InitMessage as InitMessage, + InitResponse as InitResponse, + RegistrationMessage as RegistrationMessage, + RegistrationResponse as RegistrationResponse, +) +from pylabrobot.hamilton.tcp.packets import ( + Address as Address, + HarpPacket as HarpPacket, + HoiPacket as HoiPacket, + IpPacket as IpPacket, +) +from pylabrobot.hamilton.tcp.protocol import ( + Hoi2Action as Hoi2Action, + HamiltonDataType as HamiltonDataType, + HamiltonProtocol as HamiltonProtocol, + HarpTransportableProtocol as HarpTransportableProtocol, + HoiRequestId as HoiRequestId, + RegistrationActionCode as RegistrationActionCode, + RegistrationOptionType as RegistrationOptionType, +) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py index 89dc55894e6..23d6a54fce8 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py @@ -1,179 +1,3 @@ -"""Hamilton command architecture using new simplified TCP stack. +"""Compatibility shim — canonical location is pylabrobot.hamilton.tcp.commands.""" -This module provides the HamiltonCommand base class that uses the new refactored -architecture: Wire → HoiParams → Packets → Messages → Commands. -""" - -from __future__ import annotations - -import inspect -from typing import Optional - -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( - CommandMessage, - CommandResponse, - HoiParams, -) -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol - - -class HamiltonCommand: - """Base class for Hamilton commands using new simplified architecture. - - This replaces the old HamiltonCommand from tcp_codec.py with a cleaner design: - - Explicitly uses CommandMessage for building packets - - build_parameters() returns HoiParams object (not bytes) - - Uses Address instead of ObjectAddress - - Cleaner separation of concerns - - Example: - class MyCommand(HamiltonCommand): - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 0 - command_id = 42 - - def __init__(self, dest: Address, value: int): - super().__init__(dest) - self.value = value - - def build_parameters(self) -> HoiParams: - return HoiParams().i32(self.value) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - parser = HoiParamsParser(data) - _, result = parser.parse_next() - return {'result': result} - """ - - # Class-level attributes that subclasses must override - protocol: Optional[HamiltonProtocol] = None - interface_id: Optional[int] = None - command_id: Optional[int] = None - - # Action configuration (can be overridden by subclasses) - action_code: int = 3 # Default: COMMAND_REQUEST - harp_protocol: int = 2 # Default: HOI2 - ip_protocol: int = 6 # Default: OBJECT_DISCOVERY - - def __init__(self, dest: Address): - """Initialize Hamilton command. - - Args: - dest: Destination address for this command - """ - if self.protocol is None: - raise ValueError(f"{self.__class__.__name__} must define protocol") - if self.interface_id is None: - raise ValueError(f"{self.__class__.__name__} must define interface_id") - if self.command_id is None: - raise ValueError(f"{self.__class__.__name__} must define command_id") - - self.dest = dest - self.dest_address = dest # Alias for compatibility - self.sequence_number = 0 - self.source_address: Optional[Address] = None - - def build_parameters(self) -> HoiParams: - """Build HOI parameters for this command. - - Override this method in subclasses to provide command-specific parameters. - Return a HoiParams object (not bytes!). - - Returns: - HoiParams object with command parameters - """ - return HoiParams() - - def get_log_params(self) -> dict: - """Get parameters to log for this command. - - Lazily computes the parameters by inspecting the __init__ signature - and reading current attribute values from self. - - Subclasses can override to customize formatting (e.g., unit conversions, - array truncation). - - Returns: - Dictionary of parameter names to values - """ - exclude = {"self", "dest"} - sig = inspect.signature(type(self).__init__) - params = {} - for param_name in sig.parameters: - if param_name not in exclude and hasattr(self, param_name): - params[param_name] = getattr(self, param_name) - return params - - def build( - self, src: Optional[Address] = None, seq: Optional[int] = None, response_required: bool = True - ) -> bytes: - """Build complete Hamilton message using CommandMessage. - - Args: - src: Source address (uses self.source_address if None) - seq: Sequence number (uses self.sequence_number if None) - response_required: Whether a response is expected - - Returns: - Complete packet bytes ready to send over TCP - """ - # Use instance attributes if not provided - source = src if src is not None else self.source_address - sequence = seq if seq is not None else self.sequence_number - - if source is None: - raise ValueError("Source address not set - backend should set this before building") - - # Ensure required attributes are set (they should be by subclasses) - if self.interface_id is None: - raise ValueError(f"{self.__class__.__name__} must define interface_id") - if self.command_id is None: - raise ValueError(f"{self.__class__.__name__} must define command_id") - - # Build parameters using command-specific logic - params = self.build_parameters() - - # Create CommandMessage and set parameters directly - # This avoids wasteful serialization/parsing round-trip - msg = CommandMessage( - dest=self.dest, - interface_id=self.interface_id, - method_id=self.command_id, - params=params, - action_code=self.action_code, - harp_protocol=self.harp_protocol, - ip_protocol=self.ip_protocol, - ) - - # Build final packet - return msg.build(source, sequence, harp_response_required=response_required) - - def interpret_response(self, response: CommandResponse) -> Optional[dict]: - """Interpret success response. - - This is the new interface used by the backend. Default implementation - directly calls parse_response_parameters for efficiency. - - Args: - response: CommandResponse from network - - Returns: - Dictionary with parsed response data, or None if no data to extract - """ - return self.parse_response_parameters(response.hoi.params) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> Optional[dict]: - """Parse response parameters from HOI payload. - - Override this method in subclasses to parse command-specific responses. - - Args: - data: Raw bytes from HOI fragments field - - Returns: - Dictionary with parsed response data, or None if no data to extract - """ - return None +from pylabrobot.hamilton.tcp.commands import HamiltonCommand as HamiltonCommand # noqa: F401 diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py index 5e19c55d7a2..17156ca7aaf 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py @@ -1,832 +1,21 @@ -"""Hamilton TCP Introspection API. - -This module provides dynamic discovery of Hamilton instrument capabilities -using Interface 0 introspection methods. It allows discovering available -objects, methods, interfaces, enums, and structs at runtime. -""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass, field -from typing import Any, Dict, List - -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( - HoiParams, - HoiParamsParser, -) -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( - HamiltonDataType, - HamiltonProtocol, +"""Compatibility shim — canonical location is pylabrobot.hamilton.tcp.introspection.""" + +from pylabrobot.hamilton.tcp.introspection import ( # noqa: F401 + EnumInfo, + GetEnumsCommand, + GetInterfacesCommand, + GetMethodCommand, + GetObjectCommand, + GetStructsCommand, + GetSubobjectAddressCommand, + HamiltonIntrospection, + InterfaceInfo, + MethodInfo, + ObjectInfo, + StructInfo, + get_introspection_type_category, + is_complex_introspection_type, + resolve_introspection_type_name, + resolve_type_id, + resolve_type_ids, ) - -logger = logging.getLogger(__name__) - -# ============================================================================ -# TYPE RESOLUTION HELPERS -# ============================================================================ - - -def resolve_type_id(type_id: int) -> str: - """Resolve Hamilton type ID to readable name. - - Args: - type_id: Hamilton data type ID - - Returns: - Human-readable type name - """ - try: - return HamiltonDataType(type_id).name - except ValueError: - return f"UNKNOWN_TYPE_{type_id}" - - -def resolve_type_ids(type_ids: List[int]) -> List[str]: - """Resolve list of Hamilton type IDs to readable names. - - Args: - type_ids: List of Hamilton data type IDs - - Returns: - List of human-readable type names - """ - return [resolve_type_id(tid) for tid in type_ids] - - -# ============================================================================ -# INTROSPECTION TYPE MAPPING -# ============================================================================ -# Introspection type IDs are separate from HamiltonDataType wire encoding types. -# These are used for method signature display/metadata, not binary encoding. - -# Type ID ranges for categorization: -# - Argument types: Method parameters (input) -# - ReturnElement types: Multiple return values (struct fields) -# - ReturnValue types: Single return value - -_INTROSPECTION_TYPE_NAMES: dict[int, str] = { - # Argument types (1-8, 33, 41, 45, 49, 53, 61, 66, 82, 102) - 1: "i8", - 2: "u8", - 3: "i16", - 4: "u16", - 5: "i32", - 6: "u32", - 7: "str", - 8: "bytes", - 33: "bool", - 41: "List[i16]", - 45: "List[u16]", - 49: "List[i32]", - 53: "List[u32]", - 61: "List[struct]", # Complex type, needs source_id + struct_id - 66: "List[bool]", - 82: "List[enum]", # Complex type, needs source_id + enum_id - 102: "f32", - # ReturnElement types (18-24, 35, 43, 47, 51, 55, 68, 76) - 18: "u8", - 19: "i16", - 20: "u16", - 21: "i32", - 22: "u32", - 23: "str", - 24: "bytes", - 35: "bool", - 43: "List[i16]", - 47: "List[u16]", - 51: "List[i32]", - 55: "List[u32]", - 68: "List[bool]", - 76: "List[str]", - # ReturnValue types (25-32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105) - 25: "i8", - 26: "u8", - 27: "i16", - 28: "u16", - 29: "i32", - 30: "u32", - 31: "str", - 32: "bytes", - 36: "bool", - 44: "List[i16]", - 48: "List[u16]", - 52: "List[i32]", - 56: "List[u32]", - 69: "List[bool]", - 81: "enum", # Complex type, needs source_id + enum_id - 85: "enum", # Complex type, needs source_id + enum_id - 104: "f32", - 105: "f32", - # Complex types (60, 64, 78) - these need source_id + id - 60: "struct", # ReturnValue, needs source_id + struct_id - 64: "struct", # ReturnValue, needs source_id + struct_id - 78: "enum", # Argument, needs source_id + enum_id -} - -# Type ID sets for categorization -_ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 61, 66, 82, 102} -_RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 68, 76} -_RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105} -_COMPLEX_TYPE_IDS = {60, 61, 64, 78, 81, 82, 85} # Types that need additional bytes - - -def get_introspection_type_category(type_id: int) -> str: - """Get category for introspection type ID. - - Args: - type_id: Introspection type ID - - Returns: - Category: "Argument", "ReturnElement", "ReturnValue", or "Unknown" - """ - if type_id in _ARGUMENT_TYPE_IDS: - return "Argument" - elif type_id in _RETURN_ELEMENT_TYPE_IDS: - return "ReturnElement" - elif type_id in _RETURN_VALUE_TYPE_IDS: - return "ReturnValue" - else: - return "Unknown" - - -def resolve_introspection_type_name(type_id: int) -> str: - """Resolve introspection type ID to readable name. - - Args: - type_id: Introspection type ID - - Returns: - Human-readable type name - """ - return _INTROSPECTION_TYPE_NAMES.get(type_id, f"UNKNOWN_TYPE_{type_id}") - - -def is_complex_introspection_type(type_id: int) -> bool: - """Check if introspection type is complex (needs additional bytes). - - Complex types require 3 bytes total: type_id, source_id, struct_id/enum_id - - Args: - type_id: Introspection type ID - - Returns: - True if type is complex - """ - return type_id in _COMPLEX_TYPE_IDS - - -# ============================================================================ -# DATA STRUCTURES -# ============================================================================ - - -@dataclass -class ObjectInfo: - """Object metadata from introspection.""" - - name: str - version: str - method_count: int - subobject_count: int - address: Address - - -@dataclass -class MethodInfo: - """Method signature from introspection.""" - - interface_id: int - call_type: int - method_id: int - name: str - parameter_types: list[int] = field( - default_factory=list - ) # Decoded parameter type IDs (Argument category) - parameter_labels: list[str] = field(default_factory=list) # Parameter names (if available) - return_types: list[int] = field( - default_factory=list - ) # Decoded return type IDs (ReturnElement/ReturnValue category) - return_labels: list[str] = field(default_factory=list) # Return names (if available) - - def get_signature_string(self) -> str: - """Get method signature as a readable string.""" - # Decode parameter types to readable names - if self.parameter_types: - param_type_names = [resolve_introspection_type_name(tid) for tid in self.parameter_types] - - # If we have labels, use them; otherwise just show types - if self.parameter_labels and len(self.parameter_labels) == len(param_type_names): - # Format as "param1: type1, param2: type2" - params = [ - f"{label}: {type_name}" - for label, type_name in zip(self.parameter_labels, param_type_names) - ] - param_str = ", ".join(params) - else: - # Just show types - param_str = ", ".join(param_type_names) - else: - param_str = "void" - - # Decode return types to readable names - if self.return_types: - return_type_names = [resolve_introspection_type_name(tid) for tid in self.return_types] - return_categories = [get_introspection_type_category(tid) for tid in self.return_types] - - # Format return based on category - if any(cat == "ReturnElement" for cat in return_categories): - # Multiple return values → struct format - if self.return_labels and len(self.return_labels) == len(return_type_names): - # Format as "{ label1: type1, label2: type2 }" - returns = [ - f"{label}: {type_name}" - for label, type_name in zip(self.return_labels, return_type_names) - ] - return_str = f"{{ {', '.join(returns)} }}" - else: - # Just show types - return_str = f"{{ {', '.join(return_type_names)} }}" - elif len(return_type_names) == 1: - # Single return value - if self.return_labels and len(self.return_labels) == 1: - return_str = f"{self.return_labels[0]}: {return_type_names[0]}" - else: - return_str = return_type_names[0] - else: - return_str = "void" - else: - return_str = "void" - - return f"{self.name}({param_str}) -> {return_str}" - - -@dataclass -class InterfaceInfo: - """Interface metadata from introspection.""" - - interface_id: int - name: str - version: str - - -@dataclass -class EnumInfo: - """Enum definition from introspection.""" - - enum_id: int - name: str - values: Dict[str, int] - - -@dataclass -class StructInfo: - """Struct definition from introspection.""" - - struct_id: int - name: str - fields: Dict[str, int] # field_name -> type_id - - @property - def field_type_names(self) -> Dict[str, str]: - """Get human-readable field type names.""" - return {field_name: resolve_type_id(type_id) for field_name, type_id in self.fields.items()} - - def get_struct_string(self) -> str: - """Get struct definition as a readable string.""" - field_strs = [ - f"{field_name}: {resolve_type_id(type_id)}" for field_name, type_id in self.fields.items() - ] - fields_str = "\n ".join(field_strs) if field_strs else " (empty)" - return f"struct {self.name} {{\n {fields_str}\n}}" - - -# ============================================================================ -# INTROSPECTION COMMAND CLASSES -# ============================================================================ - - -class GetObjectCommand(HamiltonCommand): - """Get object metadata (command_id=1).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 0 - command_id = 1 - action_code = 0 # QUERY - - def __init__(self, object_address: Address): - super().__init__(object_address) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_object response.""" - # Parse HOI2 DataFragments - parser = HoiParamsParser(data) - - _, name = parser.parse_next() - _, version = parser.parse_next() - _, method_count = parser.parse_next() - _, subobject_count = parser.parse_next() - - return { - "name": name, - "version": version, - "method_count": method_count, - "subobject_count": subobject_count, - } - - -class GetMethodCommand(HamiltonCommand): - """Get method signature (command_id=2).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 0 - command_id = 2 - action_code = 0 # QUERY - - def __init__(self, object_address: Address, method_index: int): - super().__init__(object_address) - self.method_index = method_index - - def build_parameters(self) -> HoiParams: - """Build parameters for get_method command.""" - return HoiParams().u32(self.method_index) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_method response.""" - parser = HoiParamsParser(data) - - _, interface_id = parser.parse_next() - _, call_type = parser.parse_next() - _, method_id = parser.parse_next() - _, name = parser.parse_next() - - # The remaining fragments are STRING types containing type IDs as bytes - # Hamilton sends ONE combined list where type IDs encode category (Argument/ReturnElement/ReturnValue) - # First STRING after method name is parameter_types (each byte is a type ID - can be Argument or Return) - # Second STRING (if present) is parameter_labels (comma-separated names - includes both params and returns) - parameter_types_str = None - parameter_labels_str = None - - if parser.has_remaining(): - _, parameter_types_str = parser.parse_next() - - if parser.has_remaining(): - _, parameter_labels_str = parser.parse_next() - - # Decode string bytes to type IDs (like piglet does: .as_bytes().to_vec()) - all_type_ids: list[int] = [] - if parameter_types_str: - all_type_ids = [ord(c) for c in parameter_types_str] - - # Parse all labels (comma-separated - includes both parameters and returns) - all_labels: list[str] = [] - if parameter_labels_str: - all_labels = [label.strip() for label in parameter_labels_str.split(",") if label.strip()] - - # Categorize by type ID ranges (like piglet does) - # Split into arguments vs returns based on type ID category - parameter_types: list[int] = [] - parameter_labels: list[str] = [] - return_types: list[int] = [] - return_labels: list[str] = [] - - for i, type_id in enumerate(all_type_ids): - category = get_introspection_type_category(type_id) - label = all_labels[i] if i < len(all_labels) else None - - if category == "Argument": - parameter_types.append(type_id) - if label: - parameter_labels.append(label) - elif category in ("ReturnElement", "ReturnValue"): - return_types.append(type_id) - if label: - return_labels.append(label) - # Unknown types - could be parameters or returns, default to parameters - else: - parameter_types.append(type_id) - if label: - parameter_labels.append(label) - - return { - "interface_id": interface_id, - "call_type": call_type, - "method_id": method_id, - "name": name, - "parameter_types": parameter_types, # Decoded type IDs (Argument category only) - "parameter_labels": parameter_labels, # Parameter names only - "return_types": return_types, # Decoded type IDs (ReturnElement/ReturnValue only) - "return_labels": return_labels, # Return names only - } - - -class GetSubobjectAddressCommand(HamiltonCommand): - """Get subobject address (command_id=3).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 0 - command_id = 3 - action_code = 0 # QUERY - - def __init__(self, object_address: Address, subobject_index: int): - super().__init__(object_address) - self.subobject_index = subobject_index - - def build_parameters(self) -> HoiParams: - """Build parameters for get_subobject_address command.""" - return HoiParams().u16(self.subobject_index) # Use u16, not u32 - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_subobject_address response.""" - parser = HoiParamsParser(data) - - _, module_id = parser.parse_next() - _, node_id = parser.parse_next() - _, object_id = parser.parse_next() - - return {"address": Address(module_id, node_id, object_id)} - - -class GetInterfacesCommand(HamiltonCommand): - """Get available interfaces (command_id=4).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 0 - command_id = 4 - action_code = 0 # QUERY - - def __init__(self, object_address: Address): - super().__init__(object_address) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_interfaces response.""" - parser = HoiParamsParser(data) - - interfaces = [] - _, interface_count = parser.parse_next() - - for _ in range(interface_count): - _, interface_id = parser.parse_next() - _, name = parser.parse_next() - _, version = parser.parse_next() - interfaces.append({"interface_id": interface_id, "name": name, "version": version}) - - return {"interfaces": interfaces} - - -class GetEnumsCommand(HamiltonCommand): - """Get enum definitions (command_id=5).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 0 - command_id = 5 - action_code = 0 # QUERY - - def __init__(self, object_address: Address, target_interface_id: int): - super().__init__(object_address) - self.target_interface_id = target_interface_id - - def build_parameters(self) -> HoiParams: - """Build parameters for get_enums command.""" - return HoiParams().u8(self.target_interface_id) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_enums response.""" - parser = HoiParamsParser(data) - - enums = [] - _, enum_count = parser.parse_next() - - for _ in range(enum_count): - _, enum_id = parser.parse_next() - _, name = parser.parse_next() - - # Parse enum values - _, value_count = parser.parse_next() - values = {} - for _ in range(value_count): - _, value_name = parser.parse_next() - _, value_value = parser.parse_next() - values[value_name] = value_value - - enums.append({"enum_id": enum_id, "name": name, "values": values}) - - return {"enums": enums} - - -class GetStructsCommand(HamiltonCommand): - """Get struct definitions (command_id=6).""" - - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 0 - command_id = 6 - action_code = 0 # QUERY - - def __init__(self, object_address: Address, target_interface_id: int): - super().__init__(object_address) - self.target_interface_id = target_interface_id - - def build_parameters(self) -> HoiParams: - """Build parameters for get_structs command.""" - return HoiParams().u8(self.target_interface_id) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_structs response.""" - parser = HoiParamsParser(data) - - structs = [] - _, struct_count = parser.parse_next() - - for _ in range(struct_count): - _, struct_id = parser.parse_next() - _, name = parser.parse_next() - - # Parse struct fields - _, field_count = parser.parse_next() - fields = {} - for _ in range(field_count): - _, field_name = parser.parse_next() - _, field_type = parser.parse_next() - fields[field_name] = field_type - - structs.append({"struct_id": struct_id, "name": name, "fields": fields}) - - return {"structs": structs} - - -# ============================================================================ -# HIGH-LEVEL INTROSPECTION API -# ============================================================================ - - -class HamiltonIntrospection: - """High-level API for Hamilton introspection.""" - - def __init__(self, backend): - """Initialize introspection API. - - Args: - backend: TCPBackend instance - """ - self.backend = backend - - async def get_object(self, address: Address) -> ObjectInfo: - """Get object metadata. - - Args: - address: Object address to query - - Returns: - Object metadata - """ - command = GetObjectCommand(address) - response = await self.backend.send_command(command) - - return ObjectInfo( - name=response["name"], - version=response["version"], - method_count=response["method_count"], - subobject_count=response["subobject_count"], - address=address, - ) - - async def get_method(self, address: Address, method_index: int) -> MethodInfo: - """Get method signature. - - Args: - address: Object address - method_index: Method index to query - - Returns: - Method signature - """ - command = GetMethodCommand(address, method_index) - response = await self.backend.send_command(command) - - return MethodInfo( - interface_id=response["interface_id"], - call_type=response["call_type"], - method_id=response["method_id"], - name=response["name"], - parameter_types=response.get("parameter_types", []), - parameter_labels=response.get("parameter_labels", []), - return_types=response.get("return_types", []), - return_labels=response.get("return_labels", []), - ) - - async def get_subobject_address(self, address: Address, subobject_index: int) -> Address: - """Get subobject address. - - Args: - address: Parent object address - subobject_index: Subobject index - - Returns: - Subobject address - """ - command = GetSubobjectAddressCommand(address, subobject_index) - response = await self.backend.send_command(command) - - # Type: ignore needed because response dict is typed as dict[str, Any] - # but we know 'address' key contains Address object - return response["address"] # type: ignore[no-any-return, return-value] - - async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: - """Get available interfaces. - - Args: - address: Object address - - Returns: - List of interface information - """ - command = GetInterfacesCommand(address) - response = await self.backend.send_command(command) - - return [ - InterfaceInfo( - interface_id=iface["interface_id"], name=iface["name"], version=iface["version"] - ) - for iface in response["interfaces"] - ] - - async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo]: - """Get enum definitions. - - Args: - address: Object address - interface_id: Interface ID - - Returns: - List of enum definitions - """ - command = GetEnumsCommand(address, interface_id) - response = await self.backend.send_command(command) - - return [ - EnumInfo(enum_id=enum_def["enum_id"], name=enum_def["name"], values=enum_def["values"]) - for enum_def in response["enums"] - ] - - async def get_structs(self, address: Address, interface_id: int) -> List[StructInfo]: - """Get struct definitions. - - Args: - address: Object address - interface_id: Interface ID - - Returns: - List of struct definitions - """ - command = GetStructsCommand(address, interface_id) - response = await self.backend.send_command(command) - - return [ - StructInfo( - struct_id=struct_def["struct_id"], name=struct_def["name"], fields=struct_def["fields"] - ) - for struct_def in response["structs"] - ] - - async def get_all_methods(self, address: Address) -> List[MethodInfo]: - """Get all methods for an object. - - Args: - address: Object address - - Returns: - List of all method signatures - """ - # First get object info to know how many methods there are - object_info = await self.get_object(address) - - methods = [] - for i in range(object_info.method_count): - try: - method = await self.get_method(address, i) - methods.append(method) - except Exception as e: - logger.warning(f"Failed to get method {i} for {address}: {e}") - - return methods - - async def discover_hierarchy(self, root_address: Address) -> Dict[str, Any]: - """Recursively discover object hierarchy. - - Args: - root_address: Root object address - - Returns: - Nested dictionary of discovered objects - """ - hierarchy = {} - - try: - # Get root object info - root_info = await self.get_object(root_address) - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["info"] = root_info # type: ignore[assignment] - - # Discover subobjects - subobjects = {} - for i in range(root_info.subobject_count): - try: - subaddress = await self.get_subobject_address(root_address, i) - subobjects[f"subobject_{i}"] = await self.discover_hierarchy(subaddress) - except Exception as e: - logger.warning(f"Failed to discover subobject {i}: {e}") - - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["subobjects"] = subobjects # type: ignore[assignment] - - # Discover methods - methods = await self.get_all_methods(root_address) - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["methods"] = methods # type: ignore[assignment] - - except Exception as e: - logger.error(f"Failed to discover hierarchy for {root_address}: {e}") - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["error"] = str(e) # type: ignore[assignment] - - return hierarchy - - async def discover_all_objects(self, root_addresses: List[Address]) -> Dict[str, Any]: - """Discover all objects starting from root addresses. - - Args: - root_addresses: List of root addresses to start discovery from - - Returns: - Dictionary mapping address strings to discovered hierarchies - """ - all_objects = {} - - for root_address in root_addresses: - try: - hierarchy = await self.discover_hierarchy(root_address) - all_objects[str(root_address)] = hierarchy - except Exception as e: - logger.error(f"Failed to discover objects from {root_address}: {e}") - all_objects[str(root_address)] = {"error": str(e)} - - return all_objects - - def print_method_signatures(self, methods: List[MethodInfo]) -> None: - """Print method signatures in a readable format. - - Args: - methods: List of MethodInfo objects to print - """ - print("Method Signatures:") - print("=" * 50) - for method in methods: - print(f" {method.get_signature_string()}") - print(f" Interface: {method.interface_id}, Method ID: {method.method_id}") - print() - - def print_struct_definitions(self, structs: List[StructInfo]) -> None: - """Print struct definitions in a readable format. - - Args: - structs: List of StructInfo objects to print - """ - print("Struct Definitions:") - print("=" * 50) - for struct in structs: - print(struct.get_struct_string()) - print() - - def get_methods_by_name(self, methods: List[MethodInfo], name_pattern: str) -> List[MethodInfo]: - """Filter methods by name pattern. - - Args: - methods: List of MethodInfo objects to filter - name_pattern: Name pattern to search for (case-insensitive) - - Returns: - List of methods matching the name pattern - """ - return [method for method in methods if name_pattern.lower() in method.name.lower()] - - def get_methods_by_interface( - self, methods: List[MethodInfo], interface_id: int - ) -> List[MethodInfo]: - """Filter methods by interface ID. - - Args: - methods: List of MethodInfo objects to filter - interface_id: Interface ID to filter by - - Returns: - List of methods from the specified interface - """ - return [method for method in methods if method.interface_id == interface_id] diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py index d2f6bb98729..701da6d33f9 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py @@ -1,863 +1,12 @@ -"""High-level Hamilton message builders and response parsers. - -This module provides user-facing message builders and their corresponding -response parsers. Each message type is paired with its response type: - -Request Builders: -- InitMessage: Builds IP[Connection] for initialization -- RegistrationMessage: Builds IP[HARP[Registration]] for discovery -- CommandMessage: Builds IP[HARP[HOI]] for method calls - -Response Parsers: -- InitResponse: Parses initialization responses -- RegistrationResponse: Parses registration responses -- CommandResponse: Parses command responses - -This pairing creates symmetry and makes correlation explicit. - -Architectural Note: -Parameter encoding (HoiParams/HoiParamsParser) is conceptually a separate layer -in the Hamilton protocol architecture (per documented architecture), but is -implemented here for efficiency since it's exclusively used by HOI messages. -This preserves the conceptual separation while optimizing implementation. - -Example: - # Build and send - msg = CommandMessage(dest, interface_id=0, method_id=42) - msg.add_i32(100) - packet_bytes = msg.build(src, seq=1) - - # Parse response - response = CommandResponse.from_bytes(received_bytes) - params = response.hoi.params -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from pylabrobot.io.binary import Reader, Writer -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import ( - Address, - HarpPacket, - HoiPacket, - IpPacket, - RegistrationPacket, +"""Compatibility shim — canonical location is pylabrobot.hamilton.tcp.messages.""" + +from pylabrobot.hamilton.tcp.messages import ( # noqa: F401 + CommandMessage, + CommandResponse, + HoiParams, + HoiParamsParser, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, ) -from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( - HamiltonDataType, - HarpTransportableProtocol, - RegistrationOptionType, -) - -# ============================================================================ -# HOI PARAMETER ENCODING - DataFragment wrapping for HOI protocol -# ============================================================================ -# -# Note: This is conceptually a separate layer in the Hamilton protocol -# architecture, but implemented here for efficiency since it's exclusively -# used by HOI messages (CommandMessage). -# ============================================================================ - - -class HoiParams: - """Builder for HOI parameters with automatic DataFragment wrapping. - - Each parameter is wrapped with DataFragment header before being added: - [type_id:1][flags:1][length:2][data:n] - - This ensures HOI parameters are always correctly formatted and eliminates - the possibility of forgetting to add DataFragment headers. - - Example: - Creates concatenated DataFragments: - [0x03|0x00|0x04|0x00|100][0x0F|0x00|0x05|0x00|"test\0"][0x1C|0x00|...array...] - - params = (HoiParams() - .i32(100) - .string("test") - .u32_array([1, 2, 3]) - .build()) - """ - - def __init__(self): - self._fragments: list[bytes] = [] - - def _add_fragment(self, type_id: int, data: bytes, flags: int = 0) -> "HoiParams": - """Add a DataFragment with the given type_id and data. - - Creates: [type_id:1][flags:1][length:2][data:n] - - Args: - type_id: Data type ID - data: Fragment data bytes - flags: Fragment flags (default: 0, but BOOL_ARRAY uses 0x01) - """ - fragment = Writer().u8(type_id).u8(flags).u16(len(data)).raw_bytes(data).finish() - self._fragments.append(fragment) - return self - - # Scalar integer types - def i8(self, value: int) -> "HoiParams": - """Add signed 8-bit integer parameter.""" - data = Writer().i8(value).finish() - return self._add_fragment(HamiltonDataType.I8, data) - - def i16(self, value: int) -> "HoiParams": - """Add signed 16-bit integer parameter.""" - data = Writer().i16(value).finish() - return self._add_fragment(HamiltonDataType.I16, data) - - def i32(self, value: int) -> "HoiParams": - """Add signed 32-bit integer parameter.""" - data = Writer().i32(value).finish() - return self._add_fragment(HamiltonDataType.I32, data) - - def i64(self, value: int) -> "HoiParams": - """Add signed 64-bit integer parameter.""" - data = Writer().i64(value).finish() - return self._add_fragment(HamiltonDataType.I64, data) - - def u8(self, value: int) -> "HoiParams": - """Add unsigned 8-bit integer parameter.""" - data = Writer().u8(value).finish() - return self._add_fragment(HamiltonDataType.U8, data) - - def u16(self, value: int) -> "HoiParams": - """Add unsigned 16-bit integer parameter.""" - data = Writer().u16(value).finish() - return self._add_fragment(HamiltonDataType.U16, data) - - def u32(self, value: int) -> "HoiParams": - """Add unsigned 32-bit integer parameter.""" - data = Writer().u32(value).finish() - return self._add_fragment(HamiltonDataType.U32, data) - - def u64(self, value: int) -> "HoiParams": - """Add unsigned 64-bit integer parameter.""" - data = Writer().u64(value).finish() - return self._add_fragment(HamiltonDataType.U64, data) - - # Floating-point types - def f32(self, value: float) -> "HoiParams": - """Add 32-bit float parameter.""" - data = Writer().f32(value).finish() - return self._add_fragment(HamiltonDataType.F32, data) - - def f64(self, value: float) -> "HoiParams": - """Add 64-bit double parameter.""" - data = Writer().f64(value).finish() - return self._add_fragment(HamiltonDataType.F64, data) - - # String and bool - def string(self, value: str) -> "HoiParams": - """Add null-terminated string parameter.""" - data = Writer().string(value).finish() - return self._add_fragment(HamiltonDataType.STRING, data) - - def bool_value(self, value: bool) -> "HoiParams": - """Add boolean parameter.""" - data = Writer().u8(1 if value else 0).finish() - return self._add_fragment(HamiltonDataType.BOOL, data) - - # Array types - def i8_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 8-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i8(val) - return self._add_fragment(HamiltonDataType.I8_ARRAY, writer.finish()) - - def i16_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 16-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i16(val) - return self._add_fragment(HamiltonDataType.I16_ARRAY, writer.finish()) - - def i32_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 32-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i32(val) - return self._add_fragment(HamiltonDataType.I32_ARRAY, writer.finish()) - - def i64_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 64-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i64(val) - return self._add_fragment(HamiltonDataType.I64_ARRAY, writer.finish()) - - def u8_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 8-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u8(val) - return self._add_fragment(HamiltonDataType.U8_ARRAY, writer.finish()) - - def u16_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 16-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u16(val) - return self._add_fragment(HamiltonDataType.U16_ARRAY, writer.finish()) - - def u32_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 32-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u32(val) - return self._add_fragment(HamiltonDataType.U32_ARRAY, writer.finish()) - - def u64_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 64-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u64(val) - return self._add_fragment(HamiltonDataType.U64_ARRAY, writer.finish()) - - def f32_array(self, values: list[float]) -> "HoiParams": - """Add array of 32-bit floats. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.f32(val) - return self._add_fragment(HamiltonDataType.F32_ARRAY, writer.finish()) - - def f64_array(self, values: list[float]) -> "HoiParams": - """Add array of 64-bit doubles. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.f64(val) - return self._add_fragment(HamiltonDataType.F64_ARRAY, writer.finish()) - - def bool_array(self, values: list[bool]) -> "HoiParams": - """Add array of booleans (stored as u8: 0 or 1). - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - - Note: BOOL_ARRAY uses flags=0x01 in the DataFragment header (unlike other types which use 0x00). - """ - writer = Writer() - for val in values: - writer.u8(1 if val else 0) - return self._add_fragment(HamiltonDataType.BOOL_ARRAY, writer.finish(), flags=0x01) - - def string_array(self, values: list[str]) -> "HoiParams": - """Add array of null-terminated strings. - - Format: [count:4][str0\0][str1\0]... - """ - writer = Writer().u32(len(values)) - for val in values: - writer.string(val) - return self._add_fragment(HamiltonDataType.STRING_ARRAY, writer.finish()) - - def build(self) -> bytes: - """Return concatenated DataFragments.""" - return b"".join(self._fragments) - - def count(self) -> int: - """Return number of fragments (parameters).""" - return len(self._fragments) - - -class HoiParamsParser: - """Parser for HOI DataFragment parameters. - - Parses DataFragment-wrapped values from HOI response payloads. - """ - - def __init__(self, data: bytes): - self._data = data - self._offset = 0 - - def parse_next(self) -> tuple[int, Any]: - """Parse the next DataFragment and return (type_id, value). - - Returns: - Tuple of (type_id, parsed_value) - - Raises: - ValueError: If data is malformed or insufficient - """ - if self._offset + 4 > len(self._data): - raise ValueError(f"Insufficient data for DataFragment header at offset {self._offset}") - - # Parse DataFragment header - reader = Reader(self._data[self._offset :]) - type_id = reader.u8() - _flags = reader.u8() # Read but unused - length = reader.u16() - - data_start = self._offset + 4 - data_end = data_start + length - - if data_end > len(self._data): - raise ValueError( - f"DataFragment data extends beyond buffer: need {data_end}, have {len(self._data)}" - ) - - # Extract data payload - fragment_data = self._data[data_start:data_end] - value = self._parse_value(type_id, fragment_data) - - # Move offset past this fragment - self._offset = data_end - - return (type_id, value) - - def _parse_value(self, type_id: int, data: bytes) -> Any: - """Parse value based on type_id using dispatch table.""" - reader = Reader(data) - - # Dispatch table for scalar types - scalar_parsers = { - HamiltonDataType.I8: reader.i8, - HamiltonDataType.I16: reader.i16, - HamiltonDataType.I32: reader.i32, - HamiltonDataType.I64: reader.i64, - HamiltonDataType.U8: reader.u8, - HamiltonDataType.U16: reader.u16, - HamiltonDataType.U32: reader.u32, - HamiltonDataType.U64: reader.u64, - HamiltonDataType.F32: reader.f32, - HamiltonDataType.F64: reader.f64, - HamiltonDataType.STRING: reader.string, - } - - # Check scalar types first - # Cast int to HamiltonDataType enum for dict lookup - try: - data_type = HamiltonDataType(type_id) - if data_type in scalar_parsers: - return scalar_parsers[data_type]() - except ValueError: - pass # Not a valid enum value, continue to other checks - - # Special case: bool - if type_id == HamiltonDataType.BOOL: - return reader.u8() == 1 - - # Dispatch table for array element parsers - array_element_parsers = { - HamiltonDataType.I8_ARRAY: reader.i8, - HamiltonDataType.I16_ARRAY: reader.i16, - HamiltonDataType.I32_ARRAY: reader.i32, - HamiltonDataType.I64_ARRAY: reader.i64, - HamiltonDataType.U8_ARRAY: reader.u8, - HamiltonDataType.U16_ARRAY: reader.u16, - HamiltonDataType.U32_ARRAY: reader.u32, - HamiltonDataType.U64_ARRAY: reader.u64, - HamiltonDataType.F32_ARRAY: reader.f32, - HamiltonDataType.F64_ARRAY: reader.f64, - HamiltonDataType.STRING_ARRAY: reader.string, - } - - # Handle arrays - # Arrays don't have a count prefix - count is derived from DataFragment length - # Calculate element size based on type - element_sizes = { - HamiltonDataType.I8_ARRAY: 1, - HamiltonDataType.I16_ARRAY: 2, - HamiltonDataType.I32_ARRAY: 4, - HamiltonDataType.I64_ARRAY: 8, - HamiltonDataType.U8_ARRAY: 1, - HamiltonDataType.U16_ARRAY: 2, - HamiltonDataType.U32_ARRAY: 4, - HamiltonDataType.U64_ARRAY: 8, - HamiltonDataType.F32_ARRAY: 4, - HamiltonDataType.F64_ARRAY: 8, - HamiltonDataType.STRING_ARRAY: None, # Variable length, handled separately - } - - # Cast int to HamiltonDataType enum for dict lookup - try: - data_type = HamiltonDataType(type_id) - if data_type in array_element_parsers: - element_size = element_sizes.get(data_type) - if element_size is not None: - # Fixed-size elements: calculate count from data length - count = len(data) // element_size - return [array_element_parsers[data_type]() for _ in range(count)] - elif data_type == HamiltonDataType.STRING_ARRAY: - # String arrays: null-terminated strings concatenated, no count prefix - # Parse by splitting on null bytes - strings = [] - current_string = bytearray() - for byte in data: - if byte == 0: - if current_string: - strings.append(current_string.decode("utf-8", errors="replace")) - current_string = bytearray() - else: - current_string.append(byte) - # Handle case where last string doesn't end with null (shouldn't happen, but be safe) - if current_string: - strings.append(current_string.decode("utf-8", errors="replace")) - return strings - except ValueError: - # Not a valid enum value, continue to other checks - # This shouldn't happen for valid Hamilton types, but we continue anyway - pass - - # Special case: bool array (1 byte per element) - if type_id == HamiltonDataType.BOOL_ARRAY: - count = len(data) // 1 # Each bool is 1 byte - return [reader.u8() == 1 for _ in range(count)] - - # Unknown type - raise ValueError(f"Unknown or unsupported type_id: {type_id}") - - def has_remaining(self) -> bool: - """Check if there are more DataFragments to parse.""" - return self._offset < len(self._data) - - def parse_all(self) -> list[tuple[int, Any]]: - """Parse all remaining DataFragments. - - Returns: - List of (type_id, value) tuples - """ - results = [] - while self.has_remaining(): - results.append(self.parse_next()) - return results - - -# ============================================================================ -# MESSAGE BUILDERS -# ============================================================================ - - -class CommandMessage: - """Build HOI command messages for method calls. - - Creates complete IP[HARP[HOI]] packets with proper protocols and actions. - Parameters are automatically wrapped with DataFragment headers via HoiParams. - - Example: - msg = CommandMessage(dest, interface_id=0, method_id=42) - msg.add_i32(100).add_string("test") - packet_bytes = msg.build(src, seq=1) - """ - - def __init__( - self, - dest: Address, - interface_id: int, - method_id: int, - params: HoiParams, - action_code: int = 3, # Default: COMMAND_REQUEST - harp_protocol: int = 2, # Default: HOI2 - ip_protocol: int = 6, # Default: OBJECT_DISCOVERY - ): - """Initialize command message. - - Args: - dest: Destination object address - interface_id: Interface ID (typically 0 for main interface, 1 for extended) - method_id: Method/action ID to invoke - action_code: HOI action code (default 3=COMMAND_REQUEST) - harp_protocol: HARP protocol identifier (default 2=HOI2) - ip_protocol: IP protocol identifier (default 6=OBJECT_DISCOVERY) - """ - self.dest = dest - self.interface_id = interface_id - self.method_id = method_id - self.params = params - self.action_code = action_code - self.harp_protocol = harp_protocol - self.ip_protocol = ip_protocol - - def build( - self, - src: Address, - seq: int, - harp_response_required: bool = True, - hoi_response_required: bool = False, - ) -> bytes: - """Build complete IP[HARP[HOI]] packet. - - Args: - src: Source address (client address) - seq: Sequence number for this request - harp_response_required: Set bit 4 in HARP action byte (default True) - hoi_response_required: Set bit 4 in HOI action byte (default False) - - Returns: - Complete packet bytes ready to send over TCP - """ - # Build HOI - it handles its own action byte construction - hoi = HoiPacket( - interface_id=self.interface_id, - action_code=self.action_code, - action_id=self.method_id, - params=self.params.build(), - response_required=hoi_response_required, - ) - - # Build HARP - it handles its own action byte construction - harp = HarpPacket( - src=src, - dst=self.dest, - seq=seq, - protocol=self.harp_protocol, - action_code=self.action_code, - payload=hoi.pack(), - response_required=harp_response_required, - ) - - # Wrap in IP packet - ip = IpPacket(protocol=self.ip_protocol, payload=harp.pack()) - - return ip.pack() - - -class RegistrationMessage: - """Build Registration messages for object discovery. - - Creates complete IP[HARP[Registration]] packets for discovering modules, - objects, and capabilities on the Hamilton instrument. - - Example: - msg = RegistrationMessage(dest, action_code=12) - msg.add_registration_option(RegistrationOptionType.HARP_PROTOCOL_REQUEST, protocol=2, request_id=1) - packet_bytes = msg.build(src, req_addr, res_addr, seq=1) - """ - - def __init__( - self, - dest: Address, - action_code: int, - response_code: int = 0, # Default: no error - harp_protocol: int = 3, # Default: Registration - ip_protocol: int = 6, # Default: OBJECT_DISCOVERY - ): - """Initialize registration message. - - Args: - dest: Destination address (typically 0:0:65534 for registration service) - action_code: Registration action code (e.g., 12=HARP_PROTOCOL_REQUEST) - response_code: Response code (default 0=no error) - harp_protocol: HARP protocol identifier (default 3=Registration) - ip_protocol: IP protocol identifier (default 6=OBJECT_DISCOVERY) - """ - self.dest = dest - self.action_code = action_code - self.response_code = response_code - self.harp_protocol = harp_protocol - self.ip_protocol = ip_protocol - self.options = bytearray() - - def add_registration_option( - self, option_type: RegistrationOptionType, protocol: int = 2, request_id: int = 1 - ) -> "RegistrationMessage": - """Add a registration packet option. - - Args: - option_type: Type of registration option (from RegistrationOptionType enum) - protocol: For HARP_PROTOCOL_REQUEST: protocol type (2=HOI, default) - request_id: For HARP_PROTOCOL_REQUEST: what to discover (1=root, 2=global) - - Returns: - Self for method chaining - """ - # Registration option format: [option_id:1][length:1][data...] - # For HARP_PROTOCOL_REQUEST (option 5): data is [protocol:1][request_id:1] - data = Writer().u8(protocol).u8(request_id).finish() - option = Writer().u8(option_type).u8(len(data)).raw_bytes(data).finish() - self.options.extend(option) - return self - - def build( - self, - src: Address, - req_addr: Address, - res_addr: Address, - seq: int, - harp_action_code: int = 3, # Default: COMMAND_REQUEST - harp_response_required: bool = True, # Default: request with response - ) -> bytes: - """Build complete IP[HARP[Registration]] packet. - - Args: - src: Source address (client address) - req_addr: Request address (for registration context) - res_addr: Response address (for registration context) - seq: Sequence number for this request - harp_action_code: HARP action code (default 3=COMMAND_REQUEST) - harp_response_required: Whether response required (default True) - - Returns: - Complete packet bytes ready to send over TCP - """ - # Build Registration packet - reg = RegistrationPacket( - action_code=self.action_code, - response_code=self.response_code, - req_address=req_addr, - res_address=res_addr, - options=bytes(self.options), - ) - - # Wrap in HARP packet - harp = HarpPacket( - src=src, - dst=self.dest, - seq=seq, - protocol=self.harp_protocol, - action_code=harp_action_code, - payload=reg.pack(), - response_required=harp_response_required, - ) - - # Wrap in IP packet - ip = IpPacket(protocol=self.ip_protocol, payload=harp.pack()) - - return ip.pack() - - -class InitMessage: - """Build Connection initialization messages. - - Creates complete IP[Connection] packets for establishing a connection - with the Hamilton instrument. Uses Protocol 7 (INITIALIZATION) which - has a different structure than HARP-based messages. - - Example: - msg = InitMessage(timeout=30) - packet_bytes = msg.build() - """ - - def __init__( - self, - timeout: int = 30, - connection_type: int = 1, # Default: standard connection - protocol_version: int = 0x30, # Default: 3.0 - ip_protocol: int = 7, # Default: INITIALIZATION - ): - """Initialize connection message. - - Args: - timeout: Connection timeout in seconds (default 30) - connection_type: Connection type (default 1=standard) - protocol_version: Protocol version byte (default 0x30=3.0) - ip_protocol: IP protocol identifier (default 7=INITIALIZATION) - """ - self.timeout = timeout - self.connection_type = connection_type - self.protocol_version = protocol_version - self.ip_protocol = ip_protocol - - def build(self) -> bytes: - """Build complete IP[Connection] packet. - - Returns: - Complete packet bytes ready to send over TCP - """ - # Build raw connection parameters (NOT DataFragments) - # Frame: [version:1][message_id:1][count:1][unknown:1] - # Parameters: [id:1][type:1][reserved:2][value:2] repeated - params = ( - Writer() - # Frame - .u8(0) # version - .u8(0) # message_id - .u8(3) # count (3 parameters) - .u8(0) # unknown - # Parameter 1: connection_id (request allocation) - .u8(1) # param id - .u8(16) # param type - .u16(0) # reserved - .u16(0) # value (0 = request allocation) - # Parameter 2: connection_type - .u8(2) # param id - .u8(16) # param type - .u16(0) # reserved - .u16(self.connection_type) # value - # Parameter 3: timeout - .u8(4) # param id - .u8(16) # param type - .u16(0) # reserved - .u16(self.timeout) # value - .finish() - ) - - # Build IP packet - packet_size = 1 + 1 + 2 + len(params) # protocol + version + opts_len + params - - return ( - Writer() - .u16(packet_size) - .u8(self.ip_protocol) - .u8(self.protocol_version) - .u16(0) # options_length - .raw_bytes(params) - .finish() - ) - - -# ============================================================================ -# RESPONSE PARSERS - Paired with message builders above -# ============================================================================ - - -@dataclass -class InitResponse: - """Parsed initialization response. - - Pairs with InitMessage - parses Protocol 7 (INITIALIZATION) responses. - """ - - raw_bytes: bytes - client_id: int - connection_type: int - timeout: int - - @classmethod - def from_bytes(cls, data: bytes) -> "InitResponse": - """Parse initialization response. - - Args: - data: Raw bytes from TCP socket - - Returns: - Parsed InitResponse with connection parameters - """ - # Skip IP header (size + protocol + version + opts_len = 6 bytes) - parser = Reader(data[6:]) - - # Parse frame - _version = parser.u8() # Read but unused - _message_id = parser.u8() # Read but unused - _count = parser.u8() # Read but unused - _unknown = parser.u8() # Read but unused - - # Parse parameter 1 (client_id) - _param1_id = parser.u8() # Read but unused - _param1_type = parser.u8() # Read but unused - _param1_reserved = parser.u16() # Read but unused - client_id = parser.u16() - - # Parse parameter 2 (connection_type) - _param2_id = parser.u8() # Read but unused - _param2_type = parser.u8() # Read but unused - _param2_reserved = parser.u16() # Read but unused - connection_type = parser.u16() - - # Parse parameter 4 (timeout) - _param4_id = parser.u8() # Read but unused - _param4_type = parser.u8() # Read but unused - _param4_reserved = parser.u16() # Read but unused - timeout = parser.u16() - - return cls( - raw_bytes=data, client_id=client_id, connection_type=connection_type, timeout=timeout - ) - - -@dataclass -class RegistrationResponse: - """Parsed registration response. - - Pairs with RegistrationMessage - parses IP[HARP[Registration]] responses. - """ - - raw_bytes: bytes - ip: IpPacket - harp: HarpPacket - registration: RegistrationPacket - - @classmethod - def from_bytes(cls, data: bytes) -> "RegistrationResponse": - """Parse registration response. - - Args: - data: Raw bytes from TCP socket - - Returns: - Parsed RegistrationResponse with all layers - """ - ip = IpPacket.unpack(data) - harp = HarpPacket.unpack(ip.payload) - registration = RegistrationPacket.unpack(harp.payload) - - return cls(raw_bytes=data, ip=ip, harp=harp, registration=registration) - - @property - def sequence_number(self) -> int: - """Get sequence number from HARP layer.""" - return self.harp.seq - - -@dataclass -class CommandResponse: - """Parsed command response. - - Pairs with CommandMessage - parses IP[HARP[HOI]] responses. - """ - - raw_bytes: bytes - ip: IpPacket - harp: HarpPacket - hoi: HoiPacket - - @classmethod - def from_bytes(cls, data: bytes) -> "CommandResponse": - """Parse command response. - - Args: - data: Raw bytes from TCP socket - - Returns: - Parsed CommandResponse with all layers - - Raises: - ValueError: If response is not HOI protocol - """ - ip = IpPacket.unpack(data) - harp = HarpPacket.unpack(ip.payload) - - if harp.protocol != HarpTransportableProtocol.HOI2: - raise ValueError(f"Expected HOI2 protocol, got {harp.protocol}") - - hoi = HoiPacket.unpack(harp.payload) - - return cls(raw_bytes=data, ip=ip, harp=harp, hoi=hoi) - - @property - def sequence_number(self) -> int: - """Get sequence number from HARP layer.""" - return self.harp.seq diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py index fb301cfbef6..83a4d1e679b 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py @@ -1,419 +1,14 @@ -"""Hamilton TCP packet structures. - -This module defines the packet layer of the Hamilton protocol stack: -- IpPacket: Transport layer (size, protocol, version, payload) -- HarpPacket: Protocol layer (addressing, sequence, action, payload) -- HoiPacket: HOI application layer (interface_id, action_id, DataFragment params) -- RegistrationPacket: Registration protocol payload -- ConnectionPacket: Connection initialization payload - -Each packet knows how to pack/unpack itself using the Wire serialization layer. -""" - -from __future__ import annotations - -import struct -from dataclasses import dataclass - -from pylabrobot.io.binary import Reader, Writer - -# Hamilton protocol version -HAMILTON_PROTOCOL_VERSION_MAJOR = 3 -HAMILTON_PROTOCOL_VERSION_MINOR = 0 - - -def encode_version_byte(major: int, minor: int) -> int: - """Pack Hamilton version byte (two 4-bit fields packed into one byte). - - Args: - major: Major version (0-15, stored in upper 4 bits) - minor: Minor version (0-15, stored in lower 4 bits) - """ - if not 0 <= major <= 15: - raise ValueError(f"major version must be 0-15, got {major}") - if not 0 <= minor <= 15: - raise ValueError(f"minor version must be 0-15, got {minor}") - version_byte = (minor & 0xF) | ((major & 0xF) << 4) - return version_byte - - -def decode_version_byte(version_bite: int) -> tuple[int, int]: - """Decode Hamilton version byte and return (major, minor). - - Returns: - Tuple of (major_version, minor_version), each 0-15 - """ - minor = version_bite & 0xF - major = (version_bite >> 4) & 0xF - return (major, minor) - - -@dataclass(frozen=True) -class Address: - """Hamilton network address (module_id, node_id, object_id).""" - - module: int # u16 - node: int # u16 - object: int # u16 - - def pack(self) -> bytes: - """Serialize address to 6 bytes.""" - return Writer().u16(self.module).u16(self.node).u16(self.object).finish() - - @classmethod - def unpack(cls, data: bytes) -> "Address": - """Deserialize address from bytes.""" - r = Reader(data) - return cls(module=r.u16(), node=r.u16(), object=r.u16()) - - def __str__(self) -> str: - return f"{self.module}:{self.node}:{self.object}" - - -@dataclass -class IpPacket: - """Hamilton IpPacket2 - Transport layer. - - Structure: - Bytes 00-01: size (2) - Bytes 02: protocol (1) - Bytes 03: version byte (major.minor) - Bytes 04-05: options_length (2) - Bytes 06+: options (x bytes) - Bytes: payload - """ - - protocol: int # Protocol identifier (6=OBJECT_DISCOVERY, 7=INITIALIZATION) - payload: bytes - options: bytes = b"" - - def pack(self) -> bytes: - """Serialize IP packet.""" - # Calculate size: protocol(1) + version(1) + opts_len(2) + options + payload - packet_size = 1 + 1 + 2 + len(self.options) + len(self.payload) - - return ( - Writer() - .u16(packet_size) - .u8(self.protocol) - .u8(encode_version_byte(HAMILTON_PROTOCOL_VERSION_MAJOR, HAMILTON_PROTOCOL_VERSION_MINOR)) - .u16(len(self.options)) - .raw_bytes(self.options) - .raw_bytes(self.payload) - .finish() - ) - - @classmethod - def unpack(cls, data: bytes) -> "IpPacket": - """Deserialize IP packet.""" - r = Reader(data) - _size = r.u16() # Read but unused - protocol = r.u8() - major, minor = decode_version_byte(r.u8()) - - # Validate version - if major != HAMILTON_PROTOCOL_VERSION_MAJOR or minor != HAMILTON_PROTOCOL_VERSION_MINOR: - # Warning but not fatal - pass - - opts_len = r.u16() - options = r.raw_bytes(opts_len) if opts_len > 0 else b"" - payload = r.remaining() - - return cls(protocol=protocol, payload=payload, options=options) - - -@dataclass -class HarpPacket: - """Hamilton HarpPacket2 - Protocol layer. - - Structure: - Bytes 00-05: src address (module, node, object) - Bytes 06-11: dst address (module, node, object) - Byte 12: sequence number - Byte 13: reserved - Byte 14: protocol (2=HOI, 3=Registration) - Byte 15: action - Bytes 16-17: message length - Bytes 18-19: options length - Bytes 20+: options - Bytes: version byte (major.minor) - Byte: reserved2 - Bytes: payload - """ - - src: Address - dst: Address - seq: int - protocol: int # 2=HOI, 3=Registration - action_code: int # Base action code (0-15) - payload: bytes - options: bytes = b"" - response_required: bool = True # Controls bit 4 of action byte - - @property - def action(self) -> int: - """Compute action byte from action_code and response_required flag. - - Returns: - Action byte with bit 4 set if response required - """ - return self.action_code | (0x10 if self.response_required else 0x00) - - def pack(self) -> bytes: - """Serialize HARP packet.""" - # Message length includes: src(6) + dst(6) + seq(1) + reserved(1) + protocol(1) + - # action(1) + msg_len(2) + opts_len(2) + options + version(1) + reserved2(1) + payload - # = 20 (fixed header) + options + version + reserved2 + payload - msg_len = 20 + len(self.options) + 1 + 1 + len(self.payload) - - return ( - Writer() - .raw_bytes(self.src.pack()) - .raw_bytes(self.dst.pack()) - .u8(self.seq) - .u8(0) # reserved - .u8(self.protocol) - .u8(self.action) # Uses computed property - .u16(msg_len) - .u16(len(self.options)) - .raw_bytes(self.options) - .u8(0) # version byte - C# DLL uses 0, not 3.0 - .u8(0) # reserved2 - .raw_bytes(self.payload) - .finish() - ) - - @classmethod - def unpack(cls, data: bytes) -> "HarpPacket": - """Deserialize HARP packet.""" - r = Reader(data) - - # Parse addresses - src = Address.unpack(r.raw_bytes(6)) - dst = Address.unpack(r.raw_bytes(6)) - - seq = r.u8() - _reserved = r.u8() # Read but unused - protocol = r.u8() - action_byte = r.u8() - _msg_len = r.u16() # Read but unused - opts_len = r.u16() - - options = r.raw_bytes(opts_len) if opts_len > 0 else b"" - _version = r.u8() # version byte (C# DLL uses 0) - Read but unused - _reserved2 = r.u8() # Read but unused - payload = r.remaining() - - # Decompose action byte into action_code and response_required flag - action_code = action_byte & 0x0F - response_required = bool(action_byte & 0x10) - - return cls( - src=src, - dst=dst, - seq=seq, - protocol=protocol, - action_code=action_code, - payload=payload, - options=options, - response_required=response_required, - ) - - -@dataclass -class HoiPacket: - """Hamilton HoiPacket2 - HOI application layer. - - Structure: - Byte 00: interface_id - Byte 01: action - Bytes 02-03: action_id - Byte 04: version byte (major.minor) - Byte 05: number of fragments - Bytes 06+: DataFragments - - Note: params must be DataFragment-wrapped (use HoiParams to build). - """ - - interface_id: int - action_code: int # Base action code (0-15) - action_id: int - params: bytes # Already DataFragment-wrapped via HoiParams - response_required: bool = False # Controls bit 4 of action byte - - @property - def action(self) -> int: - """Compute action byte from action_code and response_required flag. - - Returns: - Action byte with bit 4 set if response required - """ - return self.action_code | (0x10 if self.response_required else 0x00) - - def pack(self) -> bytes: - """Serialize HOI packet.""" - num_fragments = self._count_fragments(self.params) - - return ( - Writer() - .u8(self.interface_id) - .u8(self.action) # Uses computed property - .u16(self.action_id) - .u8(0) # version byte - always 0 for HOI packets (not 0x30!) - .u8(num_fragments) - .raw_bytes(self.params) - .finish() - ) - - @classmethod - def unpack(cls, data: bytes) -> "HoiPacket": - """Deserialize HOI packet.""" - r = Reader(data) - - interface_id = r.u8() - action_byte = r.u8() - action_id = r.u16() - major, minor = decode_version_byte(r.u8()) - _num_fragments = r.u8() # Read but unused - params = r.remaining() - - # Decompose action byte into action_code and response_required flag - action_code = action_byte & 0x0F - response_required = bool(action_byte & 0x10) - - return cls( - interface_id=interface_id, - action_code=action_code, - action_id=action_id, - params=params, - response_required=response_required, - ) - - @staticmethod - def _count_fragments(data: bytes) -> int: - """Count DataFragments in params. - - Each DataFragment has format: [type_id:1][flags:1][length:2][data:n] - """ - if len(data) == 0: - return 0 - - count = 0 - offset = 0 - - while offset < len(data): - if offset + 4 > len(data): - break # Not enough bytes for a fragment header - - # Read fragment length - fragment_length = struct.unpack(" bytes: - """Serialize Registration packet.""" - return ( - Writer() - .u16(self.action_code) - .u16(self.response_code) - .u8(0) # version byte - DLL uses 0.0, not 3.0 - .u8(0) # reserved - .raw_bytes(self.req_address.pack()) - .raw_bytes(self.res_address.pack()) - .u16(len(self.options)) - .raw_bytes(self.options) - .finish() - ) - - @classmethod - def unpack(cls, data: bytes) -> "RegistrationPacket": - """Deserialize Registration packet.""" - r = Reader(data) - - action_code = r.u16() - response_code = r.u16() - _version = r.u8() # version byte (DLL uses 0, not packed 3.0) - Read but unused - _reserved = r.u8() # Read but unused - req_address = Address.unpack(r.raw_bytes(6)) - res_address = Address.unpack(r.raw_bytes(6)) - opts_len = r.u16() - options = r.raw_bytes(opts_len) if opts_len > 0 else b"" - - return cls( - action_code=action_code, - response_code=response_code, - req_address=req_address, - res_address=res_address, - options=options, - ) - - -@dataclass -class ConnectionPacket: - """Hamilton ConnectionPacket - Connection initialization payload. - - Used for Protocol 7 (INITIALIZATION). Has a different structure than - HARP-based packets - uses raw parameter encoding, NOT DataFragments. - - Structure: - Byte 00: version - Byte 01: message_id - Byte 02: count (number of parameters) - Byte 03: unknown - Bytes 04+: raw parameters [id|type|reserved|value] repeated - """ - - params: bytes # Raw format (NOT DataFragments) - - def pack_into_ip(self) -> bytes: - """Build complete IP packet for connection initialization. - - Returns full IP packet with protocol=7. - """ - # Connection packet size: just the params (frame is included in params) - packet_size = 1 + 1 + 2 + len(self.params) - - return ( - Writer() - .u16(packet_size) - .u8(7) # INITIALIZATION protocol - .u8(encode_version_byte(HAMILTON_PROTOCOL_VERSION_MAJOR, HAMILTON_PROTOCOL_VERSION_MINOR)) - .u16(0) # options_length - .raw_bytes(self.params) - .finish() - ) - - @classmethod - def unpack_from_ip_payload(cls, data: bytes) -> "ConnectionPacket": - """Extract ConnectionPacket from IP packet payload. - - Assumes IP header has already been parsed. - """ - return cls(params=data) +"""Compatibility shim — canonical location is pylabrobot.hamilton.tcp.packets.""" + +from pylabrobot.hamilton.tcp.packets import ( # noqa: F401 + HAMILTON_PROTOCOL_VERSION_MAJOR, + HAMILTON_PROTOCOL_VERSION_MINOR, + Address, + ConnectionPacket, + HarpPacket, + HoiPacket, + IpPacket, + RegistrationPacket, + decode_version_byte, + encode_version_byte, +) diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/protocol.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/protocol.py index 9e916e91db3..e6989ffefc7 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/protocol.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/protocol.py @@ -1,178 +1,13 @@ -"""Hamilton TCP protocol constants and enumerations. - -This module contains all protocol-level constants, enumerations, and type definitions -used throughout the Hamilton TCP communication stack. -""" - -from __future__ import annotations - -from enum import IntEnum - -# Hamilton protocol version (from Piglet: version byte 0x30 = major 3, minor 0) -HAMILTON_PROTOCOL_VERSION_MAJOR = 3 -HAMILTON_PROTOCOL_VERSION_MINOR = 0 - - -class HamiltonProtocol(IntEnum): - """Hamilton protocol identifiers. - - These values are derived from the piglet Rust implementation: - - Protocol 2: PIPETTE - pipette-specific operations - - Protocol 3: REGISTRATION - object registration and discovery - - Protocol 6: OBJECT_DISCOVERY - general object discovery and method calls - - Protocol 7: INITIALIZATION - connection initialization and client ID negotiation - """ - - PIPETTE = 0x02 - REGISTRATION = 0x03 - OBJECT_DISCOVERY = 0x06 - INITIALIZATION = 0x07 - - -class Hoi2Action(IntEnum): - """HOI2/HARP2 action codes (bits 0-3 of action field). - - Values from Hamilton.Components.TransportLayer.Protocols.HoiPacket2Constants.Hoi2Action - - The action byte combines the action code (lower 4 bits) with the response_required flag (bit 4): - - action_byte = action_code | (0x10 if response_required else 0x00) - - Example: COMMAND_REQUEST with response = 3 | 0x10 = 0x13 - - Example: STATUS_REQUEST without response = 0 | 0x00 = 0x00 - - Common action codes: - - COMMAND_REQUEST (3): Send a command to an object (most common for method calls) - - STATUS_REQUEST (0): Request status information - - COMMAND_RESPONSE (4): Response to a command - - STATUS_RESPONSE (1): Response with status information - - NOTE: According to Hamilton documentation, both HARP2 and HOI2 use the same action - enumeration values. This needs verification through TCP introspection. - """ - - STATUS_REQUEST = 0 - STATUS_RESPONSE = 1 - STATUS_EXCEPTION = 2 - COMMAND_REQUEST = 3 - COMMAND_RESPONSE = 4 - COMMAND_EXCEPTION = 5 - COMMAND_ACK = 6 - UPSTREAM_SYSTEM_EVENT = 7 - DOWNSTREAM_SYSTEM_EVENT = 8 - EVENT = 9 - INVALID_ACTION_RESPONSE = 10 - STATUS_WARNING = 11 - COMMAND_WARNING = 12 - - -class HarpTransportableProtocol(IntEnum): - """HARP2 protocol field values - determines payload type. - - From Hamilton.Components.TransportLayer.Protocols.HarpTransportableProtocol. - The protocol field at byte 14 in HARP2 tells which payload parser to use. - """ - - HOI2 = 2 # Payload is Hoi2 structure (Protocol 2) - REGISTRATION2 = 3 # Payload is Registration2 structure (Protocol 3) - NOT_DEFINED = 0xFF # Invalid/unknown protocol - - -class RegistrationActionCode(IntEnum): - """Registration2 action codes (bytes 0-1 in Registration2 packet). - - From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.RegistrationActionCode2. - - Note: HARP action values for Registration packets are different from HOI action codes: - - 0x13 (19): Request with response required (typical for HARP_PROTOCOL_REQUEST) - - 0x14 (20): Response with data (typical for HARP_PROTOCOL_RESPONSE) - - 0x03 (3): Request without response - """ - - REGISTRATION_REQUEST = 0 # Initial registration handshake - REGISTRATION_RESPONSE = 1 # Response to registration - DEREGISTRATION_REQUEST = 2 # Cleanup on disconnect - DEREGISTRATION_RESPONSE = 3 # Deregistration acknowledgment - NODE_RESET_INDICATION = 4 # Node will reset - BRIDGE_REGISTRATION_REQUEST = 5 # Bridge registration - START_NODE_IDENTIFICATION = 6 # Start identification - START_NODE_IDENTIFICATION_RESPONSE = 7 - STOP_NODE_IDENTIFICATION = 8 # Stop identification - STOP_NODE_IDENTIFICATION_RESPONSE = 9 - LIST_OF_REGISTERED_MODULES_REQUEST = 10 # Request registered modules - LIST_OF_REGISTERED_MODULES_RESPONSE = 11 - HARP_PROTOCOL_REQUEST = 12 # Request objects (most important!) - HARP_PROTOCOL_RESPONSE = 13 # Response with object list - HARP_NODE_REMOVED_FROM_NETWORK = 14 - LIST_OF_REGISTERED_NODES_REQUEST = 15 - LIST_OF_REGISTERED_NODES_RESPONSE = 16 - - -class RegistrationOptionType(IntEnum): - """Registration2 option types (byte 0 of each option). - - From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.Option. - - These are semantic labels for the TYPE of information (what it means), while the - actual data inside uses Hamilton type_ids (how it's encoded). - """ - - RESERVED = 0 # Padding for 16-bit alignment when odd number of unsupported options - INCOMPATIBLE_VERSION = 1 # Version mismatch error (HARP version too high) - UNSUPPORTED_OPTIONS = 2 # Unknown options error - START_NODE_IDENTIFICATION = 3 # Identification timeout (seconds) - HARP_NETWORK_ADDRESS = 4 # Registered module/node IDs - HARP_PROTOCOL_REQUEST = 5 # Protocol request - HARP_PROTOCOL_RESPONSE = 6 # PRIMARY: Contains object ID lists (most commonly used) - - -class HamiltonDataType(IntEnum): - """Hamilton parameter data types for wire encoding in DataFragments. - - These constants represent the type identifiers used in Hamilton DataFragments - for HOI2 command parameters. Each type ID corresponds to a specific data format - and encoding scheme used on the wire. - - From Hamilton.Components.TransportLayer.Protocols.Parameter.ParameterTypes. - """ - - # Scalar integer types - I8 = 1 - I16 = 2 - I32 = 3 - U8 = 4 - U16 = 5 - U32 = 6 - I64 = 36 - U64 = 37 - - # Floating-point types - F32 = 40 - F64 = 41 - - # String and boolean - STRING = 15 - BOOL = 23 - - # Array types - U8_ARRAY = 22 - I8_ARRAY = 24 - I16_ARRAY = 25 - U16_ARRAY = 26 - I32_ARRAY = 27 - U32_ARRAY = 28 - BOOL_ARRAY = 29 - STRING_ARRAY = 34 - I64_ARRAY = 38 - U64_ARRAY = 39 - F32_ARRAY = 42 - F64_ARRAY = 43 - - -class HoiRequestId(IntEnum): - """Request types for HarpProtocolRequest (byte 3 in command_data). - - From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.HarpProtocolRequest.HoiRequestId. - """ - - ROOT_OBJECT_OBJECT_ID = 1 # Request root objects (pipette, deck, etc.) - GLOBAL_OBJECT_ADDRESS = 2 # Request global objects - CPU_OBJECT_ADDRESS = 3 # Request CPU objects +"""Compatibility shim — canonical location is pylabrobot.hamilton.tcp.protocol.""" + +from pylabrobot.hamilton.tcp.protocol import ( # noqa: F401 + HAMILTON_PROTOCOL_VERSION_MAJOR, + HAMILTON_PROTOCOL_VERSION_MINOR, + HamiltonDataType, + HamiltonProtocol, + HarpTransportableProtocol, + Hoi2Action, + HoiRequestId, + RegistrationActionCode, + RegistrationOptionType, +)