From e4ecd9e5a65b05339ae9f358955d6d68ac185152 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 4 Apr 2026 13:36:37 -0700 Subject: [PATCH 1/3] Port Hamilton Nimbus to v1b1 Device/Driver/Backend architecture Decomposes the monolithic legacy NimbusBackend into the three-layer v1b1 architecture matching the STAR port: - Nimbus(Device): user-facing class, wires PIP capability frontend - NimbusDriver(HamiltonTCPHandler): TCP communication, hardware discovery - NimbusPIPBackend(PIPBackend): pipetting operations (pick_up/drop/asp/disp) - NimbusDoor: door control subsystem (follows STARCover pattern) - NimbusChatterboxDriver: simulation driver for testing without hardware Also promotes the shared Hamilton TCP protocol layer from legacy to pylabrobot/hamilton/tcp/ and converts legacy TCP submodules to thin re-export shims (eliminating ~2,470 lines of duplication). The legacy NimbusBackend is now a thin wrapper that delegates all pipetting operations to NimbusPIPBackend and door operations to NimbusDoor, maintaining the exact same API. All 157 legacy tests pass and run through the new v1b1 code (verified via call tracing). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../liquid_handlers/nimbus/__init__.py | 5 + .../liquid_handlers/nimbus/chatterbox.py | 69 + .../liquid_handlers/nimbus/commands.py | 869 +++++++ .../hamilton/liquid_handlers/nimbus/door.py | 56 + .../hamilton/liquid_handlers/nimbus/driver.py | 145 ++ .../hamilton/liquid_handlers/nimbus/nimbus.py | 82 + .../liquid_handlers/nimbus/pip_backend.py | 1055 ++++++++ .../liquid_handlers/nimbus/tests/__init__.py | 0 .../hamilton/liquid_handlers/tcp_base.py | 582 +++++ pylabrobot/hamilton/tcp/__init__.py | 24 + pylabrobot/hamilton/tcp/commands.py | 179 ++ pylabrobot/hamilton/tcp/introspection.py | 832 +++++++ pylabrobot/hamilton/tcp/messages.py | 863 +++++++ pylabrobot/hamilton/tcp/packets.py | 419 ++++ pylabrobot/hamilton/tcp/protocol.py | 178 ++ pylabrobot/hamilton/tcp/tests/__init__.py | 0 .../backends/hamilton/nimbus_backend.py | 2150 ++--------------- .../backends/hamilton/nimbus_backend_tests.py | 20 +- .../backends/hamilton/tcp/__init__.py | 32 +- .../backends/hamilton/tcp/commands.py | 180 +- .../backends/hamilton/tcp/introspection.py | 851 +------ .../backends/hamilton/tcp/messages.py | 873 +------ .../backends/hamilton/tcp/packets.py | 433 +--- .../backends/hamilton/tcp/protocol.py | 191 +- 24 files changed, 5679 insertions(+), 4409 deletions(-) create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/commands.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/door.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/driver.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/tests/__init__.py create mode 100644 pylabrobot/hamilton/liquid_handlers/tcp_base.py create mode 100644 pylabrobot/hamilton/tcp/__init__.py create mode 100644 pylabrobot/hamilton/tcp/commands.py create mode 100644 pylabrobot/hamilton/tcp/introspection.py create mode 100644 pylabrobot/hamilton/tcp/messages.py create mode 100644 pylabrobot/hamilton/tcp/packets.py create mode 100644 pylabrobot/hamilton/tcp/protocol.py create mode 100644 pylabrobot/hamilton/tcp/tests/__init__.py 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..ac3cecff0ea --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py @@ -0,0 +1,82 @@ +"""Nimbus device: wires NimbusDriver backends to PIP capability frontend.""" + +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: str = "", + port: int = 2000, + ): + driver: NimbusDriver = ( + NimbusChatterboxDriver() if chatterbox else 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. + """ + 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 + + 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..2e950a7c15b --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -0,0 +1,1055 @@ +"""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)})" + ) + 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. + """ + 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. + """ + 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). + """ + 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). + """ + 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..4880fc9b166 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/tcp_base.py @@ -0,0 +1,582 @@ +"""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 (usually 50007) + 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 (timeout handled by TCP layer) + response_message = await self._read_one_message() + 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..8220981c8f2 --- /dev/null +++ b/pylabrobot/hamilton/tcp/messages.py @@ -0,0 +1,863 @@ +"""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: 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/hamilton/tcp/packets.py b/pylabrobot/hamilton/tcp/packets.py new file mode 100644 index 00000000000..fb301cfbef6 --- /dev/null +++ b/pylabrobot/hamilton/tcp/packets.py @@ -0,0 +1,419 @@ +"""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) 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..658f1d45d18 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,7 @@ 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 + backend._pip_backend.deck = deck # type: ignore[union-attr] return backend @@ -660,6 +671,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, +) From f969ccd38e58dad6d750a8090238971bb9a1db75 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 4 Apr 2026 13:48:45 -0700 Subject: [PATCH 2/3] Fix review findings: edge cases, typo, version warning, connection leak - Add early return for empty ops in pick_up_tips/drop_tips/aspirate/dispense - Add channel bounds check in _fill_by_channels - Add _ensure_deck() calls in pick_up_tips, drop_tips, _initialize_smart_roll - Fix Nimbus.stop() guard on _setup_finished (safe double-stop) - Fix Nimbus.setup() to clean up driver on partial failure (connection leak) - Fix version_bite typo -> version_byte in packets.py - Add actual logger.warning for protocol version mismatch (was silent pass) - Fix incorrect port docstring in tcp_base.py Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hamilton/liquid_handlers/nimbus/nimbus.py | 16 ++++++++++------ .../liquid_handlers/nimbus/pip_backend.py | 13 +++++++++++++ .../hamilton/liquid_handlers/tcp_base.py | 2 +- pylabrobot/hamilton/tcp/packets.py | 18 +++++++++++++----- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py index ac3cecff0ea..af3d10c61a4 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py @@ -38,12 +38,16 @@ async def setup(self): runs InitializeSmartRoll, and wires the PIP capability frontend to the driver's PIP backend. """ - 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 + 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. diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py index 2e950a7c15b..d0947928a50 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -222,6 +222,11 @@ def _fill_by_channels(self, values: List[T], use_channels: List[int], default: T 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 @@ -402,6 +407,8 @@ async def pick_up_tips( Raises: RuntimeError: If channels already have tips mounted. """ + if not ops: + return self._ensure_deck() params = ( backend_params @@ -487,6 +494,8 @@ async def drop_tips( Raises: ValueError: If operations mix waste and regular resources. """ + if not ops: + return self._ensure_deck() params = ( backend_params @@ -604,6 +613,8 @@ async def aspirate( - tadm_enabled: Enable TADM (Total Aspiration and Dispense Monitoring) (default: False). """ + if not ops: + return params = ( backend_params if isinstance(backend_params, NimbusPIPAspirateParams) @@ -846,6 +857,8 @@ async def dispense( - 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) diff --git a/pylabrobot/hamilton/liquid_handlers/tcp_base.py b/pylabrobot/hamilton/liquid_handlers/tcp_base.py index 4880fc9b166..d53e0c4c68c 100644 --- a/pylabrobot/hamilton/liquid_handlers/tcp_base.py +++ b/pylabrobot/hamilton/liquid_handlers/tcp_base.py @@ -89,7 +89,7 @@ def __init__( Args: host: Hamilton instrument IP address - port: Hamilton instrument port (usually 50007) + port: Hamilton instrument port read_timeout: Read timeout in seconds write_timeout: Write timeout in seconds auto_reconnect: Enable automatic reconnection diff --git a/pylabrobot/hamilton/tcp/packets.py b/pylabrobot/hamilton/tcp/packets.py index fb301cfbef6..42d308f1c48 100644 --- a/pylabrobot/hamilton/tcp/packets.py +++ b/pylabrobot/hamilton/tcp/packets.py @@ -12,11 +12,14 @@ 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 @@ -37,14 +40,14 @@ def encode_version_byte(major: int, minor: int) -> int: return version_byte -def decode_version_byte(version_bite: int) -> tuple[int, int]: +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_bite & 0xF - major = (version_bite >> 4) & 0xF + minor = version_byte & 0xF + major = (version_byte >> 4) & 0xF return (major, minor) @@ -113,8 +116,13 @@ def unpack(cls, data: bytes) -> "IpPacket": # Validate version if major != HAMILTON_PROTOCOL_VERSION_MAJOR or minor != HAMILTON_PROTOCOL_VERSION_MINOR: - # Warning but not fatal - pass + 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"" From 74697f81a5f577265a948fd02e3d13b79e0516b9 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 4 Apr 2026 14:03:08 -0700 Subject: [PATCH 3/3] Address Copilot review: string_array parser, timeout, host validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix string_array parser to honor count prefix (encoder was correct, parser was not reading the u32 count — now they match) - Enforce per-call timeout in send_command via asyncio.wait_for - Validate host is provided when chatterbox=False in Nimbus.__init__ - Replace type: ignore with assert in test helper for type safety Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hamilton/liquid_handlers/nimbus/nimbus.py | 13 ++++++++---- .../hamilton/liquid_handlers/tcp_base.py | 7 +++++-- pylabrobot/hamilton/tcp/messages.py | 20 +++++++------------ .../backends/hamilton/nimbus_backend_tests.py | 3 ++- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py index af3d10c61a4..a32517577bc 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py @@ -1,5 +1,7 @@ """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 @@ -19,12 +21,15 @@ def __init__( self, deck: NimbusDeck, chatterbox: bool = False, - host: str = "", + host: Optional[str] = None, port: int = 2000, ): - driver: NimbusDriver = ( - NimbusChatterboxDriver() if chatterbox else NimbusDriver(host=host, port=port) - ) + 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 diff --git a/pylabrobot/hamilton/liquid_handlers/tcp_base.py b/pylabrobot/hamilton/liquid_handlers/tcp_base.py index d53e0c4c68c..abf0b7f9053 100644 --- a/pylabrobot/hamilton/liquid_handlers/tcp_base.py +++ b/pylabrobot/hamilton/liquid_handlers/tcp_base.py @@ -554,8 +554,11 @@ async def send_command(self, command: HamiltonCommand, timeout: float = 10.0) -> # Send command await self.write(message) - # Read response (timeout handled by TCP layer) - response_message = await self._read_one_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 diff --git a/pylabrobot/hamilton/tcp/messages.py b/pylabrobot/hamilton/tcp/messages.py index 8220981c8f2..75fe0fb974a 100644 --- a/pylabrobot/hamilton/tcp/messages.py +++ b/pylabrobot/hamilton/tcp/messages.py @@ -412,20 +412,14 @@ def _parse_value(self, type_id: int, data: bytes) -> Any: 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 + # String arrays: [count:4][str0\0][str1\0]... + count = Reader(data[:4]).u32() 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")) + 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 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 658f1d45d18..9c80bf2d5cc 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -630,7 +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 - backend._pip_backend.deck = deck # type: ignore[union-attr] + assert backend._pip_backend is not None + backend._pip_backend.deck = deck return backend