diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py index 747d9457b91..dbf60b9b7cb 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py @@ -1,5 +1,25 @@ +from .channels import ChannelType, NimbusChannelConfig, NimbusChannelMap, Rail from .chatterbox import NimbusChatterboxDriver +from .core import NimbusCoreGripper, NimbusCoreGripperFactory, NimbusGripperArm from .door import NimbusDoor -from .driver import NimbusDriver +from .driver import NimbusDriver, NimbusSetupParams +from .info import NimbusInstrumentInfo from .nimbus import Nimbus from .pip_backend import NimbusPIPBackend + +__all__ = [ + "ChannelType", + "NimbusChannelConfig", + "NimbusChannelMap", + "NimbusChatterboxDriver", + "NimbusCoreGripper", + "NimbusCoreGripperFactory", + "NimbusDoor", + "NimbusDriver", + "NimbusGripperArm", + "NimbusInstrumentInfo", + "NimbusPIPBackend", + "NimbusSetupParams", + "Nimbus", + "Rail", +] diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/channels.py b/pylabrobot/hamilton/liquid_handlers/nimbus/channels.py new file mode 100644 index 00000000000..02f22652e90 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/channels.py @@ -0,0 +1,81 @@ +"""Nimbus channel topology — typed wrapper around ChannelConfiguration cmd 30 data.""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import TYPE_CHECKING, List + +if TYPE_CHECKING: + from .info import NimbusInstrumentInfo + + +class ChannelType(enum.IntEnum): + """Mirrors GlobalObjects.ChannelType from NimbusCORE.dll.""" + + NONE = 0 + CHANNEL_300UL = 1 + CHANNEL_1000UL = 2 + CHANNEL_5000UL = 3 + + +class Rail(enum.IntEnum): + """Mirrors GlobalObjects.Rail from NimbusCORE.dll.""" + + LEFT = 0 + RIGHT = 1 + + +_CHANNEL_TYPE_MAX_VOLUME: dict[ChannelType, float] = { + ChannelType.NONE: 0.0, + ChannelType.CHANNEL_300UL: 300.0, + ChannelType.CHANNEL_1000UL: 1000.0, + ChannelType.CHANNEL_5000UL: 5000.0, +} + + +@dataclass(frozen=True) +class NimbusChannelConfig: + """Per-channel hardware configuration decoded from firmware.""" + + type: ChannelType + rail: Rail + previous_neighbor_spacing: int + next_neighbor_spacing: int + can_address: int + + @property + def max_volume(self) -> float: + """Maximum aspirate/dispense volume for this channel type in µL.""" + return _CHANNEL_TYPE_MAX_VOLUME.get(self.type, 0.0) + + +@dataclass(frozen=True) +class NimbusChannelMap: + """Typed per-channel topology built from :class:`NimbusInstrumentInfo`.""" + + channels: List[NimbusChannelConfig] + + @property + def num_channels(self) -> int: + return len(self.channels) + + def channel_type(self, index: int) -> ChannelType: + return self.channels[index].type + + def max_volume_for_channel(self, index: int) -> float: + return self.channels[index].max_volume + + @staticmethod + def from_info(info: "NimbusInstrumentInfo") -> "NimbusChannelMap": + channels = [ + NimbusChannelConfig( + type=ChannelType(wire.channel_type), + rail=Rail(wire.rail), + previous_neighbor_spacing=wire.previous_neighbor_spacing, + next_neighbor_spacing=wire.next_neighbor_spacing, + can_address=wire.can_address, + ) + for wire in info.channel_configurations + ] + return NimbusChannelMap(channels=channels) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py index b1846b15709..3101ab9733b 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py @@ -3,17 +3,28 @@ from __future__ import annotations import logging -from typing import Optional +from typing import Any, Optional -from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.hamilton.tcp.commands import TCPCommand +from pylabrobot.hamilton.tcp.introspection import ObjectInfo from pylabrobot.hamilton.tcp.packets import Address -from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck -from .door import NimbusDoor -from .driver import NimbusDriver +from .commands import NimbusCommand, _UNRESOLVED +from .driver import NimbusDriver, NimbusSetupParams logger = logging.getLogger(__name__) +_CHATTERBOX_NIMBUS_CORE = Address(1, 1, 48896) +_CHATTERBOX_PIPETTE = Address(1, 1, 257) +_CHATTERBOX_DOOR_LOCK = Address(1, 1, 268) + +_CHATTERBOX_PATH_TO_ADDR = { + "NimbusCORE": _CHATTERBOX_NIMBUS_CORE, + "NimbusCORE.Pipette": _CHATTERBOX_PIPETTE, + "NimbusCORE.DoorLock": _CHATTERBOX_DOOR_LOCK, +} + class NimbusChatterboxDriver(NimbusDriver): """Chatterbox driver for Nimbus. Simulates commands for testing without hardware. @@ -22,49 +33,102 @@ class NimbusChatterboxDriver(NimbusDriver): and use canned addresses and responses instead. """ - def __init__(self, deck: NimbusDeck, num_channels: int = 8): - # Pass dummy host — Socket is created but never opened - super().__init__(deck=deck, host="chatterbox", port=2000) + def __init__(self, num_channels: int = 8): + super().__init__(host="chatterbox", port=2000) self._num_channels = num_channels - async def setup(self): - 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=self.deck, address=pipette_address, num_channels=self._num_channels - ) - self.door = NimbusDoor(driver=self, address=door_address) + async def setup(self, backend_params: Optional[BackendParams] = None): + if backend_params is None: + params = NimbusSetupParams() + elif isinstance(backend_params, NimbusSetupParams): + params = backend_params + else: + raise TypeError( + "NimbusChatterboxDriver.setup expected NimbusSetupParams | None for backend_params, " + f"got {type(backend_params).__name__}" + ) + del params + + # Seed introspection registry with canned addresses (skip TCP connection entirely) + self._nimbus_core_address = _CHATTERBOX_NIMBUS_CORE + seed_paths = sorted(NimbusCommand._ALL_PATHS | set(_CHATTERBOX_PATH_TO_ADDR)) + for idx, path in enumerate(seed_paths): + leaf = path.rsplit(".", 1)[-1] + addr = _CHATTERBOX_PATH_TO_ADDR.get(path, Address(1, 1, 1024 + idx)) + self.registry.register( + path, + ObjectInfo(name=leaf, version="", method_count=0, subobject_count=0, address=addr), + ) 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]: + self._nimbus_core_address = None + self._invalidate_introspection_session() + + async def send_command( + self, + command: TCPCommand, + ensure_connection: bool = True, + return_raw: bool = False, + raise_on_error: bool = True, + read_timeout: Optional[float] = None, + ) -> Any: + del ensure_connection, raise_on_error, read_timeout logger.info(f"[Chatterbox] {command.__class__.__name__}") - # Return canned responses for commands that need them + if isinstance(command, NimbusCommand) and command.dest == _UNRESOLVED: + path = type(command).firmware_path + if path is None: + raise RuntimeError( + f"{type(command).__name__} has no firmware_path declared and no " + "explicit dest= supplied at construction. Polymorphic-dest commands " + "must pass dest= to send_query or send_command." + ) + try: + addr = await self.resolve_path(path) + except KeyError as exc: + raise RuntimeError( + f"Cannot send {type(command).__name__}: firmware path " + f"{path!r} did not resolve on this instrument ({exc})." + ) from exc + command.dest = addr + command.dest_address = addr + from .commands import ( + ChannelConfiguration, GetChannelConfiguration, - GetChannelConfiguration_1, + IsCoreGripperPlateGripped, + IsCoreGripperToolHeld, IsDoorLocked, IsInitialized, IsTipPresent, + NimbusChannelConfigWire, ) - if isinstance(command, GetChannelConfiguration_1): - return {"channels": self._num_channels} + if isinstance(command, ChannelConfiguration): + # 8× Channel300uL, alternating Left/Right rails + configs = [ + NimbusChannelConfigWire( + channel_type=1, # Channel300uL + rail=i % 2, # 0=Left, 1=Right alternating + previous_neighbor_spacing=0, + next_neighbor_spacing=0, + can_address=i, + ) + for i in range(self._num_channels) + ] + return ChannelConfiguration.Response(configurations=configs) if isinstance(command, IsInitialized): - return {"initialized": True} + return IsInitialized.Response(initialized=True) if isinstance(command, IsTipPresent): - return {"tip_present": [False] * self._num_channels} + return IsTipPresent.Response(tip_present=[False] * self._num_channels) if isinstance(command, IsDoorLocked): - return {"locked": True} + return IsDoorLocked.Response(locked=True) if isinstance(command, GetChannelConfiguration): - return {"enabled": [False]} + return GetChannelConfiguration.Response(enabled=[False]) + if isinstance(command, IsCoreGripperToolHeld): + return IsCoreGripperToolHeld.Response(gripped=False, tip_type=[]) + if isinstance(command, IsCoreGripperPlateGripped): + return IsCoreGripperPlateGripped.Response(gripped=False) + if return_raw: + return (b"",) return None diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py index 33273fa73ce..812c3e4d24e 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py @@ -8,17 +8,68 @@ import enum import logging -from typing import List +from dataclasses import dataclass, field +from typing import Annotated, ClassVar, List, Optional, Set + +from pylabrobot.hamilton.tcp.commands import TCPCommand -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.hamilton.tcp.wire_types import ( + I32, + U8, + U16, + Bool, + BoolArray, + Enum, + I16Array, + I32Array, + Struct, + StructArray, + U16Array, + U32Array, +) from pylabrobot.resources import Tip from pylabrobot.resources.hamilton import HamiltonTip, TipSize logger = logging.getLogger(__name__) +_UNRESOLVED = Address(-1, -1, -1) + + +@dataclass +class NimbusCommand(TCPCommand): + """Base for all Nimbus instrument commands. + + Subclasses are dataclasses with optional ``Annotated`` payload fields. + ``dest`` is inherited here as kw_only so concrete subclasses can freely + declare required positional wire fields without default-ordering conflicts. + ``build_parameters()`` is inherited from ``TCPCommand`` and serialises all + ``Annotated`` fields via ``HoiParams.from_struct(self)`` automatically. + + Firmware target is declared via class-level ``firmware_path`` and resolved + JIT in :meth:`NimbusDriver._send_raw` when ``dest`` is the unresolved + sentinel. Set ``firmware_path = None`` for polymorphic commands that require + explicit ``dest=`` at construction. + """ + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + + firmware_path: ClassVar[Optional[str]] = None + _ALL_PATHS: ClassVar[Set[str]] = set() + + dest: Address = field(default=_UNRESOLVED, kw_only=True) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + path = cls.__dict__.get("firmware_path") + if path is not None: + NimbusCommand._ALL_PATHS.add(path) + + def __post_init__(self): + super().__init__(self.dest) + # ============================================================================ # TIP TYPE ENUM @@ -112,758 +163,727 @@ def _get_default_flow_rate(tip: Tip, is_aspirate: bool) -> float: # ============================================================================ -class LockDoor(HamiltonCommand): +@dataclass +class LockDoor(NimbusCommand): """Lock door command (DoorLock at 1:1:268, interface_id=1, command_id=1).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 1 + firmware_path = "NimbusCORE.DoorLock" -class UnlockDoor(HamiltonCommand): +@dataclass +class UnlockDoor(NimbusCommand): """Unlock door command (DoorLock at 1:1:268, interface_id=1, command_id=2).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 2 + firmware_path = "NimbusCORE.DoorLock" -class IsDoorLocked(HamiltonCommand): +@dataclass +class IsDoorLocked(NimbusCommand): """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) + firmware_path = "NimbusCORE.DoorLock" + action_code = 0 - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsDoorLocked response.""" - parser = HoiParamsParser(data) - _, locked = parser.parse_next() - return {"locked": bool(locked)} + @dataclass + class Response: + locked: Bool -class PreInitializeSmart(HamiltonCommand): +@dataclass +class PreInitializeSmart(NimbusCommand): """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 + firmware_path = "NimbusCORE.Pipette" -class InitializeSmartRoll(HamiltonCommand): - """Initialize smart roll command (NimbusCore at 1:1:48896, interface_id=1, command_id=29).""" +@dataclass +class InitializeSmartRoll(NimbusCommand): + """Initialize smart roll command (NimbusCore, cmd=29). + + Units: + - positions/distances: 0.01 mm + """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 29 + firmware_path = "NimbusCORE" - 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) - ) + x_positions: I32Array + y_positions: I32Array + begin_tip_deposit_process: I32Array + end_tip_deposit_process: I32Array + z_position_at_end_of_a_command: I32Array + roll_distances: I32Array -class IsInitialized(HamiltonCommand): +@dataclass +class IsInitialized(NimbusCommand): """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) + firmware_path = "NimbusCORE" + action_code = 0 - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsInitialized response.""" - parser = HoiParamsParser(data) - _, initialized = parser.parse_next() - return {"initialized": bool(initialized)} + @dataclass + class Response: + initialized: Bool -class IsTipPresent(HamiltonCommand): +@dataclass +class IsTipPresent(NimbusCommand): """Check tip presence (Pipette at 1:1:257, interface_id=1, command_id=16).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 16 + firmware_path = "NimbusCORE.Pipette" 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} + @dataclass + class Response: + tip_present: I16Array -class GetChannelConfiguration_1(HamiltonCommand): - """Get channel configuration (NimbusCore root, interface_id=1, command_id=15).""" +@dataclass +class NimbusChannelConfigWire: + """Wire-format struct for one entry in the ChannelConfiguration[] array (cmd 30). - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 - command_id = 15 + Members (in wire order, from GlobalObjects.ConvertProtocolStructToChannelConfigurationStruct): + channel_type — Enum 0=None 1=300uL 2=1000uL 3=5000uL + rail — Enum 0=Left 1=Right + previous_neighbor_spacing — U16 + next_neighbor_spacing — U16 + can_address — U8 + """ + + channel_type: Enum + rail: Enum + previous_neighbor_spacing: U16 + next_neighbor_spacing: U16 + can_address: U8 + + +@dataclass +class ChannelConfiguration(NimbusCommand): + """Channel configuration (NimbusCORE root, interface_id=1, command_id=30). + + Replaces the obsolete GetChannelConfiguration (cmd 15). Returns one entry + per physical channel with type, rail, spacing, and CAN address. + """ + + command_id = 30 + firmware_path = "NimbusCORE" action_code = 0 - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetChannelConfiguration_1 response. + @dataclass + class Response: + configurations: Annotated[List[NimbusChannelConfigWire], StructArray()] - 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} +@dataclass +class SetChannelConfiguration(NimbusCommand): + """Set channel configuration (Pipette, cmd=67). -class SetChannelConfiguration(HamiltonCommand): - """Set channel configuration (Pipette at 1:1:257, interface_id=1, command_id=67).""" + Field meanings: + - `channel`: 1-based physical channel index. + - `indexes`: firmware config slots (e.g. tip recognition / LLD monitors). + - `enables`: booleans matching `indexes` order. + """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 67 + firmware_path = "NimbusCORE.Pipette" - 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).""" + channel: U16 + indexes: I16Array + enables: BoolArray + + +@dataclass +class GetChannelConfiguration(NimbusCommand): + """Get channel configuration command (Pipette, cmd=66). + + Field meanings: + - `channel`: 1-based physical channel index. + - `indexes`: firmware config slots to query. + """ - 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): + firmware_path = "NimbusCORE.Pipette" + action_code = 0 + + channel: U16 + indexes: I16Array + + @dataclass + class Response: + enabled: BoolArray + + +@dataclass +class PickupGripperTool(NimbusCommand): + """Pick up CoRe gripper tool (Pipette, cmd=9). + + Units: + - positions/heights/toolWidth: 0.01 mm + """ + + command_id = 9 + firmware_path = "NimbusCORE.Pipette" + + x_position: I32 + y_position_1st_channel: I32 + y_position_2nd_channel: I32 + traverse_height: I32 + z_start_position: I32 + z_stop_position: I32 + tip_type: U16 + first_channel_number: U16 + second_channel_number: U16 + tool_width: I32 + + +@dataclass +class DropGripperTool(NimbusCommand): + """Drop CoRe gripper tool (Pipette, cmd=10). + + Units: + - positions/heights: 0.01 mm + """ + + command_id = 10 + firmware_path = "NimbusCORE.Pipette" + + x_position: I32 + y_position_1st_channel: I32 + y_position_2nd_channel: I32 + traverse_height: I32 + z_start_position: I32 + z_stop_position: I32 + z_final: I32 + first_channel_number: U16 + second_channel_number: U16 + + +@dataclass +class PickupPlate(NimbusCommand): + """Pick up plate with CoRe gripper (Pipette, cmd=11). + + Units: + - positions/heights: 0.01 mm + - yPlateWidth, yGripStrength: 0.01 mm (U32) + - yGripSpeed, zSpeed: 0.01 mm/s (U32) + """ + + command_id = 11 + firmware_path = "NimbusCORE.Pipette" + + x_position: I32 + y_plate_center_position: I32 + y_plate_width: U32 + y_open_position: I32 + y_grip_speed: U32 + y_grip_strength: U32 + traverse_height: I32 + z_grip_height: I32 + z_final: I32 + z_speed: U32 + + +@dataclass +class DropPlate(NimbusCommand): + """Drop plate with CoRe gripper (Pipette, cmd=12). + + Units: + - positions/heights: 0.01 mm + - xAcceleration: scale 1–100 (U32) + - zSpeed: 0.01 mm/s (U32) + """ + + command_id = 12 + firmware_path = "NimbusCORE.Pipette" + + x_position: I32 + x_acceleration: U32 + y_plate_center_position: I32 + y_open_position: I32 + traverse_height: I32 + z_drop_height: I32 + z_press_distance: I32 + z_final: I32 + z_speed: U32 + + +@dataclass +class MovePlate(NimbusCommand): + """Move plate with CoRe gripper (Pipette, cmd=13). + + Units: + - positions/heights: 0.01 mm + - xAcceleration: scale 1–100 (U32) + - zSpeed: 0.01 mm/s (U32) + """ + + command_id = 13 + firmware_path = "NimbusCORE.Pipette" + + x_position: I32 + x_acceleration: U32 + y_plate_center_position: I32 + traverse_height: I32 + z_final: I32 + z_speed: U32 + + +@dataclass +class ReleasePlate(NimbusCommand): + """Release plate (open CoRe gripper) (Pipette, cmd=14).""" + + command_id = 14 + firmware_path = "NimbusCORE.Pipette" + + first_channel_number: U16 + second_channel_number: U16 + + +@dataclass +class IsCoreGripperToolHeld(NimbusCommand): + """Check if CoRe gripper tool is held (Pipette, cmd=17).""" + + command_id = 17 + firmware_path = "NimbusCORE.Pipette" + action_code = 0 + + @dataclass + class Response: + gripped: Bool + tip_type: U16Array + + +@dataclass +class IsCoreGripperPlateGripped(NimbusCommand): + """Check if CoRe gripper plate is gripped (Pipette, cmd=18).""" + + command_id = 18 + firmware_path = "NimbusCORE.Pipette" + action_code = 0 + + @dataclass + class Response: + gripped: Bool + + +@dataclass +class GetPosition(NimbusCommand): + """Query current pipette position (Pipette, cmd=20). + + Units: + - x_position: 0.01 mm + - y_position: 0.01 mm per channel + - z_position: 0.01 mm per channel + """ + + command_id = 20 + firmware_path = "NimbusCORE.Pipette" + action_code = 0 + + @dataclass + class Response: + x_position: I32 + y_position: I32Array + z_position: I32Array + + +@dataclass +class ParkPipette(NimbusCommand): + """Park the pipette head (Pipette, cmd=21).""" + + command_id = 21 + firmware_path = "NimbusCORE.Pipette" + + +@dataclass +class MoveOver(NimbusCommand): + """Move to position above a location, traversing at traverse_height (Pipette, cmd=22). + + Units: + - positions/heights: 0.01 mm + """ + + command_id = 22 + firmware_path = "NimbusCORE.Pipette" + + tips_used: U16Array + x_position: I32 + y_position: I32Array + traverse_height: I32 + z_position: I32Array + + +@dataclass +class MoveToPosition(NimbusCommand): + """Move to absolute XYZ position (Pipette, cmd=23). + + Units: + - positions: 0.01 mm + """ + + command_id = 23 + firmware_path = "NimbusCORE.Pipette" + + tips_used: U16Array + x_position: I32 + y_position: I32Array + z_position: I32Array + + +@dataclass +class MoveToPositionViaLane(NimbusCommand): + """Move to XY position via lane (traverse then lower) (Pipette, cmd=24). + + Units: + - positions/heights: 0.01 mm + """ + + command_id = 24 + firmware_path = "NimbusCORE.Pipette" + + tips_used: U16Array + x_position: I32 + y_position: I32Array + traverse_height: I32 + + +@dataclass +class MoveAbsoluteXY(NimbusCommand): + """Move to absolute XY position at traverse height (Pipette, cmd=25). + + Units: + - positions: 0.01 mm + """ + + command_id = 25 + firmware_path = "NimbusCORE.Pipette" + + tips_used: U16Array + x_position: I32 + y_position: I32Array + + +@dataclass +class MoveAbsoluteX(NimbusCommand): + """Move X axis to absolute position (Pipette, cmd=26). + + Units: + - x_position: 0.01 mm + """ + + command_id = 26 + firmware_path = "NimbusCORE.Pipette" + + x_position: I32 + + +@dataclass +class MoveRelativeX(NimbusCommand): + """Move X axis by relative distance (Pipette, cmd=27). + + Units: + - x_distance: 0.01 mm + """ + + command_id = 27 + firmware_path = "NimbusCORE.Pipette" + + x_distance: I32 + + +@dataclass +class MoveAbsoluteY(NimbusCommand): + """Move channels to absolute Y positions — the channel spread mechanism (Pipette, cmd=28). + + Units: + - y_position: 0.01 mm per channel + """ + + command_id = 28 + firmware_path = "NimbusCORE.Pipette" + + tips_used: U16Array + y_position: I32Array + + +@dataclass +class MoveRelativeY(NimbusCommand): + """Move channels by relative Y distances (Pipette, cmd=29). + + Units: + - y_distance: 0.01 mm per channel + """ + + command_id = 29 + firmware_path = "NimbusCORE.Pipette" + + tips_used: U16Array + y_distance: I32Array + + +@dataclass +class MoveAbsoluteZ(NimbusCommand): + """Move channels to absolute Z positions (Pipette, cmd=30). + + Units: + - z_position: 0.01 mm per channel + """ + + command_id = 30 + firmware_path = "NimbusCORE.Pipette" + + tips_used: U16Array + z_position: I32Array + + +@dataclass +class MoveRelativeZ(NimbusCommand): + """Move channels by relative Z distances (Pipette, cmd=31). + + Units: + - z_distance: 0.01 mm per channel + """ + + command_id = 31 + firmware_path = "NimbusCORE.Pipette" + + tips_used: U16Array + z_distance: I32Array + + +@dataclass +class Park(NimbusCommand): """Park command (NimbusCore at 1:1:48896, interface_id=1, command_id=3).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 3 + firmware_path = "NimbusCORE" -class PickupTips(HamiltonCommand): - """Pick up tips command (Pipette at 1:1:257, interface_id=1, command_id=4).""" +@dataclass +class PickupTips(NimbusCommand): + """Pick up tips command (Pipette, cmd=4). + + Units: + - positions/heights: 0.01 mm + + Field meanings: + - `channels_involved`: 1=active, 0=inactive. + - `tip_types`: per-channel Nimbus tip type IDs. + """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 4 + firmware_path = "NimbusCORE.Pipette" - 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 + channels_involved: U16Array + x_positions: I32Array + y_positions: I32Array + minimum_traverse_height_at_beginning_of_a_command: I32 + begin_tip_pick_up_process: I32Array + end_tip_pick_up_process: I32Array + tip_types: U16Array - 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) - ) +@dataclass +class DropTips(NimbusCommand): + """Drop tips command (Pipette, cmd=5). -class DropTips(HamiltonCommand): - """Drop tips command (Pipette at 1:1:257, interface_id=1, command_id=5).""" + Units: + - positions/heights: 0.01 mm + + Field meanings: + - `channels_involved`: 1=active, 0=inactive. + - `default_waste`: when true, firmware default waste position is used. + """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 5 + firmware_path = "NimbusCORE.Pipette" - 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 + channels_involved: U16Array + x_positions: I32Array + y_positions: I32Array + minimum_traverse_height_at_beginning_of_a_command: I32 + begin_tip_deposit_process: I32Array + end_tip_deposit_process: I32Array + z_position_at_end_of_a_command: I32Array + default_waste: Bool - 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) - ) +@dataclass +class DropTipsRoll(NimbusCommand): + """Drop tips with roll command (Pipette, cmd=82). -class DropTipsRoll(HamiltonCommand): - """Drop tips with roll command (Pipette at 1:1:257, interface_id=1, command_id=82).""" + Units: + - positions/heights/distances: 0.01 mm + + Field meanings: + - `channels_involved`: 1=active, 0=inactive. + """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 82 + firmware_path = "NimbusCORE.Pipette" - 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 + channels_involved: U16Array + x_positions: I32Array + y_positions: I32Array + minimum_traverse_height_at_beginning_of_a_command: I32 + begin_tip_deposit_process: I32Array + end_tip_deposit_process: I32Array + z_position_at_end_of_a_command: I32Array + roll_distances: I32Array - 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) - ) +@dataclass +class EnableADC(NimbusCommand): + """Enable ADC command (Pipette, cmd=43). -class EnableADC(HamiltonCommand): - """Enable ADC command (Pipette at 1:1:257, interface_id=1, command_id=43).""" + Field meanings: + - `channels_involved`: 1=active, 0=inactive. + """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 43 + firmware_path = "NimbusCORE.Pipette" - def __init__( - self, - dest: Address, - channels_involved: List[int], - ): - """Initialize EnableADC command. + channels_involved: U16Array - 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) +@dataclass +class DisableADC(NimbusCommand): + """Disable ADC command (Pipette, cmd=44). + Field meanings: + - `channels_involved`: 1=active, 0=inactive. + """ -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 + firmware_path = "NimbusCORE.Pipette" - def __init__( - self, - dest: Address, - channels_involved: List[int], - ): - """Initialize DisableADC command. + channels_involved: U16Array - 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) +@dataclass +class Aspirate(NimbusCommand): + """Aspirate command (Pipette, cmd=6). + Units: + - linear positions/heights: 0.01 mm + - volumes: 0.1 uL + - aspiration/dispense/mix flow parameters: 0.1 uL/s (piston motion) + - swap_speed: 0.01 mm/s per wire unit (leave-liquid Z speed — not uL/s) + - settling time: 0.1 s -class Aspirate(HamiltonCommand): - """Aspirate command (Pipette at 1:1:257, interface_id=1, command_id=6).""" + Field meanings: + - `channels_involved`: 1=active, 0=inactive. + - `aspirate_type`: firmware aspirate mode per channel. + - `lld_mode`: 0=off, 1=cLLD, 2=pLLD, 3=dual. + - `tadm_enabled`: enable Total Aspiration/Dispense Monitoring. + """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 6 + firmware_path = "NimbusCORE.Pipette" + + # Channel selectors/modes. + aspirate_type: I16Array + channels_involved: U16Array + # Motion and level tracking. + x_positions: I32Array + y_positions: I32Array + minimum_traverse_height_at_beginning_of_a_command: I32 + lld_search_height: I32Array + liquid_height: I32Array + immersion_depth: I32Array + surface_following_distance: I32Array + minimum_height: I32Array + clot_detection_height: I32Array + min_z_endpos: I32 + # Volumetric profile. + swap_speed: U32Array + blow_out_air_volume: U32Array + pre_wetting_volume: U32Array + aspirate_volume: U32Array + transport_air_volume: U32Array + aspiration_speed: U32Array + settling_time: U32Array + # Mixing profile. + mix_volume: U32Array + mix_cycles: U32Array + mix_position_from_liquid_surface: I32Array + mix_surface_following_distance: I32Array + mix_speed: U32Array + # Advanced monitoring/firmware controls. + tube_section_height: I32Array + tube_section_ratio: I32Array + lld_mode: I16Array + gamma_lld_sensitivity: I16Array + dp_lld_sensitivity: I16Array + lld_height_difference: I32Array + tadm_enabled: Bool + limit_curve_index: U32Array + recording_mode: U16 + + +@dataclass +class Dispense(NimbusCommand): + """Dispense command (Pipette, cmd=7). + + Units: + - linear positions/heights: 0.01 mm + - volumes: 0.1 uL + - dispense/mix/cut-off flow parameters: 0.1 uL/s where applicable + - swap_speed: 0.01 mm/s per wire unit (leave-liquid Z speed — not uL/s) + - settling time: 0.1 s + + Field meanings: + - `channels_involved`: 1=active, 0=inactive. + - `dispense_type`: firmware dispense mode per channel. + - `lld_mode`: 0=off, 1=cLLD, 2=pLLD, 3=dual. + - `tadm_enabled`: enable Total Aspiration/Dispense Monitoring. + """ - 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) - ) + firmware_path = "NimbusCORE.Pipette" + + # Channel selectors/modes. + dispense_type: I16Array + channels_involved: U16Array + # Motion and level tracking. + x_positions: I32Array + y_positions: I32Array + minimum_traverse_height_at_beginning_of_a_command: I32 + lld_search_height: I32Array + liquid_height: I32Array + immersion_depth: I32Array + surface_following_distance: I32Array + minimum_height: I32Array + min_z_endpos: I32 + # Volumetric profile. + swap_speed: U32Array + transport_air_volume: U32Array + dispense_volume: U32Array + stop_back_volume: U32Array + blow_out_air_volume: U32Array + dispense_speed: U32Array + cut_off_speed: U32Array + settling_time: U32Array + # Mixing profile. + mix_volume: U32Array + mix_cycles: U32Array + mix_position_from_liquid_surface: I32Array + mix_surface_following_distance: I32Array + mix_speed: U32Array + # Dispense-specific offsets and advanced controls. + side_touch_off_distance: I32 + dispense_offset: I32Array + tube_section_height: I32Array + tube_section_ratio: I32Array + lld_mode: I16Array + gamma_lld_sensitivity: I16Array + tadm_enabled: Bool + limit_curve_index: U32Array + recording_mode: U16 diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/core.py b/pylabrobot/hamilton/liquid_handlers/nimbus/core.py new file mode 100644 index 00000000000..b4595ae775e --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/core.py @@ -0,0 +1,322 @@ +"""Hamilton Nimbus CoRe gripper backend and NimbusGripperArm frontend. + +CoRe gripper commands live on ``NimbusCORE.Pipette`` (cmd 9-14 and 17-18). +Units: positions/widths in 0.01mm (INT32/UINT32 wire), speeds in 0.01mm/s (UINT32), +xAcceleration is a scale factor 1-100 (UINT32). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +from pylabrobot.capabilities.arms.arm import GripperArm +from pylabrobot.capabilities.arms.backend import GripperArmBackend +from pylabrobot.capabilities.arms.standard import GripperLocation +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate, Resource + +from .commands import ( + DropGripperTool, + DropPlate, + IsCoreGripperPlateGripped, + IsCoreGripperToolHeld, + MovePlate, + PickupGripperTool, + PickupPlate, + ReleasePlate, +) + +if TYPE_CHECKING: + from .driver import NimbusDriver + from .pip_backend import NimbusPIPBackend + + +def _mm(v: float) -> int: + """Convert mm to 0.01mm wire units.""" + return round(v * 100) + + +def _mms(v: float) -> int: + """Convert mm/s to 0.01mm/s wire units.""" + return round(v * 100) + + +class NimbusCoreGripperFactory: + """Lightweight factory: :class:`Nimbus` constructs one at setup and calls + :meth:`build_backend` when tools are picked up.""" + + def __init__(self, driver: "NimbusDriver") -> None: + self._driver = driver + + def build_backend(self, pip: "NimbusPIPBackend") -> "NimbusCoreGripper": + return NimbusCoreGripper(driver=self._driver, pip=pip) + + +class NimbusCoreGripper(GripperArmBackend): + """CoRe gripper backend for Nimbus. + + Translates the v1 GripperArmBackend interface to NimbusCORE.Pipette firmware + commands (PickupPlate/DropPlate/MovePlate/ReleasePlate). + + Tool management (``pick_up_tool`` / ``drop_tool``) is handled by the + :meth:`Nimbus.core_grippers` context manager, not the GripperArmBackend interface. + """ + + @dataclass + class PickUpParams(BackendParams): + """Firmware parameters for plate pickup. + + Auto-populated from resource geometry by :class:`NimbusGripperArm.pick_up_resource`. + """ + + y_plate_width: float = 85.48 + y_open_position: float = 100.0 + y_grip_speed: float = 5.0 + y_grip_strength: float = 0.5 + z_grip_height: float = 0.0 + z_final: float = 146.0 + z_speed: float = 50.0 + + @dataclass + class DropParams(BackendParams): + """Firmware parameters for plate drop.""" + + y_open_position: float = 100.0 + x_acceleration: int = 10 + z_drop_height: float = 0.0 + z_press_distance: float = 0.0 + z_final: float = 146.0 + z_speed: float = 50.0 + + @dataclass + class MoveToLocationParams(BackendParams): + """Firmware parameters for moving a held plate.""" + + x_acceleration: int = 10 + z_final: float = 146.0 + z_speed: float = 50.0 + + @dataclass + class PickUpToolParams(BackendParams): + """Firmware parameters for picking up the CoRe gripper tool.""" + + traverse_height: float = 146.0 + z_start_offset: float = 10.0 + z_stop_position: float = 0.0 + tip_type: int = 0 + tool_width: float = 9.0 + + @dataclass + class DropToolParams(BackendParams): + """Firmware parameters for dropping the CoRe gripper tool.""" + + traverse_height: float = 146.0 + z_final: float = 146.0 + + def __init__(self, *, driver: "NimbusDriver", pip: "NimbusPIPBackend") -> None: + self._driver = driver + self._pip = pip + + @property + def client(self): + return self._driver + + # -- GripperArmBackend interface ----------------------------------------------- + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, NimbusCoreGripper.PickUpParams): + backend_params = NimbusCoreGripper.PickUpParams() + p = backend_params + await self._driver.send_command( + PickupPlate( + x_position=_mm(location.x), + y_plate_center_position=_mm(location.y), + y_plate_width=_mm(resource_width), + y_open_position=_mm(p.y_open_position), + y_grip_speed=_mms(p.y_grip_speed), + y_grip_strength=_mm(p.y_grip_strength), + traverse_height=_mm(p.z_final), + z_grip_height=_mm(location.z), + z_final=_mm(p.z_final), + z_speed=_mms(p.z_speed), + ) + ) + + async def drop_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, NimbusCoreGripper.DropParams): + backend_params = NimbusCoreGripper.DropParams() + p = backend_params + await self._driver.send_command( + DropPlate( + x_position=_mm(location.x), + x_acceleration=p.x_acceleration, + y_plate_center_position=_mm(location.y), + y_open_position=_mm(p.y_open_position), + traverse_height=_mm(p.z_final), + z_drop_height=_mm(location.z), + z_press_distance=_mm(p.z_press_distance), + z_final=_mm(p.z_final), + z_speed=_mms(p.z_speed), + ) + ) + + async def move_to_location( + self, + location: Coordinate, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, NimbusCoreGripper.MoveToLocationParams): + backend_params = NimbusCoreGripper.MoveToLocationParams() + p = backend_params + await self._driver.send_command( + MovePlate( + x_position=_mm(location.x), + x_acceleration=p.x_acceleration, + y_plate_center_position=_mm(location.y), + traverse_height=_mm(p.z_final), + z_final=_mm(location.z), + z_speed=_mms(p.z_speed), + ) + ) + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Release plate / open CoRe gripper (ReleasePlate, cmd=14).""" + num_ch = self._pip.num_channels + await self._driver.send_command( + ReleasePlate( + first_channel_number=1, + second_channel_number=num_ch, + ) + ) + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + raise NotImplementedError("Use pick_up_at_location instead.") + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + raise NotImplementedError("NimbusCoreGripper does not support is_gripper_closed") + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + raise NotImplementedError("NimbusCoreGripper does not support halt") + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + raise NotImplementedError( + "Tool management is handled by Nimbus.core_grippers() context manager." + ) + + async def request_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> GripperLocation: + raise NotImplementedError("NimbusCoreGripper does not support request_gripper_location") + + # -- Status queries ------------------------------------------------------------ + + async def is_tool_held(self) -> tuple[bool, list[int]]: + """Query whether a CoRe gripper tool is held (IsCoreGripperToolHeld, cmd=17).""" + resp = await self._driver.send_command(IsCoreGripperToolHeld()) + assert resp is not None + return bool(resp.gripped), list(resp.tip_type) + + async def is_plate_gripped(self) -> bool: + """Query whether a plate is currently gripped (IsCoreGripperPlateGripped, cmd=18).""" + resp = await self._driver.send_command(IsCoreGripperPlateGripped()) + assert resp is not None + return bool(resp.gripped) + + # -- Tool management (used by Nimbus.core_grippers context manager) ------------ + + async def pick_up_tool( + self, + x: float, + y_ch1: float, + y_ch2: float, + *, + channel1: int = 1, + channel2: int = 8, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up CoRe gripper tool (PickupGripperTool, cmd=9).""" + if not isinstance(backend_params, NimbusCoreGripper.PickUpToolParams): + backend_params = NimbusCoreGripper.PickUpToolParams() + p = backend_params + await self._driver.send_command( + PickupGripperTool( + x_position=_mm(x), + y_position_1st_channel=_mm(y_ch1), + y_position_2nd_channel=_mm(y_ch2), + traverse_height=_mm(p.traverse_height), + z_start_position=_mm(p.traverse_height - p.z_start_offset), + z_stop_position=_mm(p.z_stop_position), + tip_type=p.tip_type, + first_channel_number=channel1, + second_channel_number=channel2, + tool_width=_mm(p.tool_width), + ) + ) + + async def drop_tool( + self, + x: float, + y_ch1: float, + y_ch2: float, + *, + channel1: int = 1, + channel2: int = 8, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop CoRe gripper tool (DropGripperTool, cmd=10).""" + if not isinstance(backend_params, NimbusCoreGripper.DropToolParams): + backend_params = NimbusCoreGripper.DropToolParams() + p = backend_params + await self._driver.send_command( + DropGripperTool( + x_position=_mm(x), + y_position_1st_channel=_mm(y_ch1), + y_position_2nd_channel=_mm(y_ch2), + traverse_height=_mm(p.traverse_height), + z_start_position=_mm(p.traverse_height), + z_stop_position=_mm(0.0), + z_final=_mm(p.z_final), + first_channel_number=channel1, + second_channel_number=channel2, + ) + ) + + +class NimbusGripperArm(GripperArm): + """GripperArm that auto-populates Nimbus firmware geometry from the target resource. + + When ``pick_up_resource()`` is called, the plate width (Y-axis for Nimbus) is + extracted from the :class:`Resource` automatically. + """ + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_bottom: Optional[float] = None, + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, NimbusCoreGripper.PickUpParams): + backend_params = NimbusCoreGripper.PickUpParams() + + backend_params.y_plate_width = resource.get_absolute_size_y() + + pdfb = self._resolve_pickup_distance(resource, pickup_distance_from_bottom) + backend_params.z_grip_height = pdfb + + await super().pick_up_resource(resource, offset, pickup_distance_from_bottom, backend_params) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/door.py b/pylabrobot/hamilton/liquid_handlers/nimbus/door.py index 363bb93b6ca..c00f696c6b7 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/door.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/door.py @@ -5,8 +5,6 @@ import logging from typing import TYPE_CHECKING -from pylabrobot.hamilton.tcp.packets import Address - from .commands import IsDoorLocked, LockDoor, UnlockDoor if TYPE_CHECKING: @@ -22,9 +20,8 @@ class NimbusDoor: Owned by NimbusDriver, exposed via convenience methods on the Nimbus device. """ - def __init__(self, driver: "NimbusDriver", address: Address): + def __init__(self, driver: "NimbusDriver"): self.driver = driver - self.address = address async def _on_setup(self): """Lock door on setup if available.""" @@ -41,16 +38,16 @@ async def _on_stop(self): async def is_locked(self) -> bool: """Check if the door is locked.""" - status = await self.driver.send_command(IsDoorLocked(self.address)) + status = await self.driver.send_command(IsDoorLocked()) assert status is not None, "IsDoorLocked command returned None" - return bool(status["locked"]) + return bool(status.locked) async def lock(self) -> None: """Lock the door.""" - await self.driver.send_command(LockDoor(self.address)) + await self.driver.send_command(LockDoor()) logger.info("Door locked successfully") async def unlock(self) -> None: """Unlock the door.""" - await self.driver.send_command(UnlockDoor(self.address)) + await self.driver.send_command(UnlockDoor()) logger.info("Door unlocked successfully") diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py index 28916fc2e3d..c7494428ba2 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -1,41 +1,60 @@ -"""NimbusDriver: TCP-based driver for Hamilton Nimbus liquid handlers.""" +"""NimbusDriver: TCP-based transport driver for Hamilton Nimbus liquid handlers. + +Transport-only: opens TCP, discovers the firmware root, and resolves one +bootstrap handle — :attr:`NimbusDriver.nimbus_core_address` (``NimbusCORE``). +Everything else uses :meth:`HamiltonTCPClient.resolve_path`, which consults the +introspection registry (cache-hot after the first hit). + +**JIT command targets.** Concrete :class:`NimbusCommand` subclasses declare +``firmware_path``; :meth:`NimbusDriver._send_raw` resolves that path when +``dest`` is the unresolved sentinel. +""" from __future__ import annotations import logging -from typing import Dict, Optional +from dataclasses import dataclass +from typing import Any, Optional -from pylabrobot.hamilton.liquid_handlers.tcp_base import HamiltonTCPHandler -from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.hamilton.tcp.client import HamiltonTCPClient +from pylabrobot.hamilton.tcp.commands import TCPCommand +from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES 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 +from .commands import NimbusCommand, _UNRESOLVED logger = logging.getLogger(__name__) +_EXPECTED_ROOT = "NimbusCORE" + + +@dataclass +class NimbusSetupParams(BackendParams): + deck: Optional[NimbusDeck] = None + require_door_lock: bool = False + force_initialize: bool = False -class NimbusDriver(HamiltonTCPHandler): + +class NimbusDriver(HamiltonTCPClient): """Driver for Hamilton Nimbus liquid handlers. - Handles TCP communication, hardware discovery via introspection, and - manages the PIP backend and door subsystem. + Handles TCP communication and hardware root discovery. All orchestration + (backend construction, peer creation, initialization) lives in :class:`Nimbus`. """ + _ERROR_CODES = NIMBUS_ERROR_CODES + def __init__( self, - deck: NimbusDeck, host: str, port: int = 2000, - read_timeout: float = 30.0, + read_timeout: float = 300.0, write_timeout: float = 30.0, auto_reconnect: bool = True, max_reconnect_attempts: int = 3, + connection_timeout: int = 600, ): super().__init__( host=host, @@ -44,100 +63,81 @@ def __init__( write_timeout=write_timeout, auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, + connection_timeout=connection_timeout, ) - - self.deck = deck 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.") + raise RuntimeError("Nimbus root address not discovered. Call setup() first.") return self._nimbus_core_address - async def setup(self): - """Initialize connection, discover hardware, and create backends.""" - assert self.deck is not None, "NimbusDriver requires a deck before setup()" - # 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") + async def setup(self, backend_params: Optional[BackendParams] = None): + """Open TCP connection, verify firmware root is NimbusCORE, resolve bootstrap handle.""" + if backend_params is None: + params = NimbusSetupParams() + elif isinstance(backend_params, NimbusSetupParams): + params = backend_params + else: + raise TypeError( + "NimbusDriver.setup expected NimbusSetupParams | None for backend_params, " + f"got {type(backend_params).__name__}" + ) + del params # consumed by Nimbus / peers, not the transport - # Create backends — each object stores its own address and state - self.pip = NimbusPIPBackend( - driver=self, deck=self.deck, address=pipette_address, num_channels=num_channels - ) + await super().setup() - if door_address is not None: - self.door = NimbusDoor(driver=self, address=door_address) + root = await self._discovered_root_name() + if root != _EXPECTED_ROOT: + raise RuntimeError( + f"Expected root '{_EXPECTED_ROOT}' (Nimbus), but discovered '{root}'. Wrong instrument?" + ) - # Initialize subsystems - if self.door is not None: - await self.door._on_setup() + self._nimbus_core_address = await self.resolve_path("NimbusCORE") - async def stop(self): - """Stop driver and close connection.""" - if self.door is not None: - await self.door._on_stop() + async def stop(self) -> None: + """Close connection and clear cached addresses.""" 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) + self._nimbus_core_address = None - 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}") + async def _discovered_root_name(self) -> str: + roots = self.get_root_object_addresses() + if not roots: + raise RuntimeError("No root objects discovered. Call setup() first.") + info = await self.introspection.get_object(roots[0]) + return info.name - 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") + async def _send_raw( + self, + command: TCPCommand, + *, + ensure_connection: bool, + return_raw: bool, + raise_on_error: bool, + read_timeout: Optional[float] = None, + ) -> Any: + if isinstance(command, NimbusCommand) and command.dest == _UNRESOLVED: + path = type(command).firmware_path + if path is None: + raise RuntimeError( + f"{type(command).__name__} has no firmware_path declared and no " + "explicit dest= supplied at construction. Polymorphic-dest commands " + "must pass dest= to send_query or send_command." + ) + try: + addr = await self.resolve_path(path) + except KeyError as exc: + raise RuntimeError( + f"Cannot send {type(command).__name__}: firmware path {path!r} did not resolve " + f"on this instrument ({exc})." + ) from exc + command.dest = addr + command.dest_address = addr + return await super()._send_raw( + command, + ensure_connection=ensure_connection, + return_raw=return_raw, + raise_on_error=raise_on_error, + read_timeout=read_timeout, + ) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/info.py b/pylabrobot/hamilton/liquid_handlers/nimbus/info.py new file mode 100644 index 00000000000..f53f16c9e45 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/info.py @@ -0,0 +1,60 @@ +"""Nimbus instrument info service. + +``NimbusInstrumentInfo`` is a bootstrap peer — it runs during :meth:`Nimbus.setup` +before any other peers are constructed and caches the channel configuration +returned by firmware command 30 (ChannelConfiguration on NimbusCORE). +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, List, Optional + +from pylabrobot.hamilton.tcp.introspection import FirmwareTreeNode + +from .commands import ChannelConfiguration, IsInitialized, NimbusChannelConfigWire + +if TYPE_CHECKING: + from .driver import NimbusDriver + +logger = logging.getLogger(__name__) + + +class NimbusInstrumentInfo: + """Owns the cached ``ChannelConfiguration`` snapshot and async instrument queries.""" + + def __init__(self, driver: "NimbusDriver") -> None: + self._driver = driver + self._configurations: Optional[List[NimbusChannelConfigWire]] = None + + async def _on_setup(self) -> None: + """Fetch and cache channel configuration. Called from :meth:`Nimbus.setup`.""" + resp = await self._driver.send_command(ChannelConfiguration()) + assert resp is not None, "ChannelConfiguration command returned None" + self._configurations = list(resp.configurations) + logger.info("Channel configuration: %d channels", len(self._configurations)) + + async def _on_stop(self) -> None: + self._configurations = None + + @property + def channel_configurations(self) -> List[NimbusChannelConfigWire]: + """Cached per-channel configurations. Raises if ``_on_setup`` has not run.""" + if self._configurations is None: + raise RuntimeError("Channel configuration not available. Call Nimbus.setup() first.") + return self._configurations + + @property + def num_channels(self) -> int: + return len(self.channel_configurations) + + async def is_initialized(self) -> bool: + """Whether NimbusCORE reports as initialized (IsInitialized, cmd 14).""" + result = await self._driver.send_command(IsInitialized()) + if result is None: + return False + return bool(result.initialized) + + async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTreeNode: + """Firmware object tree. ``print(await nimbus.info.get_firmware_tree())`` for a diagnostic dump.""" + return await self._driver.introspection.get_firmware_tree(refresh=refresh) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py index 9d7a14be5ed..41329f9ec80 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py @@ -1,20 +1,33 @@ -"""Nimbus device: wires NimbusDriver backends to PIP capability frontend.""" +"""Nimbus device: orchestrates transport, instrument info, and peer construction.""" -from typing import Optional +from __future__ import annotations +import logging +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional + +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.liquid_handling.pip import PIP from pylabrobot.device import Device from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck +from .channels import NimbusChannelMap from .chatterbox import NimbusChatterboxDriver -from .driver import NimbusDriver +from .commands import InitializeSmartRoll, Park, SetChannelConfiguration +from .core import NimbusCoreGripper, NimbusCoreGripperFactory, NimbusGripperArm +from .door import NimbusDoor +from .driver import NimbusDriver, NimbusSetupParams +from .info import NimbusInstrumentInfo +from .pip_backend import NimbusPIPBackend + +logger = logging.getLogger(__name__) 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(). + Setup connects to firmware, bootstraps instrument info, initializes channels, + and constructs all peers (PIP, door, CoRe gripper factory). """ def __init__( @@ -25,67 +38,293 @@ def __init__( port: int = 2000, ): if chatterbox: - driver: NimbusDriver = NimbusChatterboxDriver(deck=deck) + driver: NimbusDriver = NimbusChatterboxDriver() else: if not host: raise ValueError("host must be provided when chatterbox is False.") - driver = NimbusDriver(deck=deck, host=host, port=port) + driver = NimbusDriver(host=host, port=port) super().__init__(driver=driver) self.driver: NimbusDriver = driver self.deck = deck - self.pip: PIP # set in setup() + self.info: NimbusInstrumentInfo = NimbusInstrumentInfo(driver) + self.pip: Optional[PIP] = None + self.door: Optional[NimbusDoor] = None + self._core_factory: Optional[NimbusCoreGripperFactory] = None + self._core_gripper_arm: Optional[NimbusGripperArm] = None - async def setup(self): - """Initialize the Nimbus instrument. + def _normalize_setup_params(self, backend_params: Optional[BackendParams]) -> NimbusSetupParams: + if backend_params is None: + return NimbusSetupParams(deck=self.deck) + if isinstance(backend_params, NimbusSetupParams): + if backend_params.deck is None: + return NimbusSetupParams( + deck=self.deck, + require_door_lock=backend_params.require_door_lock, + force_initialize=backend_params.force_initialize, + ) + return backend_params + raise TypeError( + "Nimbus.setup expected NimbusSetupParams | None for backend_params, " + f"got {type(backend_params).__name__}" + ) - 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. - """ + async def setup(self, backend_params: Optional[BackendParams] = None): + """Connect, bootstrap info, initialize SmartRoll, construct peers.""" + params = self._normalize_setup_params(backend_params) try: - await self.driver.setup() + await self.driver.setup(backend_params=params) + await self.info._on_setup() + await self._initialize_instrument(params) - self.pip = PIP(backend=self.driver.pip, deck=self.deck) + channel_map = NimbusChannelMap.from_info(self.info) + pipette_address = await self.driver.resolve_path("NimbusCORE.Pipette") + pip_backend = NimbusPIPBackend( + driver=self.driver, + deck=params.deck, + address=pipette_address, + num_channels=self.info.num_channels, + channel_map=channel_map, + ) + self.pip = PIP(backend=pip_backend) self._capabilities = [self.pip] await self.pip._on_setup() + + door_address = await self._try_resolve("NimbusCORE.DoorLock") + if door_address is not None: + self.door = NimbusDoor(driver=self.driver) + await self.door._on_setup() + elif params.require_door_lock: + raise RuntimeError("DoorLock is required but not available on this instrument.") + + self._core_factory = NimbusCoreGripperFactory(driver=self.driver) self._setup_finished = True except Exception: + await self.info._on_stop() await self.driver.stop() raise - async def stop(self): - """Tear down the Nimbus instrument. + async def _try_resolve(self, path: str): + """Resolve a firmware path; return None if absent.""" + try: + return await self.driver.resolve_path(path) + except (KeyError, RuntimeError): + return None + + async def _initialize_instrument(self, params: NimbusSetupParams) -> None: + """Run InitializeSmartRoll when the instrument reports as uninitialized.""" + if not params.force_initialize: + try: + already = await self.info.is_initialized() + except Exception as e: + logger.error("IsInitialized failed; cannot decide whether to init: %s", e) + raise + if already: + logger.info("Nimbus already initialized, skipping SmartRoll init") + return - Stops all capabilities in reverse order and closes the driver connection. - """ + await self._initialize_smart_roll(params) + logger.info( + "Nimbus initialization complete%s", + " (force_initialize=True)" if params.force_initialize else "", + ) + + async def _initialize_smart_roll(self, params: NimbusSetupParams) -> None: + """Configure channels and run InitializeSmartRoll with waste positions.""" + if params.deck is None: + raise RuntimeError("Deck must be provided to run InitializeSmartRoll.") + + num_channels = self.info.num_channels + for channel in range(1, num_channels + 1): + await self.driver.send_command( + SetChannelConfiguration( + channel=channel, + indexes=[1, 3, 4], + enables=[True, False, False, False], + ) + ) + logger.info("Channel configuration set for %d channels", num_channels) + + # Build a temporary pip_backend to use the waste coordinate helpers. + # The real one is constructed after this method returns. + pipette_address = await self.driver.resolve_path("NimbusCORE.Pipette") + temp_pip = NimbusPIPBackend( + driver=self.driver, + deck=params.deck, + address=pipette_address, + num_channels=num_channels, + ) + all_channels = list(range(num_channels)) + ( + x_positions, + y_positions, + begin_tip_deposit, + end_tip_deposit, + z_end, + roll_distances, + ) = temp_pip._build_waste_position_params(use_channels=all_channels) + + await self.driver.send_command( + InitializeSmartRoll( + x_positions=x_positions, + y_positions=y_positions, + begin_tip_deposit_process=begin_tip_deposit, + end_tip_deposit_process=end_tip_deposit, + z_position_at_end_of_a_command=z_end, + roll_distances=roll_distances, + ) + ) + logger.info("NimbusCORE initialized with InitializeSmartRoll successfully") + + async def stop(self): + """Tear down all peers and close the driver connection.""" if not self._setup_finished: return - for cap in reversed(self._capabilities): - await cap._on_stop() + if self._core_gripper_arm is not None: + logger.warning( + "Nimbus.stop() called with CoRe grippers still mounted. " + "Call `await nimbus.return_core_grippers()` first if you want the tools returned." + ) + self._core_gripper_arm = None + if self.pip is not None: + await self.pip._on_stop() + await self.info._on_stop() await self.driver.stop() + self._capabilities = [] + self.pip = None + self.door = None + self._core_factory = None self._setup_finished = False - # -- Convenience methods delegating to driver/subsystems -------------------- + # -- CoRe grippers ------------------------------------------------------------ + + @property + def core_gripper_arm(self) -> NimbusGripperArm: + """The mounted CoRe gripper arm. Raises if grippers are not currently picked up.""" + if self._core_gripper_arm is None: + raise RuntimeError( + "CoRe grippers not mounted. Call `await nimbus.pick_up_core_grippers()` first, " + "or use `async with nimbus.core_grippers() as arm:`." + ) + return self._core_gripper_arm + + @property + def core_grippers_mounted(self) -> bool: + return self._core_gripper_arm is not None + + async def pick_up_core_grippers( + self, + x: float, + y_ch1: float, + y_ch2: float, + *, + channel1: int = 1, + channel2: int = 8, + backend_params: Optional[BackendParams] = None, + ) -> NimbusGripperArm: + """Pick up the CoRe gripper tools and return the mounted arm.""" + if self._core_gripper_arm is not None: + raise RuntimeError("CoRe grippers already mounted") + if self._core_factory is None or self.pip is None: + raise RuntimeError("Nimbus.setup() has not run.") + + pip_backend = self.pip.backend + assert isinstance(pip_backend, NimbusPIPBackend) + backend = self._core_factory.build_backend(pip=pip_backend) + + await backend.pick_up_tool( + x=x, + y_ch1=y_ch1, + y_ch2=y_ch2, + channel1=channel1, + channel2=channel2, + backend_params=backend_params, + ) + + self._core_gripper_arm = NimbusGripperArm( + backend=backend, reference_resource=self.deck, grip_axis="y" + ) + return self._core_gripper_arm + + async def return_core_grippers( + self, + x: float, + y_ch1: float, + y_ch2: float, + *, + channel1: int = 1, + channel2: int = 8, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop the CoRe gripper tools back to their parking position.""" + if self._core_gripper_arm is None: + return + backend = self._core_gripper_arm.backend + assert isinstance(backend, NimbusCoreGripper) + try: + await backend.drop_tool( + x=x, + y_ch1=y_ch1, + y_ch2=y_ch2, + channel1=channel1, + channel2=channel2, + backend_params=backend_params, + ) + finally: + self._core_gripper_arm = None + + @asynccontextmanager + async def core_grippers( + self, + x: float, + y_ch1: float, + y_ch2: float, + *, + channel1: int = 1, + channel2: int = 8, + pickup_params: Optional[BackendParams] = None, + drop_params: Optional[BackendParams] = None, + ) -> AsyncIterator[NimbusGripperArm]: + """Context manager: pick up CoRe grippers, yield the arm, then return the tools.""" + arm = await self.pick_up_core_grippers( + x=x, + y_ch1=y_ch1, + y_ch2=y_ch2, + channel1=channel1, + channel2=channel2, + backend_params=pickup_params, + ) + try: + yield arm + finally: + await self.return_core_grippers( + x=x, + y_ch1=y_ch1, + y_ch2=y_ch2, + channel1=channel1, + channel2=channel2, + backend_params=drop_params, + ) + + # -- Convenience methods ------------------------------------------------------- - async def park(self): + async def park(self) -> None: """Park the instrument.""" - await self.driver.park() + await self.driver.send_command(Park()) - async def lock_door(self): + async def lock_door(self) -> None: """Lock the door.""" - if self.driver.door is None: + if self.door is None: raise RuntimeError("Door lock is not available on this instrument.") - await self.driver.door.lock() + await self.door.lock() - async def unlock_door(self): + async def unlock_door(self) -> None: """Unlock the door.""" - if self.driver.door is None: + if self.door is None: raise RuntimeError("Door lock is not available on this instrument.") - await self.driver.door.unlock() + await self.door.unlock() async def is_door_locked(self) -> bool: """Check if the door is locked.""" - if self.driver.door is None: + if self.door is None: raise RuntimeError("Door lock is not available on this instrument.") - return await self.driver.door.is_locked() + return await self.door.is_locked() diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py index 65b4b00cf09..ff5ddbbadc3 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -3,28 +3,33 @@ from __future__ import annotations import logging -from dataclasses import dataclass -from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, TypeVar, Union +from dataclasses import dataclass, fields, replace +from typing import TYPE_CHECKING, Callable, 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.liquid_handlers.liquid_class import HamiltonLiquidClass +from pylabrobot.hamilton.liquid_handlers.liquid_class_resolver import ( + corrected_volumes_for_ops, + resolve_hamilton_liquid_classes, +) 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 .channels import ChannelType, NimbusChannelMap from .commands import ( Aspirate, - Dispense as DispenseCommand, DisableADC, + Dispense as DispenseCommand, DropTips, DropTipsRoll, EnableADC, GetChannelConfiguration, InitializeSmartRoll, - IsInitialized, IsTipPresent, PickupTips, SetChannelConfiguration, @@ -37,6 +42,13 @@ from .driver import NimbusDriver +_CHANNEL_TYPE_MAX_VOLUME_MAP: dict[ChannelType, float] = { + ChannelType.NONE: 0.0, + ChannelType.CHANNEL_300UL: 300.0, + ChannelType.CHANNEL_1000UL: 1000.0, + ChannelType.CHANNEL_5000UL: 5000.0, +} + logger = logging.getLogger(__name__) T = TypeVar("T") @@ -76,6 +88,11 @@ class NimbusPIPDropTipsParams(BackendParams): @dataclass class NimbusPIPAspirateParams(BackendParams): + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None + disable_volume_correction: Optional[List[bool]] = None + jet: Optional[List[bool]] = None + blow_out: Optional[List[bool]] = None + auto_liquid_class_lookup: Optional[Callable[..., Optional[HamiltonLiquidClass]]] = None minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None adc_enabled: bool = False lld_mode: Optional[List[int]] = None @@ -95,6 +112,11 @@ class NimbusPIPAspirateParams(BackendParams): @dataclass class NimbusPIPDispenseParams(BackendParams): + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None + disable_volume_correction: Optional[List[bool]] = None + jet: Optional[List[bool]] = None + blow_out: Optional[List[bool]] = None + auto_liquid_class_lookup: Optional[Callable[..., Optional[HamiltonLiquidClass]]] = None minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None adc_enabled: bool = False lld_mode: Optional[List[int]] = None @@ -114,6 +136,37 @@ class NimbusPIPDispenseParams(BackendParams): dispense_offset: Optional[List[float]] = None +def _coerce_nimbus_aspirate_params( + backend_params: Optional[BackendParams], +) -> NimbusPIPAspirateParams: + """Use Nimbus params as-is; otherwise copy overlapping fields from any backend params object.""" + if isinstance(backend_params, NimbusPIPAspirateParams): + return backend_params + if backend_params is None: + return NimbusPIPAspirateParams() + merged = { + f.name: getattr(backend_params, f.name) + for f in fields(NimbusPIPAspirateParams) + if hasattr(backend_params, f.name) + } + return replace(NimbusPIPAspirateParams(), **merged) + + +def _coerce_nimbus_dispense_params( + backend_params: Optional[BackendParams], +) -> NimbusPIPDispenseParams: + if isinstance(backend_params, NimbusPIPDispenseParams): + return backend_params + if backend_params is None: + return NimbusPIPDispenseParams() + merged = { + f.name: getattr(backend_params, f.name) + for f in fields(NimbusPIPDispenseParams) + if hasattr(backend_params, f.name) + } + return replace(NimbusPIPDispenseParams(), **merged) + + # --------------------------------------------------------------------------- # NimbusPIPBackend # --------------------------------------------------------------------------- @@ -129,16 +182,18 @@ class NimbusPIPBackend(PIPBackend): def __init__( self, driver: "NimbusDriver", - deck: "NimbusDeck", + deck: Optional["NimbusDeck"] = None, address: Optional["Address"] = None, num_channels: int = 8, traversal_height: float = 146.0, + channel_map: Optional[NimbusChannelMap] = None, ): self.driver = driver self.deck = deck self.address = address self._num_channels = num_channels self.traversal_height = traversal_height + self.channel_map = channel_map self._channel_configurations: Optional[dict] = None @property @@ -151,28 +206,25 @@ def pipette_address(self) -> Address: raise RuntimeError("Pipette address not set. Call setup() first.") return self.address - 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) + 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 - if not is_initialized: - await self._initialize_smart_roll() - else: - logger.info("Instrument already initialized, skipping SmartRoll init") + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + pass 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], @@ -193,7 +245,6 @@ async def _initialize_smart_roll(self): 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, @@ -362,9 +413,9 @@ def _build_waste_position_params( # --------------------------------------------------------------------------- async def request_tip_presence(self) -> List[Optional[bool]]: - tip_status = await self.driver.send_command(IsTipPresent(self.pipette_address)) + tip_status = await self.driver.send_command(IsTipPresent()) assert tip_status is not None, "IsTipPresent command returned None" - tip_present = tip_status.get("tip_present", []) + tip_present = tip_status.tip_present return [bool(v) for v in tip_present] def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: @@ -374,6 +425,11 @@ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: return False if channel_idx >= self._num_channels: return False + if self.channel_map is not None: + ch_type = self.channel_map.channel_type(channel_idx) + max_vol = _CHANNEL_TYPE_MAX_VOLUME_MAP.get(ch_type, 0.0) + if max_vol > 0.0 and tip.maximal_volume > max_vol: + return False return True async def pick_up_tips( @@ -402,6 +458,7 @@ async def pick_up_tips( """ if not ops: return + self._ensure_deck() params = ( backend_params if isinstance(backend_params, NimbusPIPPickUpTipsParams) @@ -439,7 +496,6 @@ async def pick_up_tips( 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, @@ -488,6 +544,7 @@ async def drop_tips( """ if not ops: return + self._ensure_deck() params = ( backend_params if isinstance(backend_params, NimbusPIPDropTipsParams) @@ -528,7 +585,6 @@ async def drop_tips( ) command = DropTipsRoll( - dest=self.pipette_address, channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -553,7 +609,6 @@ async def drop_tips( ) command = DropTips( - dest=self.pipette_address, channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -597,7 +652,9 @@ async def aspirate( - 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). + - swap_speed: Speed when leaving liquid (Z pull-out, mm/s; same semantics as + STAR/HamiltonLiquidClass). If omitted, uses liquid class per op, else 25 mm/s + when no liquid class resolves for that op. - 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). @@ -606,11 +663,7 @@ async def aspirate( """ if not ops: return - params = ( - backend_params - if isinstance(backend_params, NimbusPIPAspirateParams) - else NimbusPIPAspirateParams() - ) + params = _coerce_nimbus_aspirate_params(backend_params) n = len(ops) @@ -620,9 +673,9 @@ async def aspirate( # ADC control if params.adc_enabled: - await self.driver.send_command(EnableADC(self.pipette_address, channels_involved)) + await self.driver.send_command(EnableADC(channels_involved=channels_involved)) else: - await self.driver.send_command(DisableADC(self.pipette_address, channels_involved)) + await self.driver.send_command(DisableADC(channels_involved=channels_involved)) # Query channel configurations if self._channel_configurations is None: @@ -631,10 +684,10 @@ async def aspirate( channel_num = channel_idx + 1 try: config = await self.driver.send_command( - GetChannelConfiguration(self.pipette_address, channel=channel_num, indexes=[2]) + GetChannelConfiguration(channel=channel_num, indexes=[2]) ) assert config is not None - enabled = config["enabled"][0] if config["enabled"] else False + 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 @@ -650,7 +703,7 @@ async def aspirate( traverse_height = self.traversal_height traverse_height_units = round(traverse_height * 100) - deck = self.deck + deck = self._ensure_deck() # Well bottoms well_bottoms = [] @@ -669,38 +722,71 @@ async def aspirate( 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 + hlcs = resolve_hamilton_liquid_classes( + params.hamilton_liquid_classes, + list(ops), + jet=params.jet or False, + blow_out=params.blow_out or False, + is_aspirate=True, + lookup=params.auto_liquid_class_lookup, + ) + volumes = corrected_volumes_for_ops(ops, hlcs, params.disable_volume_correction) + flow_rates = [ + op.flow_rate + if op.flow_rate is not None + else ( + hlc.aspiration_flow_rate + if hlc is not None + else _get_default_flow_rate(op.tip, is_aspirate=True) + ) + for op, hlc in zip(ops, hlcs) ] 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 + op.blow_out_air_volume + if op.blow_out_air_volume is not None + else (hlc.aspiration_blow_out_volume if hlc is not None else 40.0) + for op, hlc in zip(ops, hlcs) ] - 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] = [ + mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_speed = [ 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) + else ( + hlc.aspiration_mix_flow_rate + if hlc is not None + else _get_default_flow_rate(op.tip, is_aspirate=True) + ) ) - for op in ops + for op, hlc in zip(ops, hlcs) ] - # Advanced parameters + # Advanced parameters (backend lists override liquid-class defaults) 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) + settling_time = _fill_in_defaults( + params.settling_time, + [hlc.aspiration_settling_time if hlc is not None else 1.0 for hlc in hlcs], + ) + transport_air_volume = _fill_in_defaults( + params.transport_air_volume, + [hlc.aspiration_air_transport_volume if hlc is not None else 5.0 for hlc in hlcs], + ) + pre_wetting_volume = _fill_in_defaults( + params.pre_wetting_volume, + [hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + swap_speed = _fill_in_defaults( + params.swap_speed, + [hlc.aspiration_swap_speed if hlc is not None else 25.0 for hlc in hlcs], + ) mix_position_from_liquid_surface = _fill_in_defaults( params.mix_position_from_liquid_surface, [0.0] * n ) @@ -718,7 +804,8 @@ async def aspirate( 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] + # Nimbus Pipette wire: 0.01 mm/s per U32 element; swap_speed above is mm/s. + swap_speed_units = [round(s * 100) 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 = [ @@ -772,7 +859,6 @@ async def aspirate( recording_mode = 0 command = Aspirate( - dest=self.pipette_address, aspirate_type=aspirate_type, channels_involved=channels_involved, x_positions=x_positions_full, @@ -838,7 +924,9 @@ async def dispense( - 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). + - swap_speed: Speed when leaving liquid (Z pull-out, mm/s; same semantics as + STAR/HamiltonLiquidClass). If omitted, uses liquid class per op, else 10 mm/s + when no liquid class resolves for that op. - 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). @@ -850,11 +938,7 @@ async def dispense( """ if not ops: return - params = ( - backend_params - if isinstance(backend_params, NimbusPIPDispenseParams) - else NimbusPIPDispenseParams() - ) + params = _coerce_nimbus_dispense_params(backend_params) n = len(ops) @@ -864,9 +948,9 @@ async def dispense( # ADC control if params.adc_enabled: - await self.driver.send_command(EnableADC(self.pipette_address, channels_involved)) + await self.driver.send_command(EnableADC(channels_involved=channels_involved)) else: - await self.driver.send_command(DisableADC(self.pipette_address, channels_involved)) + await self.driver.send_command(DisableADC(channels_involved=channels_involved)) # Query channel configurations if self._channel_configurations is None: @@ -875,10 +959,10 @@ async def dispense( channel_num = channel_idx + 1 try: config = await self.driver.send_command( - GetChannelConfiguration(self.pipette_address, channel=channel_num, indexes=[2]) + GetChannelConfiguration(channel=channel_num, indexes=[2]) ) assert config is not None - enabled = config["enabled"][0] if config["enabled"] else False + 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 @@ -894,7 +978,7 @@ async def dispense( traverse_height = self.traversal_height traverse_height_units = round(traverse_height * 100) - deck = self.deck + deck = self._ensure_deck() # Well bottoms well_bottoms = [] @@ -913,44 +997,78 @@ async def dispense( minimum_heights_mm = well_bottoms.copy() - volumes = [op.volume for op in ops] - flow_rates: List[float] = [ + hlcs = resolve_hamilton_liquid_classes( + params.hamilton_liquid_classes, + list(ops), + jet=params.jet or False, + blow_out=params.blow_out or False, + is_aspirate=False, + lookup=params.auto_liquid_class_lookup, + ) + volumes = corrected_volumes_for_ops(ops, hlcs, params.disable_volume_correction) + flow_rates = [ op.flow_rate if op.flow_rate is not None - else _get_default_flow_rate(op.tip, is_aspirate=False) - for op in ops + else ( + hlc.dispense_flow_rate + if hlc is not None + else _get_default_flow_rate(op.tip, is_aspirate=False) + ) + for op, hlc in zip(ops, hlcs) ] 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 + op.blow_out_air_volume + if op.blow_out_air_volume is not None + else (hlc.dispense_blow_out_volume if hlc is not None else 40.0) + for op, hlc in zip(ops, hlcs) ] - 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] = [ + mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_speed = [ 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) + else ( + hlc.dispense_mix_flow_rate + if hlc is not None + else _get_default_flow_rate(op.tip, is_aspirate=False) + ) ) - for op in ops + for op, hlc in zip(ops, hlcs) ] - # Advanced parameters + # Advanced parameters (backend lists override liquid-class defaults) 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) + settling_time = _fill_in_defaults( + params.settling_time, + [hlc.dispense_settling_time if hlc is not None else 1.0 for hlc in hlcs], + ) + transport_air_volume = _fill_in_defaults( + params.transport_air_volume, + [hlc.dispense_air_transport_volume if hlc is not None else 5.0 for hlc in hlcs], + ) + swap_speed = _fill_in_defaults( + params.swap_speed, + [hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hlcs], + ) 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) + cut_off_speed = _fill_in_defaults( + params.cut_off_speed, + [hlc.dispense_stop_flow_rate if hlc is not None else 25.0 for hlc in hlcs], + ) + stop_back_volume = _fill_in_defaults( + params.stop_back_volume, + [hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hlcs], + ) dispense_offset = _fill_in_defaults(params.dispense_offset, [0.0] * n) # Unit conversions @@ -964,7 +1082,8 @@ async def dispense( 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] + # Nimbus Pipette wire: 0.01 mm/s per U32 element; swap_speed above is mm/s. + swap_speed_units = [round(s * 100) 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 = [ @@ -1019,7 +1138,6 @@ async def dispense( recording_mode = 0 command = DispenseCommand( - dest=self.pipette_address, dispense_type=dispense_type, channels_involved=channels_involved, x_positions=x_positions_full, diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py new file mode 100644 index 00000000000..ce3d6457980 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py @@ -0,0 +1,94 @@ +import asyncio +from dataclasses import dataclass +from unittest.mock import AsyncMock + +import pytest + +from pylabrobot.hamilton.liquid_handlers.nimbus.chatterbox import NimbusChatterboxDriver +from pylabrobot.hamilton.liquid_handlers.nimbus.commands import ( + ChannelConfiguration, + NimbusCommand, + Park, + _UNRESOLVED, +) +from pylabrobot.hamilton.liquid_handlers.nimbus.driver import NimbusDriver +from pylabrobot.hamilton.tcp.packets import Address + + +def test_chatterbox_setup_and_command_roundtrip(): + async def _run() -> None: + driver = NimbusChatterboxDriver(num_channels=8) + await driver.setup() + + assert driver.nimbus_core_address == Address(1, 1, 48896) + + response = await driver.send_command(ChannelConfiguration()) + assert len(response.configurations) == 8 + assert all(c.channel_type == 1 for c in response.configurations) + + await driver.stop() + + asyncio.run(_run()) + + +def test_chatterbox_jit_resolves_dest_after_send(): + async def _run() -> None: + driver = NimbusChatterboxDriver(num_channels=8) + await driver.setup() + cmd = ChannelConfiguration() + assert cmd.dest_address == _UNRESOLVED + await driver.send_command(cmd) + assert cmd.dest == driver.nimbus_core_address + assert cmd.dest_address == driver.nimbus_core_address + await driver.stop() + + asyncio.run(_run()) + + +def test_send_command_surfaces_clear_error_for_unresolvable_nimbus_path(): + async def _run() -> None: + driver = NimbusDriver(host="127.0.0.1") + driver.resolve_path = AsyncMock(side_effect=KeyError("NimbusCORE")) # type: ignore[method-assign] + with pytest.raises(RuntimeError, match="firmware path"): + await driver.send_command(Park()) + + asyncio.run(_run()) + + +def test_chatterbox_allows_explicit_dest_override_when_firmware_path_none(): + @dataclass + class _ExplicitDestCommand(NimbusCommand): + command_id = 999 + firmware_path = None + + async def _run() -> None: + driver = NimbusChatterboxDriver(num_channels=8) + await driver.setup() + cmd = _ExplicitDestCommand(dest=Address(1, 1, 48896)) + await driver.send_command(cmd) + assert cmd.dest == Address(1, 1, 48896) + assert cmd.dest_address == Address(1, 1, 48896) + await driver.stop() + + asyncio.run(_run()) + + +def test_nimbus_core_address_raises_before_setup(): + """Property requires setup() to have discovered and stored NimbusCore.""" + driver = NimbusDriver(host="127.0.0.1") + with pytest.raises(RuntimeError, match="Nimbus root address not discovered"): + _ = driver.nimbus_core_address + + +def test_chatterbox_channel_configuration_returns_correct_types(): + async def _run() -> None: + driver = NimbusChatterboxDriver(num_channels=4) + await driver.setup() + response = await driver.send_command(ChannelConfiguration()) + assert len(response.configurations) == 4 + for i, cfg in enumerate(response.configurations): + assert cfg.channel_type == 1 # Channel300uL + assert cfg.rail == i % 2 # alternating Left/Right + await driver.stop() + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py new file mode 100644 index 00000000000..d1d84162ddf --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py @@ -0,0 +1,384 @@ +"""Tests for NimbusPIPBackend liquid-class integration.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock + +from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense +from pylabrobot.hamilton.liquid_handlers.liquid_class import HamiltonLiquidClass +from pylabrobot.hamilton.liquid_handlers.nimbus.commands import Aspirate, GetChannelConfiguration +from pylabrobot.hamilton.liquid_handlers.nimbus.commands import Dispense as DispenseCmd +from pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend import ( + NimbusPIPAspirateParams, + NimbusPIPBackend, + NimbusPIPDispenseParams, +) +from pylabrobot.hamilton.liquid_handlers.star.pip_backend import STARPIPBackend +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb +from pylabrobot.resources.hamilton import HamiltonTip, TipPickupMethod, TipSize +from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck +from pylabrobot.resources.hamilton.tip_racks import hamilton_96_tiprack_300uL + + +def _make_hlc_for_volume_double() -> HamiltonLiquidClass: + """Correction curve: requested 100 µL liquid -> 200 µL piston displacement.""" + return HamiltonLiquidClass( + curve={0.0: 0.0, 100.0: 200.0, 200.0: 400.0}, + aspiration_flow_rate=88.0, + aspiration_mix_flow_rate=1.0, + aspiration_air_transport_volume=2.0, + aspiration_blow_out_volume=3.0, + aspiration_swap_speed=4.0, + aspiration_settling_time=5.0, + aspiration_over_aspirate_volume=6.0, + aspiration_clot_retract_height=7.0, + dispense_flow_rate=9.0, + dispense_mode=0.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=11.0, + dispense_blow_out_volume=12.0, + dispense_swap_speed=13.0, + dispense_settling_time=14.0, + dispense_stop_flow_rate=15.0, + dispense_stop_back_volume=16.0, + ) + + +def test_nimbus_aspirate_volume_correction_and_param_override(): + async def _run() -> None: + deck = NimbusDeck() + tip_rack = hamilton_96_tiprack_300uL("tips") + deck.assign_child_resource(tip_rack, rails=1) + plate = Cor_96_wellplate_360ul_Fb("plate") + deck.assign_child_resource(plate, rails=10) + + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + well = plate.get_well("A1") + op = Aspiration( + resource=well, + offset=Coordinate.zero(), + tip=tip, + volume=100.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + + driver = AsyncMock() + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) + + backend = NimbusPIPBackend( + driver=driver, # type: ignore[arg-type] + deck=deck, + address=Address(1, 1, 100), + num_channels=8, + ) + + hlc = _make_hlc_for_volume_double() + await backend.aspirate( + [op], + use_channels=[0], + backend_params=NimbusPIPAspirateParams( + hamilton_liquid_classes=[hlc], + transport_air_volume=[42.0], + disable_volume_correction=[False], + ), + ) + + aspirate_cmds = [ + c for c in driver.send_command.call_args_list if isinstance(c.args[0], Aspirate) + ] + assert len(aspirate_cmds) == 1 + cmd = aspirate_cmds[0].args[0] + assert isinstance(cmd, Aspirate) + # Volume correction: 100 µL target -> 200 µL internal; firmware units = round(µL * 10) + assert cmd.aspirate_volume[0] == 2000 + # Explicit backend_params override liquid-class default for transport air + assert cmd.transport_air_volume[0] == 420 + # Flow rate from liquid class when op.flow_rate is None + assert cmd.aspiration_speed[0] == round(88.0 * 10) + + asyncio.run(_run()) + + +def test_nimbus_aspirate_disable_volume_correction_keeps_nominal_volume(): + async def _run() -> None: + deck = NimbusDeck() + tip_rack = hamilton_96_tiprack_300uL("tips") + deck.assign_child_resource(tip_rack, rails=1) + plate = Cor_96_wellplate_360ul_Fb("plate") + deck.assign_child_resource(plate, rails=10) + + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + well = plate.get_well("A1") + op = Aspiration( + resource=well, + offset=Coordinate.zero(), + tip=tip, + volume=100.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + + driver = AsyncMock() + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) + + backend = NimbusPIPBackend( + driver=driver, # type: ignore[arg-type] + deck=deck, + address=Address(1, 1, 100), + num_channels=8, + ) + + hlc = _make_hlc_for_volume_double() + await backend.aspirate( + [op], + use_channels=[0], + backend_params=NimbusPIPAspirateParams( + hamilton_liquid_classes=[hlc], + disable_volume_correction=[True], + ), + ) + + aspirate_cmds = [ + c for c in driver.send_command.call_args_list if isinstance(c.args[0], Aspirate) + ] + cmd = aspirate_cmds[0].args[0] + assert cmd.aspirate_volume[0] == 1000 + + asyncio.run(_run()) + + +def test_nimbus_aspirate_explicit_swap_speed_wire_units(): + """15 mm/s → 1500 (0.01 mm/s wire units) on channel 0.""" + + async def _run() -> None: + deck = NimbusDeck() + tip_rack = hamilton_96_tiprack_300uL("tips") + deck.assign_child_resource(tip_rack, rails=1) + plate = Cor_96_wellplate_360ul_Fb("plate") + deck.assign_child_resource(plate, rails=10) + + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + well = plate.get_well("A1") + op = Aspiration( + resource=well, + offset=Coordinate.zero(), + tip=tip, + volume=50.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + + driver = AsyncMock() + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) + + backend = NimbusPIPBackend( + driver=driver, # type: ignore[arg-type] + deck=deck, + address=Address(1, 1, 100), + num_channels=8, + ) + + await backend.aspirate( + [op], + use_channels=[0], + backend_params=NimbusPIPAspirateParams(swap_speed=[15.0]), + ) + + aspirate_cmds = [ + c for c in driver.send_command.call_args_list if isinstance(c.args[0], Aspirate) + ] + cmd = aspirate_cmds[0].args[0] + assert isinstance(cmd, Aspirate) + assert cmd.swap_speed[0] == 1500 + + asyncio.run(_run()) + + +def test_nimbus_aspirate_no_hlc_uses_25_mm_s_default(): + """Explicit None liquid class → 25 mm/s → 2500 wire units (HamiltonTip still required for defaults).""" + + async def _run() -> None: + deck = NimbusDeck() + tip_rack = hamilton_96_tiprack_300uL("tips") + deck.assign_child_resource(tip_rack, rails=1) + plate = Cor_96_wellplate_360ul_Fb("plate") + deck.assign_child_resource(plate, rails=10) + + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + well = plate.get_well("A1") + op = Aspiration( + resource=well, + offset=Coordinate.zero(), + tip=tip, + volume=50.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + + driver = AsyncMock() + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) + + backend = NimbusPIPBackend( + driver=driver, # type: ignore[arg-type] + deck=deck, + address=Address(1, 1, 100), + num_channels=8, + ) + + await backend.aspirate( + [op], + use_channels=[0], + backend_params=NimbusPIPAspirateParams(hamilton_liquid_classes=[None]), + ) + + aspirate_cmds = [ + c for c in driver.send_command.call_args_list if isinstance(c.args[0], Aspirate) + ] + cmd = aspirate_cmds[0].args[0] + assert isinstance(cmd, Aspirate) + assert cmd.swap_speed[0] == 2500 + + asyncio.run(_run()) + + +def test_nimbus_dispense_no_hlc_uses_10_mm_s_default(): + """Explicit None liquid class → 10 mm/s → 1000 wire units.""" + + async def _run() -> None: + deck = NimbusDeck() + tip_rack = hamilton_96_tiprack_300uL("tips") + deck.assign_child_resource(tip_rack, rails=1) + plate = Cor_96_wellplate_360ul_Fb("plate") + deck.assign_child_resource(plate, rails=10) + + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + well = plate.get_well("A1") + op = Dispense( + resource=well, + offset=Coordinate.zero(), + tip=tip, + volume=50.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + + driver = AsyncMock() + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) + + backend = NimbusPIPBackend( + driver=driver, # type: ignore[arg-type] + deck=deck, + address=Address(1, 1, 100), + num_channels=8, + ) + + await backend.dispense( + [op], + use_channels=[0], + backend_params=NimbusPIPDispenseParams(hamilton_liquid_classes=[None]), + ) + + dispense_cmds = [ + c for c in driver.send_command.call_args_list if isinstance(c.args[0], DispenseCmd) + ] + cmd = dispense_cmds[0].args[0] + assert isinstance(cmd, DispenseCmd) + assert cmd.swap_speed[0] == 1000 + + asyncio.run(_run()) + + +def test_nimbus_aspirate_coerces_star_aspirate_params_swap_speed(): + """Overlapping fields from STARPIPBackend.AspirateParams are not dropped.""" + + async def _run() -> None: + deck = NimbusDeck() + tip_rack = hamilton_96_tiprack_300uL("tips") + deck.assign_child_resource(tip_rack, rails=1) + plate = Cor_96_wellplate_360ul_Fb("plate") + deck.assign_child_resource(plate, rails=10) + + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + well = plate.get_well("A1") + op = Aspiration( + resource=well, + offset=Coordinate.zero(), + tip=tip, + volume=50.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + + driver = AsyncMock() + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) + + backend = NimbusPIPBackend( + driver=driver, # type: ignore[arg-type] + deck=deck, + address=Address(1, 1, 100), + num_channels=8, + ) + + star_params = STARPIPBackend.AspirateParams(swap_speed=[42.0]) + await backend.aspirate([op], use_channels=[0], backend_params=star_params) + + aspirate_cmds = [ + c for c in driver.send_command.call_args_list if isinstance(c.args[0], Aspirate) + ] + cmd = aspirate_cmds[0].args[0] + assert isinstance(cmd, Aspirate) + assert cmd.swap_speed[0] == 4200 + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/liquid_handlers/prep/prep_commands.py b/pylabrobot/hamilton/liquid_handlers/prep/prep_commands.py new file mode 100644 index 00000000000..49417beb9d8 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/prep/prep_commands.py @@ -0,0 +1,2320 @@ +"""Prep command dataclasses and wire-type parameter structs. + +Pure data definitions for the Hamilton Prep protocol — enums, hardware config, +wire-type annotated parameter structs, and PrepCommand subclasses. No business +logic; used by PrepBackend for command construction and serialization. + +Moved from prep_backend.py to separate protocol contracts from domain logic. +""" + +from __future__ import annotations + +import datetime +import math +from dataclasses import dataclass, fields +from enum import IntEnum +from typing import Annotated, Optional, Tuple + +from pylabrobot.hamilton.tcp.commands import TCPCommand +from pylabrobot.hamilton.tcp.messages import HoiParams +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.hamilton.tcp.wire_types import ( + Enum as WEnum, + F32, + I8, + I16, + U16, + U32, + EnumArray, + HcResultEntry, + I16Array, + PaddedBool, + PaddedU8, + Str, + Struct, + StructArray, + U8Array, + U32Array, +) +from pylabrobot.capabilities.liquid_handling.standard import Aspiration + +# ============================================================================= +# Enums (mirrored from Prep protocol spec) +# ============================================================================= + + +class ChannelIndex(IntEnum): + InvalidIndex = 0 + FrontChannel = 1 + RearChannel = 2 + MPHChannel = 3 + + +class TipDropType(IntEnum): + FixedHeight = 0 + Stall = 1 + CLLDSeek = 2 + + +class TipTypes(IntEnum): + None_ = 0 + LowVolume = 1 + StandardVolume = 2 + HighVolume = 3 + + +class TadmRecordingModes(IntEnum): + NoRecording = 0 + Errors = 1 + All = 2 + + +# ============================================================================= +# Hardware config (probed from instrument, immutable) +# ============================================================================= + + +@dataclass(frozen=True) +class DeckBounds: + """Deck axis bounds in mm (from GetDeckBounds / DeckConfiguration).""" + + min_x: float + max_x: float + min_y: float + max_y: float + min_z: float + max_z: float + + +@dataclass(frozen=True) +class DeckSiteInfo: + """A deck slot read from DeckConfiguration.GetDeckSiteDefinitions.""" + + id: int + left_bottom_front_x: float + left_bottom_front_y: float + left_bottom_front_z: float + length: float + width: float + height: float + + +@dataclass(frozen=True) +class WasteSiteInfo: + """A waste position read from DeckConfiguration.GetWasteSiteDefinitions.""" + + index: int + x_position: float + y_position: float + z_position: float + z_seek: float + + +@dataclass +class HoiDateTime: + """Hamilton network/built-in dateTime struct (source_id=3, ref_id=3). + + Wire format: 7 DataFragments — year(U16), month(PaddedU8), day(PaddedU8), + hour(PaddedU8), minute(PaddedU8), second(PaddedU8), millisecond(U16). + + Used by EndCalibration and SetChannelHardwareConfiguration to timestamp + calibration data. Construct from ``datetime.datetime`` via ``from_datetime()``. + """ + + year: U16 + month: PaddedU8 + day: PaddedU8 + hour: PaddedU8 + minute: PaddedU8 + second: PaddedU8 + millisecond: U16 + + @classmethod + def from_datetime(cls, dt: datetime.datetime) -> "HoiDateTime": + """Create from a Python datetime (microseconds truncated to milliseconds).""" + return cls( + year=dt.year, + month=dt.month, + day=dt.day, + hour=dt.hour, + minute=dt.minute, + second=dt.second, + millisecond=dt.microsecond // 1000, + ) + + @classmethod + def now(cls) -> "HoiDateTime": + """Create from the current local time.""" + return cls.from_datetime(datetime.datetime.now()) + + def to_datetime(self) -> datetime.datetime: + """Convert to a Python datetime.""" + return datetime.datetime( + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.millisecond * 1000, + ) + + +@dataclass(frozen=True) +class CalibrationSiteInfo: + """A calibration site from DeckConfiguration.GetCalibrationSiteDefinitions.""" + + id: int + left_bottom_front_x: float + left_bottom_front_y: float + left_bottom_front_z: float + length: float + width: float + height: float + post: bool + + +@dataclass(frozen=True) +class ChannelHardwareConfigInfo: + """Per-channel hardware config from MLPrepCalibration.GetChannelHardwareConfiguration.""" + + channel: int # ChannelIndex enum value + hardware: int # Hardware type enum value + + +@dataclass(frozen=True) +class ChannelCalibrationValuesInfo: + """Per-channel calibration values from MLPrepCalibration.GetCalibrationValues.""" + + index: int # ChannelIndex enum value + y_offset: float + z_offset: float + squeeze_position: int + z_touchoff: int + pressure_shift: int + pressure_monitoring_shift: int + dispenser_return_distance: float + z_tip_height: float + core_ii: bool + + def to_pretty_string(self) -> str: + """Return a stable one-line representation for logging/reporting.""" + return ( + f"index={self.index}, y_offset={self.y_offset}, z_offset={self.z_offset}, " + f"squeeze_position={self.squeeze_position}, z_touchoff={self.z_touchoff}, " + f"pressure_shift={self.pressure_shift}, " + f"pressure_monitoring_shift={self.pressure_monitoring_shift}, " + f"dispenser_return_distance={self.dispenser_return_distance}, " + f"z_tip_height={self.z_tip_height}, core_ii={self.core_ii}" + ) + + +@dataclass(frozen=True) +class CalibrationValues: + """Full calibration values from MLPrepCalibration.GetCalibrationValues.""" + + independent_offset_x: float + mph_offset_x: float + channel_values: Tuple["ChannelCalibrationValuesInfo", ...] + + def to_pretty_string(self, sort_by_index: bool = True) -> str: + """Return deterministic, human-readable calibration output.""" + channels = self.channel_values + if sort_by_index: + channels = tuple(sorted(channels, key=lambda cv: cv.index)) + + lines = [ + f"Independent offset X: {self.independent_offset_x}", + f"MPH offset X: {self.mph_offset_x}", + "Per-channel calibration values:", + ] + for cv in channels: + lines.append(f" {cv.to_pretty_string()}") + return "\n".join(lines) + + def __str__(self) -> str: + return self.to_pretty_string() + + +@dataclass(frozen=True) +class CalibrationFieldChange: + field: str + old: object + new: object + + +@dataclass(frozen=True) +class ChannelCalibrationDiff: + index: int + state: str # "added" | "removed" | "changed" + changes: Tuple[CalibrationFieldChange, ...] + old: Optional[ChannelCalibrationValuesInfo] + new: Optional[ChannelCalibrationValuesInfo] + + +@dataclass(frozen=True) +class CalibrationValuesDiff: + top_level_changes: Tuple[CalibrationFieldChange, ...] + channel_diffs: Tuple[ChannelCalibrationDiff, ...] + + @property + def has_changes(self) -> bool: + return bool(self.top_level_changes or self.channel_diffs) + + +def _calibration_value_equal(old: object, new: object, float_tol: float) -> bool: + if isinstance(old, float) and isinstance(new, float): + return math.isclose(old, new, rel_tol=0.0, abs_tol=float_tol) + return old == new + + +def diff_calibration_values( + old: CalibrationValues, + new: CalibrationValues, + float_tol: float = 1e-6, +) -> CalibrationValuesDiff: + """Return structured diff between two calibration snapshots.""" + + top_level_changes = [] + for field_name in ("independent_offset_x", "mph_offset_x"): + old_value = getattr(old, field_name) + new_value = getattr(new, field_name) + if not _calibration_value_equal(old_value, new_value, float_tol=float_tol): + top_level_changes.append( + CalibrationFieldChange(field=field_name, old=old_value, new=new_value) + ) + + old_channels = {cv.index: cv for cv in old.channel_values} + new_channels = {cv.index: cv for cv in new.channel_values} + channel_diffs = [] + for idx in sorted(set(old_channels) | set(new_channels)): + old_cv = old_channels.get(idx) + new_cv = new_channels.get(idx) + if old_cv is None and new_cv is not None: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="added", + changes=(), + old=None, + new=new_cv, + ) + ) + continue + if old_cv is not None and new_cv is None: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="removed", + changes=(), + old=old_cv, + new=None, + ) + ) + continue + assert old_cv is not None and new_cv is not None + + field_changes = [] + for f in fields(ChannelCalibrationValuesInfo): + field_name = f.name + old_value = getattr(old_cv, field_name) + new_value = getattr(new_cv, field_name) + if not _calibration_value_equal(old_value, new_value, float_tol=float_tol): + field_changes.append(CalibrationFieldChange(field=field_name, old=old_value, new=new_value)) + if field_changes: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="changed", + changes=tuple(field_changes), + old=old_cv, + new=new_cv, + ) + ) + + return CalibrationValuesDiff( + top_level_changes=tuple(top_level_changes), + channel_diffs=tuple(channel_diffs), + ) + + +def format_calibration_diff(diff: CalibrationValuesDiff) -> str: + """Return a concise, human-readable diff summary.""" + if not diff.has_changes: + return "No calibration differences." + + lines = ["Calibration differences:"] + if diff.top_level_changes: + lines.append("Top-level:") + for change in diff.top_level_changes: + lines.append(f" {change.field}: {change.old} -> {change.new}") + + if diff.channel_diffs: + lines.append("Per-channel:") + for channel_diff in diff.channel_diffs: + if channel_diff.state == "added": + assert channel_diff.new is not None + lines.append(f" index={channel_diff.index}: added ({channel_diff.new.to_pretty_string()})") + continue + if channel_diff.state == "removed": + assert channel_diff.old is not None + lines.append( + f" index={channel_diff.index}: removed ({channel_diff.old.to_pretty_string()})" + ) + continue + changed_fields = ", ".join( + f"{change.field}: {change.old} -> {change.new}" for change in channel_diff.changes + ) + lines.append(f" index={channel_diff.index}: {changed_fields}") + + return "\n".join(lines) + + +@dataclass(frozen=True) +class InstrumentConfig: + """Instrument hardware configuration probed at setup.""" + + deck_bounds: Optional[DeckBounds] + has_enclosure: bool + safe_speeds_enabled: bool + deck_sites: Tuple[DeckSiteInfo, ...] + waste_sites: Tuple[WasteSiteInfo, ...] + default_traverse_height: Optional[float] = ( + None # None if probe failed; user can set via set_default_traverse_height + ) + num_channels: Optional[int] = None # 1 or 2 dual-channel pipettor; from GetPresentChannels + has_mph: Optional[bool] = None # True if 8MPH present; from GetPresentChannels + + +# ============================================================================= +# Inner parameter dataclasses (wire-type annotated, serialized via from_struct) +# ============================================================================= + + +@dataclass +class SeekParameters: + x_start: F32 + y_start: F32 + z_start: F32 + distance: F32 + expected_position: F32 + + +@dataclass +class XYZCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class XYCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + + +@dataclass +class ChannelYZMoveParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_position: F32 + + +@dataclass +class GantryMoveXYZParameters: + default_values: PaddedBool + gantry_x_position: F32 + axis_parameters: Annotated[list[ChannelYZMoveParameters], StructArray()] + + +@dataclass +class PlateDimensions: + default_values: PaddedBool + length: F32 + width: F32 + height: F32 + + +@dataclass +class TipDefinition: + default_values: PaddedBool + id: PaddedU8 + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + label: Str + + +@dataclass +class TipPickupParameters: + default_values: PaddedBool + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + + +@dataclass +class AspirateParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + prewet_volume: F32 + blowout_volume: F32 + + @classmethod + def for_op( + cls, + loc, + op: Aspiration, + prewet_volume: float = 0.0, + blowout_volume: Optional[float] = None, + ) -> AspirateParameters: + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + prewet_volume=prewet_volume, + blowout_volume=(op.blow_out_air_volume or 0.0) if blowout_volume is None else blowout_volume, + ) + + +@dataclass +class DispenseParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + stop_back_volume: F32 + cutoff_speed: F32 + + @classmethod + def for_op( + cls, + loc, + stop_back_volume: float = 0.0, + cutoff_speed: float = 100.0, + ) -> DispenseParameters: + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + + +@dataclass +class CommonParameters: + default_values: PaddedBool + empty: PaddedBool + z_minimum: F32 + z_final: F32 + z_liquid_exit_speed: F32 + liquid_volume: F32 + liquid_speed: F32 + transport_air_volume: F32 + tube_radius: F32 + cone_height: F32 + cone_bottom_radius: F32 + settling_time: F32 + additional_probes: U32 + + @classmethod + def for_op( + cls, + volume: float, + radius: float, + *, + flow_rate: Optional[float] = None, + empty: bool = True, + z_minimum: float = 5.0, + z_final: float = 96.97, + z_liquid_exit_speed: float = 10.0, + transport_air_volume: float = 0.0, + cone_height: float = 0.0, + cone_bottom_radius: float = 0.0, + settling_time: float = 1.0, + additional_probes: int = 0, + ) -> CommonParameters: + """Build CommonParameters for a single aspirate/dispense op. + + z_minimum is in mm; default 5.0 keeps the head above the deck surface (deck has + its own size_z). High-level aspirate()/dispense() override with well bottom when None. + z_liquid_exit_speed is in mm/s; default 10.0 aligns with STAR swap speed. + """ + return cls( + default_values=False, + empty=empty, + z_minimum=z_minimum, + z_final=z_final, + z_liquid_exit_speed=z_liquid_exit_speed, + liquid_volume=volume, + liquid_speed=flow_rate or 100.0, + transport_air_volume=transport_air_volume, + tube_radius=radius, + cone_height=cone_height, + cone_bottom_radius=cone_bottom_radius, + settling_time=settling_time, + additional_probes=additional_probes, + ) + + +@dataclass +class NoLldParameters: + default_values: PaddedBool + z_fluid: F32 + z_air: F32 + bottom_search: PaddedBool + z_bottom_search_offset: F32 + z_bottom_offset: F32 + + @classmethod + def for_fixed_z( + cls, + z_fluid: float = 94.97, + z_air: float = 96.97, + *, + z_bottom_search_offset: float = 2.0, + z_bottom_offset: float = 0.0, + ) -> NoLldParameters: + return cls( + default_values=False, + z_fluid=z_fluid, + z_air=z_air, + bottom_search=False, + z_bottom_search_offset=z_bottom_search_offset, + z_bottom_offset=z_bottom_offset, + ) + + +@dataclass +class LldParameters: + default_values: PaddedBool + search_start_position: F32 + channel_speed: F32 + z_submerge: F32 + z_out_of_liquid: F32 + + @classmethod + def default(cls) -> LldParameters: + return cls( + default_values=True, + search_start_position=0.0, + channel_speed=0.0, + z_submerge=0.0, + z_out_of_liquid=0.0, + ) + + +@dataclass +class CLldParameters: + default_values: PaddedBool + sensitivity: WEnum + clot_check_enable: PaddedBool + z_clot_check: F32 + detect_mode: WEnum + + @classmethod + def default(cls) -> CLldParameters: + return cls( + default_values=True, sensitivity=1, clot_check_enable=False, z_clot_check=0.0, detect_mode=0 + ) + + +@dataclass +class PLldParameters: + default_values: PaddedBool + sensitivity: WEnum + dispenser_seek_speed: F32 + lld_height_difference: F32 + detect_mode: WEnum + + @classmethod + def default(cls) -> PLldParameters: + return cls( + default_values=True, + sensitivity=1, + dispenser_seek_speed=0.0, + lld_height_difference=0.0, + detect_mode=0, + ) + + +@dataclass +class TadmReturnParameters: + default_values: PaddedBool + channel: WEnum + entries: U32 + error: PaddedBool + data: I16Array + + +@dataclass +class TadmParameters: + default_values: PaddedBool + limit_curve_index: U16 + recording_mode: WEnum + + @classmethod + def default(cls) -> TadmParameters: + return cls( + default_values=True, + limit_curve_index=0, + recording_mode=TadmRecordingModes.Errors, + ) + + +@dataclass +class AspirateMonitoringParameters: + default_values: PaddedBool + c_lld_enable: PaddedBool + p_lld_enable: PaddedBool + minimum_differential: U16 + maximum_differential: U16 + clot_threshold: U16 + + @classmethod + def default(cls) -> AspirateMonitoringParameters: + return cls( + default_values=True, + c_lld_enable=False, + p_lld_enable=False, + minimum_differential=30, + maximum_differential=30, + clot_threshold=20, + ) + + +@dataclass +class MixParameters: + default_values: PaddedBool + z_offset: F32 + volume: F32 + cycles: PaddedU8 + speed: F32 + + @classmethod + def default(cls) -> MixParameters: + return cls( + default_values=True, + z_offset=0.0, + volume=0.0, + cycles=0, + speed=250.0, + ) + + +@dataclass +class AdcParameters: + default_values: PaddedBool + errors: PaddedBool + maximum_volume: F32 + + @classmethod + def default(cls) -> AdcParameters: + return cls( + default_values=True, + errors=True, + maximum_volume=4.5, + ) + + +@dataclass +class ChannelBoundsParameters: + """Per-channel movement bounds returned by PipettorService.GetChannelBounds.""" + + default_values: PaddedBool + channel: WEnum + x_min: F32 + x_max: F32 + y_min: F32 + y_max: F32 + z_min: F32 + z_max: F32 + + +@dataclass +class ChannelXYZPositionParameters: + default_values: PaddedBool + channel: WEnum + position_x: F32 + position_y: F32 + position_z: F32 + + +@dataclass +class PressureReturnParameters: + default_values: PaddedBool + channel: WEnum + pressure: U16 + + +@dataclass +class LiquidHeightReturnParameters: + default_values: PaddedBool + channel: WEnum + c_lld_detected: PaddedBool + c_lld_liquid_height: F32 + p_lld_detected: PaddedBool + p_lld_liquid_height: F32 + + +@dataclass +class DispenserVolumeReturnParameters: + default_values: PaddedBool + channel: WEnum + volume: F32 + + +@dataclass +class PotentiometerParameters: + default_values: PaddedBool + channel: WEnum + gain: PaddedU8 + offset: PaddedU8 + + +@dataclass +class YLLDSeekParameters: + default_values: PaddedBool + channel: WEnum + start_position_x: F32 + start_position_y: F32 + start_position_z: F32 + seek_position_y: F32 + seek_velocity_y: F32 + lld_sensitivity: WEnum + detect_mode: WEnum + + +@dataclass +class ChannelSeekParameters: + default_values: PaddedBool + channel: WEnum + seek_position_x: F32 + seek_position_y: F32 + seek_height: F32 + min_seek_height: F32 + final_position_z: F32 + + +@dataclass +class LLDChannelSeekParameters: + default_values: PaddedBool + channel: WEnum + seek_position_x: F32 + seek_position_y: F32 + seek_velocity_z: F32 + seek_height: F32 + min_seek_height: F32 + final_position_z: F32 + lld_sensitivity: WEnum + detect_mode: WEnum + + +@dataclass +class SeekResultParameters: + default_values: PaddedBool + channel: WEnum + detected: PaddedBool + position: F32 + + +@dataclass +class ChannelCounterParameters: + default_values: PaddedBool + channel: WEnum + tip_pickup_counter: U32 + tip_eject_counter: U32 + aspirate_counter: U32 + dispense_counter: U32 + + +@dataclass +class ChannelCalibrationParameters: + default_values: PaddedBool + channel: WEnum + dispenser_return_steps: U32 + squeeze_position: F32 + z_touchoff: F32 + z_tip_height: F32 + pressure_monitoring_shift: U32 + + +@dataclass +class LeakCheckSimpleParameters: + default_values: PaddedBool + channel: WEnum + time: F32 + high_pressure: PaddedBool + + +@dataclass +class LeakCheckParameters: + default_values: PaddedBool + channel: WEnum + start_position_x: F32 + start_position_y: F32 + start_position_z: F32 + seek_distance_y: F32 + pre_load_distance_y: F32 + final_z: F32 + tip_definition_id: PaddedU8 + test_time: F32 + high_pressure: PaddedBool + + +@dataclass +class DriveStatus: + initialized: PaddedBool + position: F32 + encoder_position: F32 + in_home_sensor: PaddedBool + + +@dataclass +class ChannelDriveStatus: + default_values: PaddedBool + channel: WEnum + y_axis_drive_status: Annotated[DriveStatus, Struct()] + z_axis_drive_status: Annotated[DriveStatus, Struct()] + dispenser_drive_status: Annotated[DriveStatus, Struct()] + squeeze_drive_status: Annotated[DriveStatus, Struct()] + + +@dataclass +class AspirateParametersNoLldAndMonitoring: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + + +@dataclass +class AspirateParametersNoLldAndTadm: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndMonitoring: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndTadm: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class DispenseParametersNoLld: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DispenseParametersLld: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DropTipParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_seek: F32 + z_tip: F32 + z_final: F32 + z_seek_speed: F32 + drop_type: WEnum + + +@dataclass +class InitTipDropParameters: + default_values: PaddedBool + x_position: F32 + rolloff_distance: F32 + channel_parameters: Annotated[list[DropTipParameters], StructArray()] + + +@dataclass +class DispenseInitToWasteParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class MoveAxisAbsoluteParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + position: F32 + delay: U32 + + +@dataclass +class MoveAxisRelativeParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + distance: F32 + delay: U32 + + +@dataclass +class LimitCurveEntry: + default_values: PaddedBool + sample: U16 + pressure: I16 + + +@dataclass +class TipPositionParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + z_seek: F32 + + @classmethod + def for_op( + cls, + channel: WEnum, + loc, + tip, + *, + z_seek_offset: Optional[float] = None, + ) -> TipPositionParameters: + """Build from an op location and tip (pickup). + + z_seek default: z_position + fitting_depth + 5mm guard (tip-type-aware, + comparable to Nimbus/Vantage). z_seek_offset: additive mm on top of + computed default (None = 0). + """ + z = loc.z + tip.total_tip_length - tip.fitting_depth + z_seek = z + tip.fitting_depth + 5.0 + (z_seek_offset or 0.0) + return cls( + default_values=False, + channel=channel, + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z_seek, + ) + + +@dataclass +class TipDropParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + z_seek: F32 + drop_type: WEnum + + @classmethod + def for_op( + cls, + channel: WEnum, + loc, + tip, + *, + z_seek_offset: Optional[float] = None, + drop_type: Optional[TipDropType] = None, + ) -> TipDropParameters: + """Build from an op location and tip (drop). + + z_position uses (total_tip_length - fitting_depth) so the tip bottom lands + at the spot surface (consistent with STAR and with pickup). + z_seek default: loc.z + total_tip_length + 5mm so tip bottom clears adjacent tips during + lateral approach. z_seek_offset: additive mm on top of computed default + (None = 0). + """ + z = loc.z + (tip.total_tip_length - tip.fitting_depth) + z_seek = loc.z + tip.total_tip_length + 10.0 + (z_seek_offset or 0.0) + return cls( + default_values=False, + channel=channel, + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z_seek, + drop_type=drop_type if drop_type is not None else TipDropType.FixedHeight, + ) + + +@dataclass +class TipHeightCalibrationParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_start: F32 + z_stop: F32 + z_final: F32 + volume: F32 + tip_type: WEnum + + +@dataclass +class DispenserVolumeEntry: + default_values: PaddedBool + type: WEnum + volume: F32 + + +@dataclass +class DispenserVolumeStackReturnParameters: + default_values: PaddedBool + channel: WEnum + total_volume: F32 + volumes: Annotated[list[DispenserVolumeEntry], StructArray()] + + +@dataclass +class SegmentDescriptor: + area_top: F32 + area_bottom: F32 + height: F32 + + +@dataclass +class AspirateParametersNoLldAndMonitoring2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + + +@dataclass +class AspirateParametersNoLldAndTadm2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndMonitoring2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndTadm2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class DispenseParametersNoLld2: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DispenseParametersLld2: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +# ============================================================================= +# PrepCommand base class +# ============================================================================= + + +@dataclass +class PrepCommand(TCPCommand): + """Base for all Prep instrument commands. + + Subclasses are dataclasses with ``dest: Address`` (inherited) plus any + ``Annotated`` payload fields. ``build_parameters()`` calls + ``HoiParams.from_struct(self)`` which serialises only ``Annotated`` fields, + so ``dest`` is automatically excluded from the wire payload. + """ + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return HoiParams.from_struct(self) + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map HoiResult entry → 0-indexed channel via the first per-channel struct-array field. + + Prep commands carry a ``StructArray`` of per-channel parameters whose + elements have a ``channel`` attribute (e.g. ``aspirate_parameters[i].channel``). + Entry N maps to the ``channel`` of element N. Commands without such a field + (``PrepGetPositions``, ``PrepIsParked``, …) fall back to the entry index. + """ + for f in fields(self): + value = getattr(self, f.name, None) + if not isinstance(value, list) or not value: + continue + if entry_index >= len(value): + continue + elem = value[entry_index] + channel = getattr(elem, "channel", None) + if channel is None: + continue + try: + return int(channel) + except (TypeError, ValueError): + continue + return entry_index + + +# ============================================================================= +# Pipettor / ChannelCoordinator command classes +# ============================================================================= + + +@dataclass +class PrepAspirateNoLldMonitoring(PrepCommand): + """Aspirate without LLD or monitoring (cmd=1, dest=Pipettor).""" + + command_id = 1 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateTadm(PrepCommand): + """Aspirate with TADM, no LLD (cmd=2, dest=Pipettor).""" + + command_id = 2 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] + + +@dataclass +class PrepAspirateWithLld(PrepCommand): + """Aspirate with LLD and monitoring (cmd=3, dest=Pipettor).""" + + command_id = 3 + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadm(PrepCommand): + """Aspirate with LLD and TADM (cmd=4, dest=Pipettor).""" + + command_id = 4 + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] + + +@dataclass +class PrepDispenseNoLld(PrepCommand): + """Dispense without LLD (cmd=5, dest=Pipettor).""" + + command_id = 5 + dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] + + +@dataclass +class PrepDispenseWithLld(PrepCommand): + """Dispense with LLD (cmd=6, dest=Pipettor).""" + + command_id = 6 + dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] + + +@dataclass +class PrepDispenseInitToWaste(PrepCommand): + """Dispense initialize to waste (cmd=7, dest=Pipettor).""" + + command_id = 7 + waste_parameters: Annotated[list[DispenseInitToWasteParameters], StructArray()] + + +@dataclass +class PrepPickUpTipsById(PrepCommand): + """Pick up tips by tip-definition ID (cmd=8, dest=Pipettor).""" + + command_id = 8 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition_id: PaddedU8 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpTips(PrepCommand): + """Pick up tips by tip-definition struct (cmd=9, dest=Pipettor).""" + + command_id = 9 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpNeedlesById(PrepCommand): + """Pick up needles by tip-definition ID (cmd=10, dest=Pipettor).""" + + command_id = 10 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition_id: PaddedU8 + blowout_offset: F32 + blowout_speed: F32 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpNeedles(PrepCommand): + """Pick up needles by tip-definition struct (cmd=11, dest=Pipettor).""" + + command_id = 11 + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + blowout_offset: F32 + blowout_speed: F32 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepDropTips(PrepCommand): + """Drop tips (cmd=12, dest=Pipettor).""" + + command_id = 12 + tip_positions: Annotated[list[TipDropParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + +@dataclass +class MphPickupTips(PrepCommand): + """Pick up tips via MPH coordinator (iface=1 id=9, dest=MphRoot.MPH). + + Resolved introspection signature: + PickupTips(tipParameters: struct(iface=1), finalZ: f32, + tipDefinition: struct(iface=1), tadm: bool, + dispenserVolume: f32, dispenserSpeed: f32, + tipMask: u32) -> { seekSpeed: List[u16] } + + The MPH takes a SINGLE struct (type_57) for tip_parameters, not a + StructArray (type_61) like the Pipettor. All 8 probes move as one unit; + tip_mask selects which channels engage. + """ + + command_id = 9 + tip_parameters: Annotated[TipPositionParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + tip_mask: U32 + + +@dataclass +class MphDropTips(PrepCommand): + """Drop tips via MPH coordinator (iface=1 id=12, dest=MphRoot.MPH). + + Resolved introspection signature: + DropTips(dropTipParameters: struct(iface=1), finalZ: f32, + tipRollOffDistance: f32) -> seekSpeed: List[u16] + + Single struct (type_57) for drop position — all probes drop together. + """ + + command_id = 12 + drop_parameters: Annotated[TipDropParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + +@dataclass +class PrepPickUpToolById(PrepCommand): + """Pick up tool by tip-definition ID (cmd=14, dest=Pipettor).""" + + command_id = 14 + tip_definition_id: PaddedU8 + tool_position_x: F32 + tool_position_z: F32 + front_channel_position_y: F32 + rear_channel_position_y: F32 + tool_seek: F32 + tool_x_radius: F32 + tool_y_radius: F32 + + +@dataclass +class PrepPickUpTool(PrepCommand): + """Pick up tool by tip-definition struct (cmd=15, dest=Pipettor).""" + + command_id = 15 + tip_definition: Annotated[TipPickupParameters, Struct()] + tool_position_x: F32 + tool_position_z: F32 + front_channel_position_y: F32 + rear_channel_position_y: F32 + tool_seek: F32 + tool_x_radius: F32 + tool_y_radius: F32 + + +@dataclass +class PrepDropTool(PrepCommand): + """Drop tool (cmd=16, dest=Pipettor).""" + + command_id = 16 + + +@dataclass +class PrepPickUpPlate(PrepCommand): + """Pick up plate (cmd=17, dest=Pipettor).""" + + command_id = 17 + plate_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + + +@dataclass +class PrepDropPlate(PrepCommand): + """Drop plate (cmd=18, dest=Pipettor).""" + + command_id = 18 + plate_top_center: Annotated[XYZCoord, Struct()] + clearance_y: F32 + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepMovePlate(PrepCommand): + """Move plate to position (cmd=19, dest=Pipettor).""" + + command_id = 19 + plate_top_center: Annotated[XYZCoord, Struct()] + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepTransferPlate(PrepCommand): + """Transfer plate from source to destination (cmd=20, dest=Pipettor).""" + + command_id = 20 + plate_source_top_center: Annotated[XYZCoord, Struct()] + plate_destination_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepReleasePlate(PrepCommand): + """Release plate / open gripper (cmd=21, dest=Pipettor).""" + + command_id = 21 + + +# CORE gripper tool definition for PrepPickUpTool (struct); matches instrument id=11. +CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS = TipPickupParameters( + default_values=False, + volume=1.0, + length=22.9, + tip_type=TipTypes.None_, + has_filter=False, + is_needle=False, + is_tool=True, +) + + +@dataclass +class PrepEmptyDispenser(PrepCommand): + """Empty dispenser (cmd=23, dest=Pipettor).""" + + command_id = 23 + channels: EnumArray + + +@dataclass +class PrepMoveToPosition(PrepCommand): + """Move to position (cmd=26, dest=Pipettor or ChannelCoordinator).""" + + command_id = 26 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMoveToPositionViaLane(PrepCommand): + """Move to position via lane (cmd=27, dest=Pipettor or ChannelCoordinator).""" + + command_id = 27 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepGetPositions(PrepCommand): + """GetPositions (cmd=25, dest=Pipettor). + + Returns the current XYZ position of each channel as a StructArray of + ChannelXYZPositionParameters. + """ + + command_id = 25 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + positions: Annotated[list[ChannelXYZPositionParameters], StructArray()] + + +@dataclass +class PrepMoveZUpToSafe(PrepCommand): + """Move Z axes up to safe height (cmd=28, dest=Pipettor).""" + + command_id = 28 + channels: EnumArray + + +@dataclass +class PrepZSeekLldPosition(PrepCommand): + """Z-seek LLD position (cmd=29, dest=Pipettor).""" + + command_id = 29 + seek_parameters: Annotated[list[LLDChannelSeekParameters], StructArray()] + + +@dataclass +class PrepCreateTadmLimitCurve(PrepCommand): + """Create TADM limit curve (cmd=31, dest=Pipettor).""" + + command_id = 31 + channel: U32 + name: Str + lower_limit: Annotated[list[LimitCurveEntry], StructArray()] + upper_limit: Annotated[list[LimitCurveEntry], StructArray()] + + +@dataclass +class PrepEraseTadmLimitCurves(PrepCommand): + """Erase TADM limit curves for a channel (cmd=32, dest=Pipettor).""" + + command_id = 32 + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveNames(PrepCommand): + """Get TADM limit curve names for a channel (cmd=33, dest=Pipettor).""" + + command_id = 33 + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveInfo(PrepCommand): + """Get TADM limit curve info (cmd=34, dest=Pipettor).""" + + command_id = 34 + channel: U32 + name: Str + + +@dataclass +class PrepRetrieveTadmData(PrepCommand): + """Retrieve TADM data for a channel (cmd=35, dest=Pipettor).""" + + command_id = 35 + channel: U32 + + +@dataclass +class PrepResetTadmFifo(PrepCommand): + """Reset TADM FIFO (cmd=36, dest=Pipettor).""" + + command_id = 36 + channels: EnumArray + + +@dataclass +class PrepAspirateNoLldMonitoringV2(PrepCommand): + """Aspirate v2 without LLD or monitoring (cmd=38, dest=Pipettor).""" + + command_id = 38 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateTadmV2(PrepCommand): + """Aspirate v2 with TADM, no LLD (cmd=39, dest=Pipettor).""" + + command_id = 39 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] + + +@dataclass +class PrepAspirateWithLldV2(PrepCommand): + """Aspirate v2 with LLD and monitoring (cmd=40, dest=Pipettor).""" + + command_id = 40 + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadmV2(PrepCommand): + """Aspirate v2 with LLD and TADM (cmd=41, dest=Pipettor).""" + + command_id = 41 + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] + + +@dataclass +class PrepDispenseNoLldV2(PrepCommand): + """Dispense v2 without LLD (cmd=42, dest=Pipettor).""" + + command_id = 42 + dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] + + +@dataclass +class PrepDispenseWithLldV2(PrepCommand): + """Dispense v2 with LLD (cmd=43, dest=Pipettor).""" + + command_id = 43 + dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] + + +# ============================================================================= +# MLPrep command classes +# ============================================================================= + + +@dataclass +class PrepInitialize(PrepCommand): + """Initialize MLPrep (cmd=1, dest=MLPrep).""" + + command_id = 1 + smart: PaddedBool + tip_drop_params: Annotated[InitTipDropParameters, Struct()] + + +@dataclass +class PrepGetIsInitialized(PrepCommand): + """Query whether MLPrep is initialized. Firmware yaml: [1:2] GetIsInitialized(void) -> value: bool.""" + + command_id = 2 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepPark(PrepCommand): + """Park MLPrep (cmd=3, dest=MLPrep).""" + + command_id = 3 + + +@dataclass +class PrepSpread(PrepCommand): + """Spread channels (cmd=4, dest=MLPrep).""" + + command_id = 4 + + +@dataclass +class PrepAddTipAndNeedleDefinition(PrepCommand): + """Add tip/needle definition (cmd=12, dest=MLPrep).""" + + command_id = 12 + tip_definition: Annotated[TipDefinition, Struct()] + + +@dataclass +class PrepRemoveTipAndNeedleDefinition(PrepCommand): + """Remove tip/needle definition by ID (cmd=13, dest=MLPrep).""" + + command_id = 13 + id_: WEnum + + +@dataclass +class PrepReadStorage(PrepCommand): + """Read from instrument storage (cmd=14, dest=MLPrep).""" + + command_id = 14 + offset: U32 + length: U32 + + +@dataclass +class PrepWriteStorage(PrepCommand): + """Write to instrument storage (cmd=15, dest=MLPrep).""" + + command_id = 15 + offset: U32 + data: U8Array + + +@dataclass +class PrepPowerDownRequest(PrepCommand): + """Request power down (cmd=17, dest=MLPrep).""" + + command_id = 17 + + +@dataclass +class PrepConfirmPowerDown(PrepCommand): + """Confirm power down (cmd=18, dest=MLPrep).""" + + command_id = 18 + + +@dataclass +class PrepCancelPowerDown(PrepCommand): + """Cancel power down (cmd=19, dest=MLPrep).""" + + command_id = 19 + + +@dataclass +class PrepRemoveChannelPower(PrepCommand): + """Remove channel power for head swap (cmd=23, dest=MLPrep).""" + + command_id = 23 + + +@dataclass +class PrepRestoreChannelPower(PrepCommand): + """Restore channel power after head swap (cmd=24, dest=MLPrep).""" + + command_id = 24 + delay_ms: U32 + + +@dataclass +class PrepSetDeckLight(PrepCommand): + """Set deck LED colour (cmd=25, dest=MLPrep).""" + + command_id = 25 + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + +@dataclass +class PrepGetDeckLight(PrepCommand): + """Get deck LED colour (cmd=26, dest=MLPrep).""" + + command_id = 26 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + +@dataclass +class PrepSuspendedPark(PrepCommand): + """Suspended park / move to load position (cmd=29, dest=MLPrep).""" + + command_id = 29 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMethodBegin(PrepCommand): + """Begin method (cmd=30, dest=MLPrep).""" + + command_id = 30 + automatic_pause: PaddedBool + + +@dataclass +class PrepMethodEnd(PrepCommand): + """End method (cmd=31, dest=MLPrep).""" + + command_id = 31 + + +@dataclass +class PrepMethodAbort(PrepCommand): + """Abort method (cmd=33, dest=MLPrep).""" + + command_id = 33 + + +@dataclass +class PrepIsParked(PrepCommand): + """Query parked status (cmd=34, dest=MLPrep). Firmware yaml: IsParked(void) -> parked: bool.""" + + command_id = 34 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepIsSpread(PrepCommand): + """Query spread status (cmd=35, dest=MLPrep). Firmware yaml: IsSpread(void) -> parked: bool.""" + + command_id = 35 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +# ----------------------------------------------------------------------------- +# Wire structs for config responses (used by nested Response and InstrumentConfig) +# ----------------------------------------------------------------------------- + + +@dataclass +class _DeckSiteDefinitionWire: + """Wire shape for one DeckSiteDefinition (GetDeckSiteDefinitions element).""" + + default_values: PaddedBool + id: U32 + left_bottom_front_x: F32 + left_bottom_front_y: F32 + left_bottom_front_z: F32 + length: F32 + width: F32 + height: F32 + + +@dataclass +class _CalibrationSiteDefinitionWire: + """Wire shape for one CalibrationSiteDefinition (GetCalibrationSiteDefinitions element). + + Same fields as DeckSiteDefinition plus trailing Post (BOOL). + """ + + default_values: PaddedBool + id: U32 + left_bottom_front_x: F32 + left_bottom_front_y: F32 + left_bottom_front_z: F32 + length: F32 + width: F32 + height: F32 + post: PaddedBool + + +@dataclass +class _ChannelHardwareConfigWire: + """Wire shape for ChannelHardwareConfig (GetChannelHardwareConfiguration element).""" + + channel: WEnum # ChannelIndex + hardware: WEnum # Hardware type enum (interface 2, id 1) + + +@dataclass +class _ChannelCalibrationValuesWire: + """Wire shape for ChannelCalibrationValues (GetCalibrationValues element).""" + + index: WEnum # ChannelIndex + y_offset: F32 + z_offset: F32 + squeeze_position: U32 + z_touchoff: U32 + pressure_shift: U32 + pressure_monitoring_shift: U32 + dispenser_return_distance: F32 + z_tip_height: F32 + core_ii: PaddedBool + + +@dataclass +class _WasteSiteDefinitionWire: + """Wire shape for one WasteSiteDefinition (GetWasteSiteDefinitions element).""" + + default_values: PaddedBool + index: WEnum + x_position: I8 + y_position: U16 + z_position: F32 + z_seek: F32 + + +# ----------------------------------------------------------------------------- +# Config queries (MLPrep / DeckConfiguration) for _get_hardware_config +# ----------------------------------------------------------------------------- + + +@dataclass +class _PrepStatusQuery(PrepCommand): + """Base for MLPrep status queries: STATUS_REQUEST (0), no params.""" + + action_code = 0 + + +@dataclass +class PrepGetIsEnclosurePresent(_PrepStatusQuery): + """GetIsEnclosurePresent (cmd=21, dest=MLPrep). Firmware yaml: -> value: bool.""" + + command_id = 21 + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepGetSafeSpeedsEnabled(_PrepStatusQuery): + """GetSafeSpeedsEnabled (cmd=28, dest=MLPrep). Firmware yaml: -> value: bool.""" + + command_id = 28 + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepGetDefaultTraverseHeight(_PrepStatusQuery): + """GetDefaultTraverseHeight (cmd=10, dest=MLPrep). Returns F32.""" + + command_id = 10 + + @dataclass(frozen=True) + class Response: + value: F32 + + +@dataclass +class PrepGetTipAndNeedleDefinitions(_PrepStatusQuery): + """GetTipAndNeedleDefinitions (cmd=11, dest=MLPrep). + + Returns the list of tip/needle definitions registered on the instrument. + Introspection: iface=1 id=11 GetTipAndNeedleDefinitions(value: type_64) -> void + (response carries STRUCTURE_ARRAY of tip definition structs). + """ + + command_id = 11 + + @dataclass(frozen=True) + class Response: + definitions: Annotated[list[TipDefinition], StructArray()] + + +@dataclass +class PrepGetDeckBounds(_PrepStatusQuery): + """GetDeckBounds (cmd=1, dest=DeckConfiguration). Returns 6× F32 (min/max x,y,z).""" + + command_id = 1 + + @dataclass(frozen=True) + class Response: + min_x: F32 + max_x: F32 + min_y: F32 + max_y: F32 + min_z: F32 + max_z: F32 + + +@dataclass +class PrepGetCalibrationSiteDefinitions(_PrepStatusQuery): + """GetCalibrationSiteDefinitions (cmd=3, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of CalibrationSiteDefinition structs: + DefaultValues: BOOL, Id: U32, LeftBottomFrontX/Y/Z: F32, Length, Width, Height: F32, Post: BOOL + """ + + command_id = 3 + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_CalibrationSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetDeckSiteDefinitions(_PrepStatusQuery): + """GetDeckSiteDefinitions (cmd=7, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of DeckSiteDefinition structs: + DefaultValues: BOOL, Id: U32, LeftBottomFrontX: F32, LeftBottomFrontY: F32, + LeftBottomFrontZ: F32, Length: F32, Width: F32, Height: F32 + """ + + command_id = 7 + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_DeckSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetWasteSiteDefinitions(_PrepStatusQuery): + """GetWasteSiteDefinitions (cmd=12, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of WasteSiteDefinition structs: + DefaultValues: BOOL, Index: ENUM, XPosition: I8, YPosition: U16, + ZPosition: F32, ZSeek: F32 + """ + + command_id = 12 + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_WasteSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetChannelBounds(PrepCommand): + """GetChannelBounds (cmd=10, dest=PipettorService). + + Returns per-channel movement bounds (x_min, x_max, y_min, y_max, z_min, z_max) + as a StructArray of ChannelBoundsParameters. + """ + + command_id = 10 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + bounds: Annotated[list[ChannelBoundsParameters], StructArray()] + + +@dataclass +class PrepGetPresentChannels(_PrepStatusQuery): + """GetPresentChannels (cmd=17, dest=MLPrepService). + + Returns a list of enum values (iface=1, id=5): which channels are present. + Map to ChannelIndex: 0=InvalidIndex, 1=FrontChannel, 2=RearChannel, 3=MPHChannel. + Use this to determine hardware configuration: 1 vs 2 channels, or 8MPH presence. + """ + + command_id = 17 + + @dataclass(frozen=True) + class Response: + channels: EnumArray # list of ints: map to ChannelIndex for present channels + + +# ----------------------------------------------------------------------------- +# MLPrepCalibration commands +# ----------------------------------------------------------------------------- + + +@dataclass +class PrepBeginCalibration(PrepCommand): + """BeginCalibration (cmd=1, dest=MLPrepCalibration). Enter calibration mode.""" + + command_id = 1 + + +@dataclass +class PrepCancelCalibration(PrepCommand): + """CancelCalibration (cmd=2, dest=MLPrepCalibration). Cancel active calibration session.""" + + command_id = 2 + + +@dataclass +class PrepEndCalibration(PrepCommand): + """EndCalibration (cmd=3, dest=MLPrepCalibration). End calibration and store results with timestamp.""" + + command_id = 3 + date_time: Annotated[HoiDateTime, Struct()] + + +@dataclass +class PrepResetCalibration(PrepCommand): + """ResetCalibration (cmd=4, dest=MLPrepCalibration). Reset calibration data, optionally storing.""" + + command_id = 4 + store: PaddedBool + + +@dataclass +class PrepCalibrationInitialize(PrepCommand): + """CalibrationInitialize (cmd=5, dest=MLPrepCalibration). Initialize calibration hardware.""" + + command_id = 5 + + +@dataclass +class NeedleDefinition: + """Wire shape for NeedleDefinition (MLPrepCalibration local struct, id=2). + + When default_values=True the firmware uses stored defaults for all fields. + TipDefinition is nested (global pool source_id=1, ref_id=8). + """ + + default_values: PaddedBool + x_position: F32 + y_position: F32 + z_start: F32 + z_stop: F32 + tip_definition: Annotated[TipDefinition, Struct()] + tip_mask: U32 + + @classmethod + def defaults(cls) -> "NeedleDefinition": + """Return an all-defaults instance (firmware fills in stored values).""" + return cls( + default_values=True, + x_position=0.0, + y_position=0.0, + z_start=0.0, + z_stop=0.0, + tip_definition=TipDefinition( + default_values=True, + id=0, + volume=0.0, + length=0.0, + tip_type=0, + has_filter=False, + is_needle=False, + is_tool=False, + label="", + ), + tip_mask=0, + ) + + +@dataclass +class PrepSelfCalibrate(PrepCommand): + """SelfCalibrate (cmd=6, dest=MLPrepCalibration). + + Runs a full self-calibration sequence. Set individual booleans to select + which calibration phases to run. Pass NeedleDefinition.defaults() to use + firmware-stored needle parameters. + """ + + command_id = 6 + site_index: U32 + channels: WEnum # ChannelIndex + axis: PaddedBool + pressure: PaddedBool + touchoff: PaddedBool + needle: Annotated[NeedleDefinition, Struct()] + + +@dataclass +class PrepCalibrateXAxis(PrepCommand): + """CalibrateXAxis (cmd=7, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 7 + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + +@dataclass +class PrepCalibrateYAxis(PrepCommand): + """CalibrateYAxis (cmd=8, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 8 + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + +@dataclass +class PrepCalibrateZAxis(PrepCommand): + """CalibrateZAxis (cmd=9, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 9 + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + +@dataclass +class PrepCalibrateSqueeze(PrepCommand): + """CalibrateSqueeze (cmd=14, dest=MLPrepCalibration). Returns position: U32.""" + + command_id = 14 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + position: U32 + + +@dataclass +class PrepCalibrateSqueezeTips(PrepCommand): + """CalibrateSqueezeTips (cmd=15, dest=MLPrepCalibration). + + Takes per-channel TipPositionParameters (same struct as pick_up_tips) and + returns per-channel squeeze positions as a list of u32. + """ + + command_id = 15 + channels: Annotated[list[TipPositionParameters], StructArray()] + + @dataclass(frozen=True) + class Response: + positions: U32Array + + +@dataclass +class PrepGetCalibrationValues(_PrepStatusQuery): + """GetCalibrationValues (cmd=16, dest=MLPrepCalibration). + + Returns independentOffsetX (F32), mphOffsetX (F32), and per-channel + calibration values as a StructArray of ChannelCalibrationValues. + """ + + command_id = 16 + + @dataclass(frozen=True) + class Response: + independent_offset_x: F32 + mph_offset_x: F32 + channel_values: Annotated[list[_ChannelCalibrationValuesWire], StructArray()] + + +@dataclass +class PrepGetChannelHardwareConfiguration(_PrepStatusQuery): + """GetChannelHardwareConfiguration (cmd=24, dest=MLPrepCalibration). + + Response is a StructArray of ChannelHardwareConfig: Channel (enum) + Hardware (enum). + """ + + command_id = 24 + + @dataclass(frozen=True) + class Response: + channels: Annotated[list[_ChannelHardwareConfigWire], StructArray()] diff --git a/pylabrobot/hamilton/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py index 35658d754b5..6b04b576391 100644 --- a/pylabrobot/hamilton/tcp/__init__.py +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -1,7 +1,23 @@ -"""Shared Hamilton TCP protocol layer for TCP-based instruments (Nimbus, Prep, etc.).""" +"""Canonical v1 Hamilton TCP namespace.""" -from pylabrobot.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection +from pylabrobot.hamilton.tcp.client import HamiltonTCPClient +from pylabrobot.hamilton.tcp.commands import TCPCommand +from pylabrobot.hamilton.tcp.hoi_error import ( + HoiError, + parse_hamilton_error_entries, + parse_hamilton_error_entry, + parse_hamilton_error_params, +) +from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs +from pylabrobot.hamilton.tcp.introspection import ( + Direction, + FirmwareTreeNode, + MethodInfo, + MethodParamType, + ObjectInfo, + StructFieldType, + flatten_firmware_tree, +) from pylabrobot.hamilton.tcp.messages import ( CommandMessage, CommandResponse, @@ -12,13 +28,63 @@ RegistrationMessage, RegistrationResponse, ) -from pylabrobot.hamilton.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket +from pylabrobot.hamilton.tcp.packets import ( + Address, + ConnectionPacket, + HarpPacket, + HoiPacket, + IpPacket, + RegistrationPacket, +) from pylabrobot.hamilton.tcp.protocol import ( - Hoi2Action, - HamiltonDataType, + HAMILTON_PROTOCOL_VERSION_MAJOR, + HAMILTON_PROTOCOL_VERSION_MINOR, HamiltonProtocol, HarpTransportableProtocol, + Hoi2Action, HoiRequestId, RegistrationActionCode, RegistrationOptionType, ) +from pylabrobot.hamilton.tcp.wire_types import HamiltonDataType + +__all__ = [ + "Address", + "InterfacePathSpec", + "resolve_interface_path_specs", + "CommandMessage", + "CommandResponse", + "ConnectionPacket", + "Direction", + "FirmwareTreeNode", + "MethodParamType", + "StructFieldType", + "flatten_firmware_tree", + "HAMILTON_PROTOCOL_VERSION_MAJOR", + "HAMILTON_PROTOCOL_VERSION_MINOR", + "HoiError", + "parse_hamilton_error_entries", + "parse_hamilton_error_entry", + "parse_hamilton_error_params", + "TCPCommand", + "HamiltonTCPClient", + "HamiltonDataType", + "HamiltonProtocol", + "HarpPacket", + "HarpTransportableProtocol", + "Hoi2Action", + "HoiPacket", + "HoiParams", + "HoiParamsParser", + "HoiRequestId", + "InitMessage", + "InitResponse", + "IpPacket", + "MethodInfo", + "ObjectInfo", + "RegistrationActionCode", + "RegistrationMessage", + "RegistrationOptionType", + "RegistrationPacket", + "RegistrationResponse", +] diff --git a/pylabrobot/hamilton/tcp/client.py b/pylabrobot/hamilton/tcp/client.py new file mode 100644 index 00000000000..333ccc82a8d --- /dev/null +++ b/pylabrobot/hamilton/tcp/client.py @@ -0,0 +1,731 @@ +"""Hamilton TCP client for TCP-based instruments (Nimbus, Prep, etc.). + +Use :attr:`HamiltonTCPClient.introspection` as the **only** supported entry for +Interface-0 discovery and type work. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Any, Callable, ClassVar, Dict, Optional, Sequence, Tuple, Union, cast + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError +from pylabrobot.device import Driver +from pylabrobot.hamilton.tcp.commands import TCPCommand, hamilton_error_for_entry +from pylabrobot.hamilton.tcp.error_tables import HC_RESULT_PROTOCOL +from pylabrobot.hamilton.tcp.hoi_error import ( + HoiError, + parse_hamilton_error_entries, + parse_hamilton_error_params, +) +from pylabrobot.hamilton.tcp.introspection import ( + HamiltonIntrospection, + MethodDescriptor, + ObjectRegistry, +) +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, +) +from pylabrobot.hamilton.tcp.wire_types import HcResultEntry +from pylabrobot.io.binary import Reader +from pylabrobot.io.socket import Socket + +logger = logging.getLogger(__name__) + + +class HamiltonTCPClient(Driver): + """Standalone transport + discovery/introspection client for Hamilton TCP devices.""" + + _ERROR_CODES: ClassVar[Dict[Tuple[int, int, int, int, int], str]] = {} + + def __init__( + self, + host: str, + port: int, + read_timeout: float = 300.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + connection_timeout: int = 600, + ): + super().__init__() + + self.io = Socket( + human_readable_device_name="Hamilton Liquid Handler", + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + + self._connected = False + self._reconnect_attempts = 0 + self.auto_reconnect = auto_reconnect + self.max_reconnect_attempts = max_reconnect_attempts + self._connection_timeout = connection_timeout + + self._client_id: Optional[int] = None + self.client_address: Optional[Address] = None + self._sequence_numbers: Dict[Address, int] = {} + self._instrument_addresses: Dict[str, Address] = {} + self._registry = ObjectRegistry() + self._global_object_addresses: list[Address] = [] + self._event_handlers: list[Callable[[CommandResponse], None]] = [] + self._introspection_impl: Optional[HamiltonIntrospection] = None + + @property + def registry(self) -> ObjectRegistry: + """Object path registry for this session.""" + return self._registry + + @property + def global_object_addresses(self) -> Sequence[Address]: + """Global object addresses discovered during :meth:`setup` (read-only).""" + return tuple(self._global_object_addresses) + + def get_root_object_addresses(self) -> list[Address]: + """Root address from the registry as a single-element list.""" + addr = self._registry.get_root_address() + return [addr] if addr is not None else [] + + @property + def introspection(self) -> HamiltonIntrospection: + """Lazy Interface-0 / type introspection facet (canonical entry).""" + if self._introspection_impl is None: + self._introspection_impl = HamiltonIntrospection( + registry=self._registry, + global_object_addresses=self._global_object_addresses, + send_discovery_command=self.send_discovery_command, + send_query=self.send_query, + ) + return self._introspection_impl + + def _invalidate_introspection_session(self) -> None: + self._introspection_impl = None + + async def _describe_entry(self, entry: HcResultEntry) -> Tuple[Optional[str], str]: + """Resolve an HcResultEntry to (interface_name, description) for error reporting.""" + addr = Address(entry.module_id, entry.node_id, entry.object_id) + iface_name = await self.introspection.get_interface_name(addr, entry.interface_id) + # Vendor tables key on (module, node, object_id, interface_id, hc_result). The wire + # action_id is the failing method id and must not be used for that slot — otherwise + # we miss lookups and show raw HC_RESULT=0x.... instead of "No Tip Picked Up." / etc. + key_iface = (entry.module_id, entry.node_id, entry.object_id, entry.interface_id, entry.result) + key_action = (entry.module_id, entry.node_id, entry.object_id, entry.action_id, entry.result) + desc = self._ERROR_CODES.get(key_iface) + if desc is None and key_action != key_iface: + desc = self._ERROR_CODES.get(key_action) + if desc is None: + desc = HC_RESULT_PROTOCOL.get(entry.result) + if desc is None: + desc = await self.introspection.get_hc_result_text(addr, entry.interface_id, entry.result) + if desc is None: + desc = f"HC_RESULT=0x{entry.result:04X}" + return iface_name, desc + + async def _format_entry_context(self, entry: HcResultEntry) -> Optional[str]: + """Resolve an HcResultEntry to a human-readable method context string.""" + addr = Address(entry.module_id, entry.node_id, entry.object_id) + path = self._registry.path(addr) + path_part = f"path={path}" if path else "path=?" + descriptor = await self._lookup_method_descriptor(addr, entry.interface_id, entry.action_id) + if descriptor is None: + return f"{path_part}, addr={addr}, iface={entry.interface_id}, action={entry.action_id}" + return f"{path_part}, addr={addr}, method={descriptor.id_string} {descriptor.signature_string()}" + + async def _lookup_method_descriptor( + self, addr: Address, interface_id: int, action_id: int + ) -> Optional[MethodDescriptor]: + try: + method = await self.introspection.get_method_by_id(addr, interface_id, action_id) + if method is None: + return None + return method.describe(None) + except Exception as exc: + logger.debug( + "Method descriptor lookup failed for %s iface=%d action=%d: %s", + addr, + interface_id, + action_id, + exc, + ) + return None + + def on_event(self, callback: Callable[[CommandResponse], None]) -> Callable[[], None]: + """Register a callback for ``Hoi2Action.EVENT`` frames. + + Returns an unsubscribe function. Callback exceptions are logged and swallowed. + """ + self._event_handlers.append(callback) + + def _unsubscribe() -> None: + try: + self._event_handlers.remove(callback) + except ValueError: + pass + + return _unsubscribe + + def _dispatch_event(self, response_message: CommandResponse) -> None: + for handler in list(self._event_handlers): + try: + handler(response_message) + except Exception as exc: + logger.exception("Event handler %r raised: %s", handler, exc) + + def _clear_session_state_for_setup(self) -> None: + self._global_object_addresses = [] + self._invalidate_introspection_session() + + async def _ensure_connected(self): + 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): + 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}" + ) + + try: + await self.stop() + except Exception: + pass + + if attempt > 0: + wait_time = 1.0 * (2 ** (attempt - 1)) + await asyncio.sleep(wait_time) + + 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}") + + 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): + 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: + await self._ensure_connected() + + try: + data = await self.io.read(num_bytes, timeout=timeout) + self._connected = True + return cast(bytes, data) + except (ConnectionError, OSError, TimeoutError): + self._connected = False + raise + + async def read_exact(self, num_bytes: int, timeout: Optional[float] = None) -> bytes: + await self._ensure_connected() + + try: + data = await self.io.read_exact(num_bytes, timeout=timeout) + self._connected = True + return cast(bytes, data) + except (ConnectionError, OSError, TimeoutError): + self._connected = False + raise + + @property + def is_connected(self) -> bool: + return self._connected + + async def _read_one_message( + self, timeout: Optional[float] = None + ) -> Union[RegistrationResponse, CommandResponse]: + size_data = await self.read_exact(2, timeout=timeout) + packet_size = Reader(size_data).u16() + + payload_data = await self.read_exact(packet_size, timeout=timeout) + complete_data = size_data + payload_data + + ip_protocol = complete_data[2] + + if ip_protocol == 6: + ip_options_len = int.from_bytes(complete_data[4:6], "little") + harp_start = 6 + ip_options_len + harp_protocol_offset = harp_start + 14 + harp_protocol = complete_data[harp_protocol_offset] + + if harp_protocol == 2: + resp = CommandResponse.from_bytes(complete_data) + if resp.hoi.action_code == Hoi2Action.EVENT and self._event_handlers: + self._dispatch_event(resp) + return resp + if harp_protocol == 3: + 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, backend_params: Optional[BackendParams] = None): + del backend_params + self._clear_session_state_for_setup() + await self.io.setup() + self._connected = True + self._reconnect_attempts = 0 + await self._initialize_connection() + await self._register_client() + await self._discover_root() + await self._discover_globals() + + root_addr = self._registry.get_root_address() + if root_addr is not None: + root_info = await self.introspection.get_object(root_addr) + root_info.children = {} + self._registry.register(root_info.name, root_info) + + logger.info( + "Hamilton TCP client setup complete. Client ID: %s, globals: %d", + self._client_id, + len(self._global_object_addresses), + ) + + async def _initialize_connection(self): + logger.info("Initializing Hamilton connection...") + + packet = InitMessage(timeout=self._connection_timeout).build() + await self.write(packet) + + size_data = await self.read_exact(2) + packet_size = Reader(size_data).u16() + payload_data = await self.read_exact(packet_size) + response_bytes = size_data + payload_data + response = InitResponse.from_bytes(response_bytes) + + self._client_id = response.client_id + self.client_address = Address(2, response.client_id, 65535) + + async def _register_client(self): + logger.info("Registering Hamilton client...") + registration_service = Address(0, 0, 65534) + + reg_msg = RegistrationMessage( + dest=registration_service, action_code=RegistrationActionCode.REGISTRATION_REQUEST + ) + + 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 = reg_msg.build( + src=self.client_address, + req_addr=Address(2, self._client_id, 65535), + res_addr=Address(0, 0, 0), + seq=seq, + harp_action_code=3, + harp_response_required=False, + ) + + await self.write(packet) + await self._read_one_message() + + async def _discover_root(self): + logger.info("Discovering Hamilton root objects...") + + registration_service = Address(0, 0, 65534) + 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, + ) + + 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, + harp_response_required=True, + ) + + await self.write(packet) + response = await self._read_one_message() + assert isinstance(response, RegistrationResponse) + + root_objects = self._parse_registration_response(response) + if len(root_objects) != 1: + raise RuntimeError( + f"Expected exactly one root object from discovery, got {len(root_objects)}: {root_objects}" + ) + self._registry.set_root_address(root_objects[0]) + + async def _discover_globals(self) -> None: + logger.info("Discovering Hamilton global objects...") + registration_service = Address(0, 0, 65534) + global_msg = RegistrationMessage( + dest=registration_service, action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST + ) + global_msg.add_registration_option( + RegistrationOptionType.HARP_PROTOCOL_REQUEST, + protocol=2, + request_id=HoiRequestId.GLOBAL_OBJECT_ADDRESS, + ) + + 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 = global_msg.build( + src=self.client_address, + req_addr=Address(0, 0, 0), + res_addr=Address(0, 0, 0), + seq=seq, + harp_action_code=3, + harp_response_required=True, + ) + + await self.write(packet) + response = await self._read_one_message() + assert isinstance(response, RegistrationResponse) + self._global_object_addresses = self._parse_registration_response(response) + + def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: + objects: list[Address] = [] + options_data = response.registration.options + + if not options_data: + logger.debug("No options in registration response (no objects found)") + return objects + + 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: + _ = reader.u16() + num_objects = (length - 2) // 2 + for _ in range(num_objects): + object_id = reader.u16() + objects.append(Address(1, 1, object_id)) + else: + logger.warning(f"Unknown registration option ID: {option_id}, skipping {length} bytes") + reader.raw_bytes(length) + + return objects + + def _allocate_sequence_number(self, dest_address: Address) -> int: + current = self._sequence_numbers.get(dest_address, 0) + next_seq = (current + 1) % 256 + self._sequence_numbers[dest_address] = next_seq + return next_seq + + async def send_command( + self, + command: TCPCommand, + *, + read_timeout: Optional[float] = None, + ) -> Any: + """Send a command and return the interpreted response. Raises on any firmware error.""" + return await self._send_raw( + command, + ensure_connection=True, + return_raw=False, + raise_on_error=True, + read_timeout=read_timeout, + ) + + async def send_query( + self, + command: TCPCommand, + *, + read_timeout: Optional[float] = None, + ) -> Optional[tuple]: + """Send a read/status command and return raw HOI bytes. Returns None on firmware error. + + Use for hardware state probing where the response needs manual parsing or where + the firmware path may legitimately return an error (e.g. tip-presence checks). + Follows SCPI convention: queries read state, commands change state. + """ + return cast( + Optional[tuple], + await self._send_raw( + command, + ensure_connection=True, + return_raw=True, + raise_on_error=False, + read_timeout=read_timeout, + ), + ) + + async def send_discovery_command( + self, + command: TCPCommand, + *, + read_timeout: Optional[float] = None, + ) -> Any: + """Send an Interface-0 introspection command during setup (no reconnect on failure).""" + return await self._send_raw( + command, + ensure_connection=False, + return_raw=False, + raise_on_error=True, + read_timeout=read_timeout, + ) + + async def _send_raw( + self, + command: TCPCommand, + *, + ensure_connection: bool, + return_raw: bool, + raise_on_error: bool, + read_timeout: Optional[float] = None, + ) -> Any: + connection_errors = ( + BrokenPipeError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + TimeoutError, + OSError, + ) + max_attempts = 2 if ensure_connection else 1 + last_error: Optional[BaseException] = None + + for attempt in range(max_attempts): + try: + if command.source_address is None: + if self.client_address is None: + raise RuntimeError( + "Client not initialized - call setup() first to assign client_address" + ) + command.source_address = self.client_address + + command.sequence_number = self._allocate_sequence_number(command.dest_address) + message = command.build() + + log_params = command.get_log_params() + logger.debug(f"{command.__class__.__name__} parameters: {log_params}") + + await self.write(message) + + while True: + response_message = await self._read_one_message(timeout=read_timeout) + assert isinstance(response_message, CommandResponse) + action = Hoi2Action(response_message.hoi.action_code) + if action is Hoi2Action.COMMAND_ACK: + logger.debug( + "%s COMMAND_ACK from %s; awaiting terminal response", + command.__class__.__name__, + response_message.harp.src, + ) + continue + if action is Hoi2Action.EVENT: + logger.debug( + "%s EVENT from %s; skipping past to await terminal response", + command.__class__.__name__, + response_message.harp.src, + ) + continue + break + + if action in ( + Hoi2Action.STATUS_EXCEPTION, + Hoi2Action.COMMAND_EXCEPTION, + Hoi2Action.INVALID_ACTION_RESPONSE, + ): + entries = parse_hamilton_error_entries(response_message.hoi.params) + if not entries: + raw = parse_hamilton_error_params(response_message.hoi.params) + enriched_msg = f"Hamilton error {action.name} (action={action:#x}): {raw}" + if raise_on_error: + logger.error(enriched_msg) + raise RuntimeError(enriched_msg) + logger.debug(enriched_msg) + return None + + if command.error_entries_use_physical_channels(): + per_channel: Dict[int, Exception] = {} + context_by_channel: Dict[int, Optional[str]] = {} + hoi_exceptions: Dict[int, Exception] = {} + for idx, entry in enumerate(entries): + _iface_name, desc = await self._describe_entry(entry) + err = hamilton_error_for_entry(entry, desc) + hoi_exceptions[idx] = err + channel = command._channel_index_for_entry(idx, entry) + if channel is None: + channel = idx + per_channel.setdefault(channel, err) + if channel not in context_by_channel: + context_by_channel[channel] = await self._format_entry_context(entry) + + if raise_on_error: + channel_summary = ", ".join( + ( + f"ch{ch}: {per_channel[ch]} ({context_by_channel[ch]})" + if context_by_channel.get(ch) + else f"ch{ch}: {per_channel[ch]}" + ) + for ch in sorted(per_channel) + ) + logger.error( + "Hamilton %s (action=%#x) on %d channel(s): %s", + action.name, + action, + len(per_channel), + channel_summary, + ) + raise ChannelizedError( + errors=per_channel, + raw_response=response_message.hoi.params, + hoi_entries=list(entries), + hoi_exceptions=hoi_exceptions, + ) + logger.debug( + "Hamilton %s (action=%#x) suppressed; entries=%d (raise_on_error=False)", + action.name, + action, + len(entries), + ) + return None + + entry_errors: Dict[int, Exception] = {} + context_by_idx: Dict[int, Optional[str]] = {} + for idx, entry in enumerate(entries): + _iface_name, desc = await self._describe_entry(entry) + err = hamilton_error_for_entry(entry, desc) + entry_errors[idx] = err + context_by_idx[idx] = await self._format_entry_context(entry) + + if raise_on_error: + summary = ", ".join( + ( + f"entry[{idx}]: {entry_errors[idx]} ({context_by_idx[idx]})" + if context_by_idx.get(idx) + else f"entry[{idx}]: {entry_errors[idx]}" + ) + for idx in sorted(entry_errors) + ) + logger.error( + "Hamilton %s (action=%#x), instrument-wide error (%d entries): %s", + action.name, + action, + len(entries), + summary, + ) + raise HoiError( + exceptions=entry_errors, + entries=list(entries), + raw_response=response_message.hoi.params, + ) + logger.debug( + "Hamilton %s (action=%#x) suppressed; entries=%d (raise_on_error=False)", + action.name, + action, + len(entries), + ) + return None + + if return_raw: + return (response_message.hoi.params,) + + result = command.interpret_response(response_message) + fatal = command.fatal_entries_by_channel(response_message) + if fatal: + fatal_per_channel: Dict[int, Exception] = {} + fatal_context_by_channel: Dict[int, Optional[str]] = {} + for ch, e in fatal.items(): + _iface_name, desc = await self._describe_entry(e) + fatal_per_channel[ch] = hamilton_error_for_entry(e, desc) + fatal_context_by_channel[ch] = await self._format_entry_context(e) + logger.error( + "Hamilton command fatal entries: %s", + ", ".join( + ( + f"ch{ch}: {fatal_per_channel[ch]} ({fatal_context_by_channel[ch]})" + if fatal_context_by_channel.get(ch) + else f"ch{ch}: {fatal_per_channel[ch]}" + ) + for ch in sorted(fatal_per_channel) + ), + ) + raise ChannelizedError(errors=fatal_per_channel, raw_response=response_message.hoi.params) + return result + + except connection_errors as e: + last_error = e + self._connected = False + if not self.auto_reconnect or attempt == max_attempts - 1: + raise + logger.warning( + f"{self.io._unique_id} Command failed (connection error), reconnecting and retrying: {e}" + ) + await self._reconnect() + + assert last_error is not None + raise last_error + + async def resolve_path(self, path: str) -> Address: + """Resolve dot-path to Address (delegates to introspection).""" + return await self.introspection.resolve_path(path) + + async def resolve_target( + self, + target: Union[Address, str], + aliases: Optional[Dict[str, str]] = None, + ) -> Address: + """Resolve Address | alias | dot-path to Address.""" + if isinstance(target, Address): + return target + resolved = aliases.get(target, target) if aliases is not None else target + return await self.resolve_path(resolved) + + async def stop(self): + try: + await self.io.stop() + except Exception as e: + logger.warning(f"Error during stop: {e}") + finally: + self._connected = False + self._invalidate_introspection_session() + logger.info("Hamilton TCP client stopped") diff --git a/pylabrobot/hamilton/tcp/commands.py b/pylabrobot/hamilton/tcp/commands.py index 1867d2149ca..eac460f996e 100644 --- a/pylabrobot/hamilton/tcp/commands.py +++ b/pylabrobot/hamilton/tcp/commands.py @@ -1,50 +1,50 @@ -"""Hamilton command architecture using new simplified TCP stack. +"""Command layer for Hamilton TCP. -This module provides the HamiltonCommand base class that uses the new refactored -architecture: Wire -> HoiParams -> Packets -> Messages -> Commands. +TCPCommand base: build_parameters() returns HoiParams; interpret_response() +auto-decodes success responses via nested Response dataclasses (wire-type +annotations and parse_into_struct). Wire → HoiParams → Packets → Messages → Commands. """ from __future__ import annotations import inspect -from typing import Optional +from dataclasses import fields, is_dataclass +from typing import Any, Optional from pylabrobot.hamilton.tcp.messages import ( CommandMessage, CommandResponse, HoiParams, + interpret_hoi_success_payload, + log_hoi_result_entries, + split_hoi_params_after_warning_prefix, ) from pylabrobot.hamilton.tcp.packets import Address from pylabrobot.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.hamilton.tcp.wire_types import HcResultEntry -class HamiltonCommand: - """Base class for Hamilton commands using new simplified architecture. +class TCPCommand: + """Base class for Hamilton TCP commands. - 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 + Preferred usage: define commands as ``@dataclass`` subclasses with + ``Annotated`` wire-type fields. ``build_parameters()`` and + ``interpret_response()`` are handled automatically by the base class. - Example: - class MyCommand(HamiltonCommand): + Example:: + + @dataclass + class MyCommand(TCPCommand): 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) + dest: Address # infrastructure field — not serialised + value: Annotated[int, I32] # wire field — serialised in order - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - parser = HoiParamsParser(data) - _, result = parser.parse_next() - return {'result': result} + @dataclass + class Response: + result: Annotated[int, U32] """ # Class-level attributes that subclasses must override @@ -58,7 +58,7 @@ def parse_response_parameters(cls, data: bytes) -> dict: ip_protocol: int = 6 # Default: OBJECT_DISCOVERY def __init__(self, dest: Address): - """Initialize Hamilton command. + """Initialize TCP command. Args: dest: Destination address for this command @@ -78,12 +78,17 @@ def __init__(self, dest: Address): 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!). + Default: serializes all ``Annotated`` wire-type fields on ``self`` via + ``HoiParams.from_struct``. On non-dataclass subclasses ``from_struct`` + finds no fields and returns an empty ``HoiParams``, preserving the old + behaviour. Override only when the wire layout cannot be expressed with + ``Annotated`` field declarations. Returns: HoiParams object with command parameters """ + if is_dataclass(self): + return HoiParams.from_struct(self) return HoiParams() def get_log_params(self) -> dict: @@ -150,19 +155,84 @@ def build( # Build final packet return msg.build(source, sequence, harp_response_required=response_required) - def interpret_response(self, response: CommandResponse) -> Optional[dict]: - """Interpret success response. + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map a ``HcResultEntry`` to a 0-indexed PLR channel, or ``None`` to skip. - This is the new interface used by the backend. Default implementation - directly calls parse_response_parameters for efficiency. + Default: the entry's position in the HoiResult — firmware populates arrays + in active-channel order. ``NimbusCommand`` / ``PrepCommand`` override this + to translate the active-channel ordinal into the caller's 0-indexed channel + via ``channels_involved`` bitmask or per-channel struct-array reflection. + """ + return entry_index - Args: - response: CommandResponse from network + def error_entries_use_physical_channels(self) -> bool: + """Whether ``STATUS_EXCEPTION`` entries should be mapped to PLR channel indices. - Returns: - Dictionary with parsed response data, or None if no data to extract + Returns ``True`` when the command carries per-channel wire parameters: + Prep ``StructArray`` elements with a ``channel`` field, or Nimbus + ``channels_involved`` parallel arrays. Void MLPrep / status queries return + ``False`` so the client raises :class:`~pylabrobot.hamilton.tcp.hoi_error.HoiError` + instead of attributing errors to synthetic ``ch0``. """ - return self.parse_response_parameters(response.hoi.params) + if not is_dataclass(self): + return False + for f in fields(self): + if f.name == "channels_involved": + return True + value = getattr(self, f.name, None) + if isinstance(value, list) and value: + if getattr(value[0], "channel", None) is not None: + return True + return False + + def interpret_response(self, response: CommandResponse) -> Any: + """Pure decoder for a success response — never raises on channel errors. + + For ``STATUS_WARNING`` / ``COMMAND_WARNING`` frames, strips the leading + summary + formatted-string prefix (per ``SystemController.SendAndReceive``) + and logs entries parsed via ``HoiDecoder2.GetHcResults``. For plain + ``STATUS_RESPONSE`` / ``COMMAND_RESPONSE`` frames, decodes the Response + dataclass directly — the firmware emits exactly the fields declared in + the interface yaml, with no HoiResult trailer. HoiResult only rides on + warning (prefix) or exception (separate payload, handled in + ``send_command``) frames. + + Fatal (non-success, non-warning) entries from a warning frame surface + through ``fatal_entries_by_channel`` and are lifted into a + ``ChannelizedError`` by the backend — this decoder stays pure. + """ + eff, _prefix = self._strip_warning_prefix(response) + return interpret_hoi_success_payload(self, eff) + + def fatal_entries_by_channel(self, response: CommandResponse) -> dict[int, HcResultEntry]: + """Return fatal entries keyed by 0-indexed PLR channel. + + Only non-success, non-warning entries from a warning-frame prefix are + included; warnings remain log-only. Exception frames are handled + separately in ``send_command`` via :func:`~pylabrobot.hamilton.tcp.hoi_error.parse_hamilton_error_entry`. + + ``entry_index`` passed to ``_channel_index_for_entry`` is the position of + the entry in the *original* entries list (i.e. active-channel ordinal), + not among fatal entries only — so bitmask / struct-array overrides can + map ordinal → channel correctly even when earlier channels warned. + """ + _eff, prefix_entries = self._strip_warning_prefix(response) + per_channel: dict[int, HcResultEntry] = {} + for i, entry in enumerate(prefix_entries): + if entry.is_success: + continue + ch = self._channel_index_for_entry(i, entry) + if ch is None: + continue + per_channel[ch] = entry + return per_channel + + def _strip_warning_prefix(self, response: CommandResponse) -> tuple[bytes, list[HcResultEntry]]: + """Strip the warning-frame HoiResult prefix, if present. Logs entries.""" + raw = response.hoi.params + eff, prefix_entries = split_hoi_params_after_warning_prefix(response.hoi.action_code, raw) + log_hoi_result_entries(type(self).__name__, prefix_entries, source="HOI prefix") + return eff, prefix_entries @classmethod def parse_response_parameters(cls, data: bytes) -> Optional[dict]: @@ -177,3 +247,19 @@ def parse_response_parameters(cls, data: bytes) -> Optional[dict]: Dictionary with parsed response data, or None if no data to extract """ return None + + +def hamilton_error_for_entry(entry: HcResultEntry, description: str) -> Exception: + """Wrap an ``HcResultEntry`` in a ``RuntimeError`` using a pre-resolved description. + + ``description`` is sourced from the device itself via Interface 0 method 5 + (``EnumInfo``) — see ``HamiltonTCPClient._describe_entry``. The returned + exception has ``.entry`` attached so callers can dispatch on + ``entry.result`` / ``entry.interface_id`` / ``entry.address``. + """ + err = RuntimeError( + f"{description} (HcResult=0x{entry.result:04X}) " + f"at {entry.address} iface={entry.interface_id} action={entry.action_id}" + ) + err.entry = entry # type: ignore[attr-defined] + return err diff --git a/pylabrobot/hamilton/tcp/error_tables.py b/pylabrobot/hamilton/tcp/error_tables.py new file mode 100644 index 00000000000..b2f2a58e935 --- /dev/null +++ b/pylabrobot/hamilton/tcp/error_tables.py @@ -0,0 +1,2401 @@ +"""Hamilton error-code tables. + +Generated by ``_generate_error_tables.py`` from firmware reference exports. +Do not edit by hand — regenerate with:: + + python -m pylabrobot.hamilton.tcp._generate_error_tables + +Tables +------ +- ``HC_RESULT_PROTOCOL`` : ``{code: enum_name}``. Protocol-level universal + result codes that apply to any module. ~200 entries in the range 0–1069. +- ``NIMBUS_ERROR_CODES`` : ``{(module_id, node_id, object_id, interface_id, code): + text}``. Module-scoped text registered by ``NimbusCORESystem`` and + ``GripperControllerSystem`` ``AddErrorData`` calls at runtime. Codes in this + table start at 0x0F01 (3841) — the module-specific range. +- ``PREP_ERROR_CODES`` : Prep-specific codes (pipettor and MPH). Module-specific + range starting at 0x0F01. +""" + +from __future__ import annotations + +from typing import Dict, Tuple + +HC_RESULT_PROTOCOL: Dict[int, str] = { + 0: "Success", + 1: "GenericError", + 2: "GenericNotReady", + 3: "GenericNullParameter", + 4: "GenericCalledByInitHandler", + 5: "GenericInvalidData", + 6: "GenericOutOfMemory", + 7: "GenericWriteFault", + 8: "GenericReadFault", + 9: "GenericBufferOverflow", + 10: "GenericNotInitialized", + 11: "GenericAlreadyInitialized", + 12: "GenericWaitAborted", + 13: "GenericTimeOut", + 14: "GenericMissingCallBack", + 15: "GenericInvalidHandle", + 16: "GenericNotSupported", + 17: "GenericInvalidParameter", + 18: "GenericNotImplemented", + 19: "GenericBadCrc", + 20: "GenericFlashNotBlank", + 21: "GenericMultipleErrorsReported", + 22: "GenericCoordinatedCommandTimeout", + 23: "GenericAccessDenied", + 25: "GenericBusy", + 26: "GenericMethodObsolete", + 27: "GenericNotConfigured", + 257: "KernelMutexTimeout", + 258: "KernelSemaphoreTimeout", + 259: "KernelEventTimeout", + 260: "KernelNoMutex", + 261: "KernelMutexNotOwned", + 262: "KernelNoWaitingTask", + 263: "KernelInvalidTask", + 264: "KernelNoTaskControlBlock", + 513: "NetworkUndefinedProtocol", + 514: "NetworkNoDestination", + 515: "NetworkRegistrationError", + 516: "NetworkNotRegistered", + 517: "NetworkBusy", + 518: "NetworkInvalidDispatchID", + 519: "NetworkInvalidMessage", + 520: "NetworkUnsupportedParameter", + 521: "NetworkCommandCompleteNotValid", + 522: "NetworkInvalidMessageParameter", + 523: "NetworkIncompatibleProtocolVersion", + 524: "NetworkInvalidNodeId", + 525: "NetworkInvalidModuleId", + 526: "NetworkInvalidInterfaceId", + 527: "NetworkInvalidAction", + 528: "NetworkProxySendAttemptFailed", + 529: "NetworkRegistrationFailedDuplicateAddress", + 530: "NetworkUnableToProperlyFillOutResults", + 531: "NetworkDuplicateEventRegistration", + 532: "NetworkEventRegistrationExceedsMaximumAllowedSubscribers", + 533: "NetworkMaximumNodeToNodeEventRegistrationsExceeded", + 534: "NetworkMaximumNodeToNodeEventHandlerRegistrationsExceeded", + 535: "NetworkUnsupportedHarpPayloadProtocol", + 769: "XPortSlOsPortNotInstalled", + 770: "XPortSlIpTaskPriorityNotSet", + 771: "XPortSlTimerTaskPriorityNotSet", + 772: "XPortSlDriverNotSet", + 773: "XPortSlIpAddressNotSet", + 774: "XPortSlNetMaskNotSet", + 775: "XPortSlCmxInitFailure", + 776: "XPortSlMacAddressNotSet", + 777: "XPortSlHostNameTooShort", + 778: "XPortSlNostNameTooLong", + 779: "XPortSlHostNameInvalidChars", + 800: "XPortNxpLpc2xxxCanInvalidChannel", + 801: "XPortNxpLpc2xxxCanInvalidGroup", + 802: "XPortNxpLpc2xxxCanBitRate", + 803: "XPortNxpLpc2xxxCanRxInterruptInstall", + 804: "XPortNxpLpc2xxxCanRxInterruptRemove", + 805: "XPortNxpLpc2xxxCanTxInterruptInstall", + 806: "XPortNxpLpc2xxxCanTxInterruptRemove", + 807: "XPortNxpLpc2xxxCanTxInvalidLength", + 808: "XPortNxpLpc2xxxCanTxBusy", + 809: "XPortArcNetAlreadyConfigured", + 810: "XPortArcNetNotConfigured", + 811: "XPortArcNetInterruptInstallFailed", + 812: "XPortArcNetTxNoAck", + 813: "XPortArcNetDiagnosticTestFailed", + 814: "XPortArcNetNodeIdTestFailed", + 815: "XPortArcNetInvalidNodeId", + 816: "XPortArcNetTxNotAvailable", + 817: "XPortArcNetInvalidDataRate", + 818: "XPortArcNetInvalidPacketLength", + 819: "XPortArcNetSingleNodeNetwork", + 820: "XPortArcNetNoResponseToFbe", + 833: "XPortProtocolMismatch", + 834: "XPortPacketRouterNotRegistered", + 835: "XPortCouldNotStartPacketRouterRxThread", + 836: "XPortPacketRouterAlreadyRegistered", + 837: "XPortNoPacketToProcess", + 838: "XPortWireProtocolNotRegistered", + 839: "XPortWireProtocolAlreadyRegistered", + 840: "XPortWireProtocolRegistrationSpaceFull", + 841: "XPortPayloadProtocolNotRegistered", + 842: "XPortPayloadProtocolAlreadyRegistered", + 843: "XPortPayloadRegistrationSpaceFull", + 844: "XPortAddressNotSet", + 845: "XPortAttemptToSendToSelf", + 846: "XPortTxTimeout", + 847: "XPortRxDuplicateFrame", + 864: "XPortCanWp0VersionConflict", + 865: "XPortCanExcessivePacketSize", + 866: "XPortCanWp0AckHasNoMatchingPacket", + 867: "XPortCanWp0WrapperOnlyOneAddressSupported", + 868: "XPortCanWp0ErrorStartRefused", + 869: "XPortCanWp0ErrorBufferOverrun", + 870: "XPortCanWp0InvalidFrame", + 871: "XPortCanWp0StrayDataFrame", + 872: "XPortCanWp0ShortMessage", + 873: "XPortCanWp0LongMessage", + 874: "XPortCanWp0UnknownError", + 875: "XPortCanWp0NoResponseFromDestination", + 876: "XPortCanWp0SendError", + 877: "XPortCanWbzUnknownFrame", + 878: "XPortCanWbzUnsolicitedRemoteFrame", + 879: "XPortCanWbzUnsolicitedDataFrame", + 880: "XPortCanWbzWrapperOnlyOneAddressSupported", + 881: "XPortCanWp0LastMessageFailed", + 896: "XPortIpStackConfigurationFailure", + 897: "XPortIpStackNotConfigured", + 898: "XPortSocketCreationFailure", + 899: "XPortSocketConfigFailure", + 900: "XPortSocketBindFailure", + 901: "XPortIpTaskAlreadyStarted", + 902: "XPortIpTaskNotStarted", + 903: "XPortTcpListenFailure", + 904: "XPortTcpClientAlreadyConnected", + 905: "XPortTcpClientNotConnected", + 906: "XPortTcpConnectionFailure", + 907: "XPortTcpCloseFailure", + 908: "XPortTcpSendError", + 909: "XPortUdpSendError", + 910: "XPortMalformedDiscoveryRequest", + 911: "XPortIpDhcpFailed", + 912: "XPortIpStaticAddressConfigFailed", + 928: "XPortArcNetBufferOverrun", + 929: "XPortArcNetVersionConflict", + 930: "XPortArcNetInvalidFrameType", + 931: "XPortArcNetInvalidFrame", + 932: "XPortArcNetUnknownError", + 933: "XPortArcNetAckHasNoMatchingPacket", + 934: "XPortArcNetInvalidMessageSize", + 935: "XPortArcNetLastMessageFailed", + 936: "XPortArcNetWp0RefusedSyn", + 937: "XPortArcNetWp0MessageTooShort", + 938: "XPortArcNetWp0MessageTooLong", + 939: "XPortArcNetWp0InvalidSequenceNumber", + 940: "XPortArcNetWp0NoResponseFromDestination", + 1024: "ComLinkReferToInnerException", + 1025: "ComLinkNotConnected", + 1026: "ComLinkTcpConnectionFailed", + 1027: "ComLinkFailedToCloseConnectionProperly", + 1028: "ComLinkInvalidProtocolVersion", + 1029: "ComLinkUnsupportedOptionsDetectedByServer", + 1030: "ComLinkNodeIdNegotiationFailure", + 1031: "ComLinkConnectionIntentError", + 1032: "ComLinkUnableToConfigureKeepAlive", + 1033: "ComLinkFailedToSendConnectionPacket", + 1034: "ComLinkInvalidRegistrationAction", + 1035: "ComLinkUnexpectedRequestedHarpAddressReturned", + 1036: "ComLinkHarpAddressRegistrationFailed", + 1037: "ComLinkHarpAddressDeregistrationFailed", + 1038: "ComLinkIdentificationNotImplemented", + 1039: "ComLinkIdentificationNotSupported", + 1040: "ComLinkFailedToSendIdentificationRequest", + 1041: "ComLinkNoResponseFromInstrumentRegistrationServer", + 1042: "ComLinkNoRootObjectFound", + 1043: "ComLinkEthernetObjectNotFound", + 1044: "ComLinkMethodNotFound", + 1045: "ComLinkProtocolActionConversionFailed", + 1046: "ComLinkTimeout", + 1047: "ComLinkUnableToSendOrReceive", + 1048: "ComLinkTransportTransportableIntroductionFailure", + 1049: "ComLinkHarpHarpableIntroductionFailure", + 1050: "ComLinkDownloadException", + 1051: "ComLinkSizeOfReturnParametersNotValid", + 1052: "ComLinkRestrictedMethod", + 1053: "ComLinkInvalidNumberOfStructureParametersFromNetworkLayer", + 1054: "ComLinkInvalidTypeInStructureFromNetworkLayer", + 1055: "ComLinkRs232ConnectionFailed", + 1056: "ComLinkRs232InvalidPort", + 1057: "ComLinkLoggingCannotBeConfiguredWhileConnectedOrConnecting", + 1058: "ComLinkThreadAbortExceptionDetected", + 1059: "ComLinkUnableToSend", + 1060: "ComLinkUnableToReceive", + 1061: "ComLinkConnectionRequiredToProceed", + 1062: "ComLinkTooMuchDataToSend", + 1063: "ComLinkCanConfigurationFailure", + 1064: "ComLinkUnableToRetrieveListOfModules", + 1065: "ComLinkTcpConnectionFailedConnectionRefused", + 1066: "ComLinkTcpConnectionFailedHostUnreachable", + 1067: "ComLinkTcpConnectionFailedHostNotFound", + 1068: "ComLinkTcpConnectionFailedTimedOut", + 1069: "ComLinkTcpConnectionFailedIsConnected", + 32792: "GenericMultipleWarningsReported", +} + +NIMBUS_ERROR_CODES: Dict[Tuple[int, int, int, int, int], str] = { + (0x0001, 0x0001, 0x0101, 1, 0x0F01): "Invalid tips specified.", + (0x0001, 0x0001, 0x0101, 1, 0x0F02): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x0101, 1, 0x0F03): "Gripper tool not installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F04): "Plate is in the gripper.", + (0x0001, 0x0001, 0x0101, 1, 0x0F05): "No plate in the gripper.", + (0x0001, 0x0001, 0x0101, 1, 0x0F06): "Invalid tip type.", + (0x0001, 0x0001, 0x0101, 1, 0x0F07): "Tip not installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F08): "Tip already installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F09): "Gripper tool not installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0A): "Tip type is not a Gripper tool.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0B): "Invalid aspirate type.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0C): "Invalid dispense type.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0D): "Invalid LLD mode.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0E): "Sequential aspirate with pressure LLD.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0F): "Jet dispense with LLD.", + (0x0001, 0x0001, 0x0101, 1, 0x0F10): "Surface dispense with pressure LLD.", + (0x0001, 0x0001, 0x0101, 1, 0x0F11): "Invalid aspirate dispense pattern.", + (0x0001, 0x0001, 0x0101, 1, 0x0F12): "Pressure differential not achieved.", + (0x0001, 0x0001, 0x0101, 1, 0x0F13): "Not enough array items for the specified tips.", + (0x0001, 0x0001, 0x0101, 1, 0x0F14): "All pressure differentials not achieved.", + (0x0001, 0x0001, 0x0101, 1, 0x0F15): "Y position limit exceeded for channel 1.", + (0x0001, 0x0001, 0x0101, 1, 0x0F16): "Y position limit exceeded for channel 2.", + (0x0001, 0x0001, 0x0101, 1, 0x0F17): "Y position limit exceeded for channels 1 and 2.", + (0x0001, 0x0001, 0x0101, 1, 0x0F18): "Y position limit exceeded for channel 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F19): "Y position limit exceeded for channels 1 and 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1A): "Y position limit exceeded for channels 2 and 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1B): "Y position limit exceeded for channels 1, 2 and 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1C): "Y position limit exceeded for channel 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1D): "Y position limit exceeded for channels 1 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1E): "Y position limit exceeded for channels 2 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1F): "Y position limit exceeded for channels 1, 2 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F20): "Y position limit exceeded for channels 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F21): "Y position limit exceeded for channels 1, 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F22): "Y position limit exceeded for channels 2, 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F23): "Y position limit exceeded for channels 1, 2, 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F24): "Z position limit exceeded for one or more channels.", + (0x0001, 0x0001, 0x0101, 1, 0x0F25): "Unexpected Vacuum Detected.", + (0x0001, 0x0001, 0x0102, 1, 0x0F01): "LLD not detected.", + (0x0001, 0x0001, 0x0102, 1, 0x0F02): "Invalid channel specified.", + (0x0001, 0x0001, 0x0102, 1, 0x0F03): "Gripper not detected.", + (0x0001, 0x0001, 0x0102, 1, 0x0F04): "LLD unexpectedly detected during Z movement.", + (0x0001, 0x0001, 0x0102, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0102, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0102, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0102, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0102, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0102, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0102, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0102, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0102, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0102, 1, 0x0F14): "Reserved Error 20.", + (0x0001, 0x0001, 0x0102, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0102, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0102, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0102, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0102, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1E): "Reserved Error 30.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1F): "Reserved Error 31.", + (0x0001, 0x0001, 0x0102, 1, 0x0F20): "Reserved Error 32.", + (0x0001, 0x0001, 0x0102, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0102, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0102, 1, 0x0F23): "Reserved Error 35.", + (0x0001, 0x0001, 0x0102, 1, 0x0F24): "Invalid tips specified.", + (0x0001, 0x0001, 0x0102, 1, 0x0F25): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x0102, 1, 0x0F26): "LLD seeks not within 0.05mm of each other.", + ( + 0x0001, + 0x0001, + 0x0104, + 1, + 0x0F01, + ): "Motion was stopped prematurely via a call to IAxisWrapper.Stop(BOOL hard).", + (0x0001, 0x0001, 0x0105, 1, 0x0F01): "Not enough array items for the specified tips.", + (0x0001, 0x0001, 0x0105, 1, 0x0F02): "One or more Shift N Scan tube racks not installed.", + (0x0001, 0x0001, 0x0105, 1, 0x0F03): "Invalid tips specified.", + (0x0001, 0x0001, 0x0105, 1, 0x0F04): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x0106, 1, 0x0F01): "Unable to sequential aspirate with pressure LLD.", + (0x0001, 0x0001, 0x0106, 1, 0x0F02): "Invalid parameter combination.", + (0x0001, 0x0001, 0x0106, 1, 0x0F03): "Cannot dispense with pressure LLD.", + (0x0001, 0x0001, 0x0106, 1, 0x0F04): "Not enough array items for the specified tips.", + (0x0001, 0x0001, 0x0108, 1, 0x0F01): "Gripper detects force applied.", + (0x0001, 0x0001, 0x0108, 1, 0x0F02): "Wrist may be in an unsafe location for initialization.", + (0x0001, 0x0001, 0x0108, 1, 0x0F03): "Y axis cannot be moved to safe zone.", + (0x0001, 0x0001, 0x0108, 1, 0x0F04): "Park sensor not active when gripper is parked.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F01, + ): "Deck monitoring is not available in the current hardware configuration.", + (0x0001, 0x0001, 0x010A, 1, 0x0F02): "ConfigureTracks only allowed while monitoring.", + (0x0001, 0x0001, 0x010A, 1, 0x0F03): "LoadTrack only allowed while configuring.", + (0x0001, 0x0001, 0x010A, 1, 0x0F04): "Invalid track position specified.", + (0x0001, 0x0001, 0x010A, 1, 0x0F05): "Track plus width created an invalid track position.", + (0x0001, 0x0001, 0x010A, 1, 0x0F06): "CancelTrack only allowed while configuring.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F07, + ): "MonitorDeck(FALSE) only allowed while monitoring and a method is not running.", + (0x0001, 0x0001, 0x010A, 1, 0x0F08): "MonitorDeck(TRUE) only allowed while not monitoring.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F09, + ): "LoadTracks2 tracks and widths array sizes are not identical.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F0A, + ): "LoadTracks2 tracks and widths arrays have overlapping tracks.", + (0x0001, 0x0001, 0x010C, 1, 0x0F01): "Right door lock does not indicate locked.", + (0x0001, 0x0001, 0x010C, 1, 0x0F02): "Left door lock does not indicate locked.", + (0x0001, 0x0001, 0x010C, 1, 0x0F03): "Both door locks do not indicate locked.", + ( + 0x0001, + 0x0001, + 0x010C, + 1, + 0x0F04, + ): "Door cannot be unlocked until the instrument completes the command currently in progress.", + (0x0001, 0x0001, 0x010E, 1, 0x0F01): "Invalid tips specified.", + (0x0001, 0x0001, 0x010E, 1, 0x0F02): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x010E, 1, 0x0F03): "Not enough array items for the specified tips.", + ( + 0x0001, + 0x0001, + 0x010F, + 1, + 0x8F01, + ): "Speed exceeds maximum speed of the z and g axis and will not be applied to these axes.", + (0x0001, 0x0001, 0x0110, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0110, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0110, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0110, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0110, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0110, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0110, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0110, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0110, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0110, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0110, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0110, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0110, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0110, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0110, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0110, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0110, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0110, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0110, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0110, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0110, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0110, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0110, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0110, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0110, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0110, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0110, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0110, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0110, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0110, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0110, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0110, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0110, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0110, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0110, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0110, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0110, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0110, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0110, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0110, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0110, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0110, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0110, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0110, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0110, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0110, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0110, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0110, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0110, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0110, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0110, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0110, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0110, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0110, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0110, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0110, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0111, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0111, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0111, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0111, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0111, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0111, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0111, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0111, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0111, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0111, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0111, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0111, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0111, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0111, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0111, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0111, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0111, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0111, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0111, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0111, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0111, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0111, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0111, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0111, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0111, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0111, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0111, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0111, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0111, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0111, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0111, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0111, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0111, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0111, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0111, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0111, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0111, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0111, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0111, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0111, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0111, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0111, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0111, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0111, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0111, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0111, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0111, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0111, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0111, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0111, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0111, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0111, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0111, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0111, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0111, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0111, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0112, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0112, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0112, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0112, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0112, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0112, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0112, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0112, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0112, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0112, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0112, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0112, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0112, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0112, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0112, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0112, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0112, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0112, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0112, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0112, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0112, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0112, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0112, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0112, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0112, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0112, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0112, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0112, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0112, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0112, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0112, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0112, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0112, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0112, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0112, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0112, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0112, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0112, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0112, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0112, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0112, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0112, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0112, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0112, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0112, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0112, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0112, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0112, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0112, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0112, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0112, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0112, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0112, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0112, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0112, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0112, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0113, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0113, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0113, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0113, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0113, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0113, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0113, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0113, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0113, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0113, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0113, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0113, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0113, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0113, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0113, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0113, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0113, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0113, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0113, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0113, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0113, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0113, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0113, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0113, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0113, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0113, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0113, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0113, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0113, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0113, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0113, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0113, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0113, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0113, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0113, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0113, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0113, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0113, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0113, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0113, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0113, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0113, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0113, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0113, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0113, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0113, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0113, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0113, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0113, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0113, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0113, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0113, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0113, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0113, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0113, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0113, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0114, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0114, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0114, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0114, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0114, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0114, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0114, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0114, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0114, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0114, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0114, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0114, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0114, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0114, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0114, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0114, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0114, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0114, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0114, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0114, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0114, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0114, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0114, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0114, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0114, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0114, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0114, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0114, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0114, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0114, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0114, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0114, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0114, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0114, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0114, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0114, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0114, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0114, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0114, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0114, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0114, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0114, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0114, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0114, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0114, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0114, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0114, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0114, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0114, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0114, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0114, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0114, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0114, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0114, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0114, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0114, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0115, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0115, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0115, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0115, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0115, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0115, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0115, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0115, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0115, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0115, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0115, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0115, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0115, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0115, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0115, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0115, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0115, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0115, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0115, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0115, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0115, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0115, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0115, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0115, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0115, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0115, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0115, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0115, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0115, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0115, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0115, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0115, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0115, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0115, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0115, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0115, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0115, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0115, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0115, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0115, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0115, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0115, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0115, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0115, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0115, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0115, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0115, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0115, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0115, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0115, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0115, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0115, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0115, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0115, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0115, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0115, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0116, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0116, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0116, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0116, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0116, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0116, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0116, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0116, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0116, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0116, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0116, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0116, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0116, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0116, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0116, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0116, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0116, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0116, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0116, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0116, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0116, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0116, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0116, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0116, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0116, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0116, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0116, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0116, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0116, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0116, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0116, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0116, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0116, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0116, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0116, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0116, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0116, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0116, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0116, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0116, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0116, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0116, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0116, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0116, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0116, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0116, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0116, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0116, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0116, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0116, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0116, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0116, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0116, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0116, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0116, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0116, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0117, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0117, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0117, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0117, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0117, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0117, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0117, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0117, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0117, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0117, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0117, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0117, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0117, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0117, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0117, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0117, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0117, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0117, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0117, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0117, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0117, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0117, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0117, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0117, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0117, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0117, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0117, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0117, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0117, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0117, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0117, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0117, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0117, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0117, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0117, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0117, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0117, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0117, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0117, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0117, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0117, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0117, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0117, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0117, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0117, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0117, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0117, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0117, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0117, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0117, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0117, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0117, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0117, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0117, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0117, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0117, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F01): "The park button is currently disabled.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F02): "The gripper is holding a plate.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F03): "Unknown device identifier.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F04): "No shift and scan racks installed.", + (0x0001, 0x0001, 0xC100, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC100, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC100, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC100, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC100, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC101, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC101, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC101, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC101, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC101, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC102, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC102, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC102, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC102, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC102, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC103, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC103, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC103, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC103, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC103, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC104, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC104, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC104, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC104, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC104, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC105, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC105, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC105, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC105, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC105, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC106, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC106, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC106, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC106, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC106, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC107, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC107, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC107, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC107, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC107, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0020, 0x0100, 1, 0x0F01): "Sequencer Exception.", + (0x0001, 0x0020, 0x0100, 1, 0x0F02): "Static Position Error Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F09): "Position Flag Not Found.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0001, 0x0020, 0x0100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0001, 0x0020, 0x0100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0001, 0x0020, 0x0100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0001, 0x0020, 0x0100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0001, 0x0020, 0x0100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0001, 0x0020, 0x0100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0001, 0x0020, 0x0100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0001, 0x0020, 0x0100, 1, 0x0F17): "Servo Loop Overrun.", + (0x0001, 0x0020, 0x0101, 1, 0x0F01): "Sequencer Exception.", + (0x0001, 0x0020, 0x0101, 1, 0x0F02): "Static Position Error Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F04): "Settling Position Error Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F09): "Position Flag Not Found.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0B): "Servo Not Enabled.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0D): "Incomplete Configuration.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0E): "Flash Memory Failure.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0001, 0x0020, 0x0101, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0001, 0x0020, 0x0101, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0001, 0x0020, 0x0101, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0001, 0x0020, 0x0101, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0001, 0x0020, 0x0101, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0001, 0x0020, 0x0101, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0001, 0x0020, 0x0101, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0001, 0x0020, 0x0101, 1, 0x0F17): "Servo Loop Overrun.", + (0x0001, 0x0060, 0x0101, 1, 0x0F01): "Bad buddy address.", + (0x0001, 0x0060, 0x0101, 1, 0x0F02): "Barcode read timeout.", + (0x0001, 0x0060, 0x0101, 1, 0x0F03): "Barcode engine communication timeout.", + (0x0001, 0x0060, 0x0101, 1, 0x0F04): "Barcode engine is already scanning.", + (0x0001, 0x0060, 0x0101, 1, 0x0F05): "Barcode queue is empty.", + (0x0001, 0x0060, 0x0101, 1, 0x0F06): "CTS handshake timeout.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F07, + ): "The top field is not a multiple of the correct number. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F08, + ): "The bottom field is not of the correct multiple. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F09, + ): "The left field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0A, + ): "The right field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0B, + ): "The bottom and top fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0C, + ): "The left and right fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0D, + ): "The top field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0E, + ): "The bottom field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0F, + ): "The left field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F10, + ): "The right field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + (0x0001, 0x0060, 0x0101, 1, 0x0F11): "The top field exceeds the top of the current FOV.", + (0x0001, 0x0060, 0x0101, 1, 0x0F12): "The bottom field exceeds the bottom of the current FOV.", + (0x0001, 0x0060, 0x0101, 1, 0x0F13): "The left field exceeds the left of the current FOV.", + (0x0001, 0x0060, 0x0101, 1, 0x0F14): "The right field exceeds the right of the current FOV.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x8F01, + ): "The maximum number of queued barcodes has been exceeded. Barcodes have been lost.", + (0x0001, 0x0060, 0x0102, 1, 0x0F01): "Bad buddy address.", + (0x0001, 0x0060, 0x0102, 1, 0x0F02): "Barcode read timeout.", + (0x0001, 0x0060, 0x0102, 1, 0x0F03): "Barcode engine communication timeout.", + (0x0001, 0x0060, 0x0102, 1, 0x0F04): "Barcode engine is already scanning.", + (0x0001, 0x0060, 0x0102, 1, 0x0F05): "Barcode queue is empty.", + (0x0001, 0x0060, 0x0102, 1, 0x0F06): "CTS handshake timeout.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F07, + ): "The top field is not a multiple of the correct number. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F08, + ): "The bottom field is not of the correct multiple. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F09, + ): "The left field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0A, + ): "The right field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0B, + ): "The bottom and top fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0C, + ): "The left and right fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0D, + ): "The top field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0E, + ): "The bottom field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0F, + ): "The left field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F10, + ): "The right field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + (0x0001, 0x0060, 0x0102, 1, 0x0F11): "The top field exceeds the top of the current FOV.", + (0x0001, 0x0060, 0x0102, 1, 0x0F12): "The bottom field exceeds the bottom of the current FOV.", + (0x0001, 0x0060, 0x0102, 1, 0x0F13): "The left field exceeds the left of the current FOV.", + (0x0001, 0x0060, 0x0102, 1, 0x0F14): "The right field exceeds the right of the current FOV.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x8F01, + ): "The maximum number of queued barcodes has been exceeded. Barcodes have been lost.", + ( + 0x0001, + 0x0080, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0080, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0080, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0080, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0080, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0080, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0080, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0080, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0080, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0080, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + ( + 0x0001, + 0x0081, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0081, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0081, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0081, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0081, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0081, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0081, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0081, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0081, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0081, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + ( + 0x0001, + 0x0082, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0082, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0082, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0082, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0082, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0082, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0082, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0082, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0082, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0082, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + ( + 0x0001, + 0x0083, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0083, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0083, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0083, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0083, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0083, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0083, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0083, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0083, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0083, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + (0x0001, 0xE000, 0xBF00, 1, 0x0F01): "LED Update Timeout.", + (0x0001, 0xE001, 0xBF00, 1, 0x0F01): "LED Update Timeout.", + (0x0001, 0xE020, 0xBF00, 1, 0x0F01): "LED Update Timeout.", + (0x0020, 0x0001, 0x1000, 1, 0x0F01): "The gripper calibration has not yet started.", + (0x0020, 0x0001, 0x1000, 1, 0x0F02): "The flash memory sector contains an invalid magic cookie.", + (0x0020, 0x0001, 0x1000, 1, 0x0F03): "The flash memory sector contains an invalid checksum.", + (0x0020, 0x0001, 0x1000, 1, 0x0F04): "Flash memory sector failed preparation for writing.", + (0x0020, 0x0001, 0x1000, 1, 0x0F05): "Flash memory sectors failed to erase.", + (0x0020, 0x0001, 0x1000, 1, 0x0F06): "Flash memory write failed.", + (0x0020, 0x0001, 0x1000, 1, 0x0F07): "The open width is less than the tool width.", + (0x0020, 0x0001, 0x1000, 1, 0x0F08): "Force is still applied to the object within the gripper.", + (0x0020, 0x0001, 0x1000, 1, 0x0F09): "The calibration tool was not detected.", + (0x0020, 0x0001, 0x1000, 1, 0x0F0A): "The gripper width calibration failed.", + ( + 0x0020, + 0x0001, + 0x1000, + 1, + 0x0F0B, + ): "The gripper travel extent calibration cannot start because the gripper width calibration is in progress.", + (0x0020, 0x0001, 0x1000, 1, 0x0F0C): "The gripper travel extent calibration verification failed.", + (0x0020, 0x0001, 0xBF00, 1, 0x0F01): "Force is still applied to the object within the gripper.", + (0x0020, 0x0001, 0xBF00, 1, 0x0F02): "Not enough force applied to the object within the gripper.", + (0x0020, 0x0001, 0xBF00, 1, 0x0F03): "The park sensor has not been enabled.", + (0x0020, 0x0021, 0x0100, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0021, 0x0100, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0021, 0x0100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0021, 0x0100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0021, 0x0100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0021, 0x0100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0021, 0x0100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0021, 0x0100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0021, 0x0100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0021, 0x0100, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0021, 0x0101, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0021, 0x0101, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0021, 0x0101, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0021, 0x0101, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0021, 0x0101, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0021, 0x0101, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0021, 0x0101, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0021, 0x0101, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0021, 0x0101, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0021, 0x0101, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0023, 0x0100, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0023, 0x0100, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0023, 0x0100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0023, 0x0100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0023, 0x0100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0023, 0x0100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0023, 0x0100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0023, 0x0100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0023, 0x0100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0023, 0x0100, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0023, 0x0101, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0023, 0x0101, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0023, 0x0101, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0023, 0x0101, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0023, 0x0101, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0023, 0x0101, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0023, 0x0101, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0023, 0x0101, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0023, 0x0101, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0023, 0x0101, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0044, 0x0004, 1, 0x0F01): "No ACK from digital potentiometers.", + (0x0020, 0x0044, 0x0004, 1, 0x0F02): "Cannot confirm write to digital potentiometers.", + (0x0020, 0x0044, 0x0004, 1, 0x0F03): "Digital potentiometer write timeout.", + (0x0020, 0x0044, 0x0004, 1, 0x0F04): "Failed to start A/D conversion.", + (0x0020, 0x0044, 0x0004, 1, 0x0F05): "A/D results are invalid.", + (0x0020, 0x0044, 0x0004, 1, 0x0F06): "A/D timeout.", + (0x0020, 0x0044, 0x0004, 1, 0x0F07): "Force monitor is running.", + (0x0020, 0x0044, 0x0004, 1, 0x0F08): "Force calibration factors are not valid.", + (0x0020, 0x0044, 0x0004, 1, 0x0F09): "Force sensor is not calibrated.", + (0x0020, 0x0044, 0x0004, 1, 0x0F0A): "Failed to calibrate force sensor.", + (0x0020, 0x0044, 0x0004, 1, 0x0F0B): "Applied force is out of range.", + (0x0020, 0x0044, 0x0004, 1, 0x0F0C): "Force monitor calibration is running.", + ( + 0x0020, + 0x0044, + 0x0004, + 1, + 0x0F0D, + ): "Cannot perform the requested operation because LLD detection is enabled.", + ( + 0x0020, + 0x0044, + 0x0004, + 1, + 0x0F0E, + ): "Cannot perform the requested operation because LLD detection is not enabled.", + ( + 0x0020, + 0x0044, + 0x0005, + 1, + 0x0F01, + ): "This error is generated when the current operation resulted in a overflow.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F01): "No ACK from digital potentiometers.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F02): "Cannot confirm write to digital potentiometers.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F03): "Digital potentiometer write timeout.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F04): "Failed to start A/D conversion.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F05): "A/D results are invalid.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F06): "A/D timeout.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F07): "Force monitor is running.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F08): "Force calibration factors are not valid.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F09): "Force sensor is not calibrated.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F0A): "Failed to calibrate force sensor.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F0B): "Applied force is out of range.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F0C): "Force monitor calibration is running.", + ( + 0x0020, + 0x0044, + 0xBF00, + 1, + 0x0F0D, + ): "Cannot perform the requested operation because LLD detection is enabled.", + ( + 0x0020, + 0x0044, + 0xBF00, + 1, + 0x0F0E, + ): "Cannot perform the requested operation because LLD detection is not enabled.", +} + +# Generated from Hamilton.Module.MLPrep.Service.dll (MLPrepSystem.RegisterErrors). +# Node IDs: 0x00e8=FrontChannel, 0x00ec=RearChannel, 0x00ee=Pipettor. +# Object IDs: 0x0100=Pipettor, 0x0101=Dispenser, 0x0200-0x0205=drives/calibration, +# 0x0107=TADM, 0x1000=MLPrep, 0x1100=MPH, 0x1300-0x1304=Calibration, +# 0x1500=ChannelPresenter, 0x2000=Arm, 0x2200=ArmMotion, 0x3000/0x3100/0x4000-0x4310=Servo/Motor, +# 0x6000=ParticulateSensor, 0xbef0=MLPrepController. +PREP_ERROR_CODES: Dict[Tuple[int, int, int, int, int], str] = { + # Global / unaddressed (HarpAddress default) + (0x0000, 0x0000, 0x0000, 0, 0x0E01): "The pipettor channels are busy with an operation.", + (0x0000, 0x0000, 0x0000, 0, 0x0E02): "The supplied channel index is invalid, or not present.", + (0x0000, 0x0000, 0x0000, 0, 0x0E03): "The indicated site is not defined.", + ( + 0x0000, + 0x0000, + 0x0000, + 0, + 0x0E04, + ): "The requested operation cannot be performed while the channel power is removed.", + (0x0000, 0x0000, 0x0000, 0, 0x0E05): "The requested channel does not have a head installed.", + ( + 0x0000, + 0x0000, + 0x0000, + 0, + 0x0E06, + ): "Coordinator Proxy Communication Timeout. Address refers to the target, not the source.", + (0x0000, 0x0000, 0x0000, 0, 0x0E07): "A Calibration procedure is in progress.", + # Pipettor node (0x00ee) — pipetting operations + (0x0001, 0x00EE, 0x0100, 1, 0x0F01): "AspirateLld must specify cLld and/or pLld.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F02): "DispenseLld must specify cLld.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F03): "Mix must have at least 1 mix cycle.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F04): "Mix must have a non-zero volume.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F05): "Dispense empty and cLLD cannot be used at the same time.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F06): "Aspirate monitoring must enable cLLD and/or pLLD.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F07): "A tip is held.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F08): "A tip is not held.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F09): "All tips picked up are not held.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0A): "Wrong type of tip detected.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0B): "Tip volume will be exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0C): "Dispenser limit will be exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0D): "Z axis stalled.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0E): "cLLD detected unexpectedly.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0F): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F10): "pLLD did not detect liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F11): "cLLD did not detect liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F12): "Both cLLD and pLLD did not detect liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F13): "Container does not contain sufficient liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F14): "cLLD and pLLD heights exceed limit.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F15): "pLLD aspirate monitoring exceeded limits.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F16): "pLLD aspirate monitoring detected a clot.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F17): "cLLD aspirate monitoring detected no liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F18): "cLLD detected a clot.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F19): "Insufficient memory to store TADM data.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1A): "Invalid TADM limit curve index.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1B): "TADM lower limit exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1C): "TADM upper limit exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1D): "Automatic drip control failed.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1E): "Non-volatile memory cannot store data.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1F): "Unable to achieve the required pressure.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F20): "Unable to achieve the required vacuum.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F21): "Pressure leak detected.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F22): "TADM not supported.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F23): "cLLD aspirate monitoring not supported.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F24): "No tip selected in TipMask.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F25): "Invalid tip selected in TipMask.", + (0x0001, 0x00EE, 0x0101, 1, 0x0F01): "Position exceeds tip volume.", + (0x0001, 0x00EE, 0x0101, 1, 0x0F02): "Position exceeds drive limits.", + (0x0001, 0x00EE, 0x0201, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0201, 1, 0x0F02): "The drive did not stall during initialization.", + (0x0001, 0x00EE, 0x0201, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F05): "Home sensor detected outside of tolerance.", + ( + 0x0001, + 0x00EE, + 0x0202, + 1, + 0x0F06, + ): "Cannot calibrate squeeze position until torque is calibrated.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F07): "Torque calibration failed.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F08): "Squeeze position calibration failed.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F01): "Parameter(s) exceed buffer limits.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F02): "Unable to erase the limit curves.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F03): "Limit curve name is too short (1 character minimum).", + (0x0001, 0x00EE, 0x0107, 1, 0x0F04): "Limit curve name is too long (36 characters maximum).", + (0x0001, 0x00EE, 0x0107, 1, 0x0F05): "Limit curve name is invalid (starts with 0xFF).", + (0x0001, 0x00EE, 0x0107, 1, 0x0F06): "A maximum of 2999 lower limit entries are allowed.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F07): "A maximum of 2999 upper limit entries are allowed.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F08): "Lower limit sample values are not strictly increasing.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F09): "Upper limit sample values are not strictly increasing.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F0A): "Unable to create the limit curve.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F0B): "Invalid limit curve index.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F0C): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F01): "Calibration has not been started.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F02): "Unable to read from the pressure potentiometer.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F03): "Unable to write to the pressure potentiometer.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F04): "Calibration was not successful.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F05): "Unable to automatically adjust the pressure sensor.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F06): "A tip is not held.", + ( + 0x0001, + 0x00EE, + 0x0205, + 1, + 0x0F01, + ): "An error occurred communicating to the digital potentiometer.", + (0x0001, 0x00EE, 0x0205, 1, 0x0F02): "Unable to automatically adjust the pressure sensor.", + # RearChannel node (0x00ec) — mirrors Pipettor errors + (0x0001, 0x00EC, 0x0100, 1, 0x0F01): "AspirateLld must specify cLld and/or pLld.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F02): "DispenseLld must specify cLld.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F03): "Mix must have at least 1 mix cycle.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F04): "Mix must have a non-zero volume.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F05): "Dispense empty and cLLD cannot be used at the same time.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F06): "Aspirate monitoring must enable cLLD and/or pLLD.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F07): "A tip is held.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F08): "A tip is not held.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F09): "All tips picked up are not held.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0A): "Wrong type of tip detected.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0B): "Tip volume will be exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0C): "Dispenser limit will be exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0D): "Z axis stalled.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0E): "cLLD detected unexpectedly.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0F): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F10): "pLLD did not detect liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F11): "cLLD did not detect liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F12): "Both cLLD and pLLD did not detect liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F13): "Container does not contain sufficient liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F14): "cLLD and pLLD heights exceed limit.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F15): "pLLD aspirate monitoring exceeded limits.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F16): "pLLD aspirate monitoring detected a clot.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F17): "cLLD aspirate monitoring detected no liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F18): "cLLD detected a clot.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F19): "Insufficient memory to store TADM data.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1A): "Invalid TADM limit curve index.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1B): "TADM lower limit exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1C): "TADM upper limit exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1D): "Automatic drip control failed.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1E): "Non-volatile memory cannot store data.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1F): "Unable to achieve the required pressure.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F20): "Unable to achieve the required vacuum.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F21): "Pressure leak detected.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F22): "TADM not supported.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F23): "cLLD aspirate monitoring not supported.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F24): "No tip selected in TipMask.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F25): "Invalid tip selected in TipMask.", + (0x0001, 0x00EC, 0x0101, 1, 0x0F01): "Position exceeds tip volume.", + (0x0001, 0x00EC, 0x0101, 1, 0x0F02): "Position exceeds drive limits.", + (0x0001, 0x00EC, 0x0201, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0201, 1, 0x0F02): "The drive did not stall during initialization.", + (0x0001, 0x00EC, 0x0201, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F05): "Home sensor detected outside of tolerance.", + ( + 0x0001, + 0x00EC, + 0x0202, + 1, + 0x0F06, + ): "Cannot calibrate squeeze position until torque is calibrated.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F07): "Torque calibration failed.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F08): "Squeeze position calibration failed.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F01): "Parameter(s) exceed buffer limits.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F02): "Unable to erase the limit curves.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F03): "Limit curve name is too short (1 character minimum).", + (0x0001, 0x00EC, 0x0107, 1, 0x0F04): "Limit curve name is too long (36 characters maximum).", + (0x0001, 0x00EC, 0x0107, 1, 0x0F05): "Limit curve name is invalid (starts with 0xFF).", + (0x0001, 0x00EC, 0x0107, 1, 0x0F06): "A maximum of 2999 lower limit entries are allowed.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F07): "A maximum of 2999 upper limit entries are allowed.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F08): "Lower limit sample values are not strictly increasing.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F09): "Upper limit sample values are not strictly increasing.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F0A): "Unable to create the limit curve.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F0B): "Invalid limit curve index.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F0C): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F01): "Calibration has not been started.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F02): "Unable to read from the pressure potentiometer.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F03): "Unable to write to the pressure potentiometer.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F04): "Calibration was not successful.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F05): "Unable to automatically adjust the pressure sensor.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F06): "A tip is not held.", + ( + 0x0001, + 0x00EC, + 0x0205, + 1, + 0x0F01, + ): "An error occurred communicating to the digital potentiometer.", + (0x0001, 0x00EC, 0x0205, 1, 0x0F02): "Unable to automatically adjust the pressure sensor.", + # FrontChannel node (0x00e8) — mirrors Pipettor errors + (0x0001, 0x00E8, 0x0100, 1, 0x0F01): "AspirateLld must specify cLld and/or pLld.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F02): "DispenseLld must specify cLld.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F03): "Mix must have at least 1 mix cycle.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F04): "Mix must have a non-zero volume.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F05): "Dispense empty and cLLD cannot be used at the same time.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F06): "Aspirate monitoring must enable cLLD and/or pLLD.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F07): "A tip is held.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F08): "A tip is not held.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F09): "All tips picked up are not held.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0A): "Wrong type of tip detected.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0B): "Tip volume will be exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0C): "Dispenser limit will be exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0D): "Z axis stalled.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0E): "cLLD detected unexpectedly.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0F): "pLLD auto adjustment was not successful.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F10): "pLLD did not detect liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F11): "cLLD did not detect liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F12): "Both cLLD and pLLD did not detect liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F13): "Container does not contain sufficient liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F14): "cLLD and pLLD heights exceed limit.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F15): "pLLD aspirate monitoring exceeded limits.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F16): "pLLD aspirate monitoring detected a clot.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F17): "cLLD aspirate monitoring detected no liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F18): "cLLD detected a clot.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F19): "Insufficient memory to store TADM data.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1A): "Invalid TADM limit curve index.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1B): "TADM lower limit exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1C): "TADM upper limit exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1D): "Automatic drip control failed.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1E): "Non-volatile memory cannot store data.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1F): "Unable to achieve the required pressure.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F20): "Unable to achieve the required vacuum.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F21): "Pressure leak detected.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F22): "TADM not supported.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F23): "cLLD aspirate monitoring not supported.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F24): "No tip selected in TipMask.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F25): "Invalid tip selected in TipMask.", + (0x0001, 0x00E8, 0x0101, 1, 0x0F01): "Position exceeds tip volume.", + (0x0001, 0x00E8, 0x0101, 1, 0x0F02): "Position exceeds drive limits.", + (0x0001, 0x00E8, 0x0201, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0201, 1, 0x0F02): "The drive did not stall during initialization.", + (0x0001, 0x00E8, 0x0201, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F05): "Home sensor detected outside of tolerance.", + ( + 0x0001, + 0x00E8, + 0x0202, + 1, + 0x0F06, + ): "Cannot calibrate squeeze position until torque is calibrated.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F07): "Torque calibration failed.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F08): "Squeeze position calibration failed.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F01): "Parameter(s) exceed buffer limits.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F02): "Unable to erase the limit curves.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F03): "Limit curve name is too short (1 character minimum).", + (0x0001, 0x00E8, 0x0107, 1, 0x0F04): "Limit curve name is too long (36 characters maximum).", + (0x0001, 0x00E8, 0x0107, 1, 0x0F05): "Limit curve name is invalid (starts with 0xFF).", + (0x0001, 0x00E8, 0x0107, 1, 0x0F06): "A maximum of 2999 lower limit entries are allowed.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F07): "A maximum of 2999 upper limit entries are allowed.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F08): "Lower limit sample values are not strictly increasing.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F09): "Upper limit sample values are not strictly increasing.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F0A): "Unable to create the limit curve.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F0B): "Invalid limit curve index.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F0C): "pLLD auto adjustment was not successful.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F01): "Calibration has not been started.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F02): "Unable to read from the pressure potentiometer.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F03): "Unable to write to the pressure potentiometer.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F04): "Calibration was not successful.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F05): "Unable to automatically adjust the pressure sensor.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F06): "A tip is not held.", + ( + 0x0001, + 0x00E8, + 0x0205, + 1, + 0x0F01, + ): "An error occurred communicating to the digital potentiometer.", + (0x0001, 0x00E8, 0x0205, 1, 0x0F02): "Unable to automatically adjust the pressure sensor.", + # MLPrep node (0x0001) — instrument-level errors + (0x0001, 0x0001, 0x6000, 1, 0x0F01): "The particulate sensor fan is blocked.", + (0x0001, 0x0001, 0x6000, 1, 0x0F02): "The particulate sensor reported an internal issue.", + (0x0001, 0x0001, 0x6000, 1, 0x0F03): "The particulate sensor reported a laser failure.", + (0x0001, 0x0001, 0x1500, 1, 0x0F01): "Cannot change tip definitions when tips are held.", + (0x0001, 0x0001, 0x1500, 1, 0x0F02): "Already in the Power Down Requested state.", + ( + 0x0001, + 0x0001, + 0x1500, + 1, + 0x0F03, + ): "Must request to Power Down before confirming or canceling the procedure.", + (0x0001, 0x0001, 0x1500, 1, 0x0F04): "The channels already have power applied.", + (0x0001, 0x0001, 0x1500, 1, 0x0F05): "No tips can be held during channel head swap.", + ( + 0x0001, + 0x0001, + 0x1500, + 1, + 0x0F06, + ): "The method may only be invoked while the system is in the Suspended state.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F01): "No pipettors registered with the controller.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F02): "No MPH registered with the controller.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F03): "No HHS registered with the controller.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F04): "Cannot manipulate channel axes with tips held.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F05): "No Hod registered with the controller.", + ( + 0x0001, + 0x0001, + 0x1300, + 1, + 0x0F01, + ): "The deck configuration entry for the self calibration fixture is not defined.", + ( + 0x0001, + 0x0001, + 0x1300, + 1, + 0x0F02, + ): "BeginCalibration has not been called before invoking calibration commands.", + (0x0001, 0x0001, 0x1300, 1, 0x0F03): "Calibration cannot be performed with tips held.", + (0x0001, 0x0001, 0x1300, 1, 0x0F04): "The calculated skew in X is out of allowed tolerance.", + (0x0001, 0x0001, 0x1300, 1, 0x0F05): "The calculated skew in Z is out of allowed tolerance.", + (0x0001, 0x0001, 0x1300, 1, 0x0F06): "The MPH Width is outside of allowed tolerance.", + (0x0001, 0x0001, 0x1300, 1, 0x0F07): "The operation requires a needle definition.", + (0x0001, 0x0001, 0x1300, 1, 0x0F08): "The Z axis for a channel must be calibrated before X or Y.", + ( + 0x0001, + 0x0001, + 0x1300, + 1, + 0x0F09, + ): "The operation cannot be performed with a calibration in progress.", + (0x0001, 0x0001, 0x1301, 1, 0x0F01): "Calibration needs to have been started first.", + (0x0001, 0x0001, 0x1301, 1, 0x0F02): "Calibration is in progress, please finish it first.", + (0x0001, 0x0001, 0x1301, 1, 0x0F03): "Calibration cannot be performed with tips held.", + (0x0001, 0x0001, 0x1301, 1, 0x0F04): "The LLD seeks were not within 0.05mm of eachother.", + ( + 0x0001, + 0x0001, + 0x1301, + 1, + 0x0F05, + ): "The measured width of the MPH is outside of allowed tolerance.", + (0x0001, 0x0001, 0x1301, 1, 0x0F06): "The calibration tool was not detected.", + ( + 0x0001, + 0x0001, + 0x1301, + 1, + 0x0F07, + ): "The measured skew in Z for the MPH is outside of allowed tolerance.", + (0x0001, 0x0001, 0x1302, 1, 0x0F01): "Calibration needs to have been started first.", + (0x0001, 0x0001, 0x1302, 1, 0x0F02): "Calibration cannot be performed with tips held.", + (0x0001, 0x0001, 0x1302, 1, 0x0F03): "The LLD seeks were not within 0.15mm of eachother.", + (0x0001, 0x0001, 0x1302, 1, 0x0F04): "The calibration tool was not detected.", + (0x0001, 0x0001, 0x1304, 1, 0x0F01): "A Site ID was represented more than once.", + (0x0001, 0x0001, 0x2000, 1, 0x0F01): "The current command cannot be paused.", + (0x0001, 0x0001, 0x2000, 1, 0x0F02): "There is no paused command to resume.", + ( + 0x0001, + 0x0001, + 0x2000, + 1, + 0x0F03, + ): "The system cannot resume the paused operation with the door open.", + (0x0001, 0x0001, 0x2000, 1, 0x0F04): "The provided X position would exceed the travel limits.", + (0x0001, 0x0001, 0x2000, 1, 0x0F05): "The provided Y position would exceed the travel limits.", + (0x0001, 0x0001, 0x2000, 1, 0x0F06): "The provided Z position would exceed the travel limits.", + (0x0001, 0x0001, 0x2000, 1, 0x0F07): "Duplicate Channel Indices were present when not allowed.", + (0x0001, 0x0001, 0x2000, 1, 0x0F08): "All X positions of a multi-axis move must match.", + ( + 0x0001, + 0x0001, + 0x2000, + 1, + 0x0F09, + ): "Y positions for channels are in conflict, i.e. channels would need to move through each other.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0A): "Cannot initialize with a plate gripped.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0B): "The door is present and open, movement not allowed.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0C): "The selected tip ID is not valid.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0D): "The X Axis is not initialized.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0E): "A channel's Y Axis is not initialized.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0F): "A channel's Z Axis is not initialized.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F01, + ): "The passed parameters have conflicting Y positions, refer to options.", + (0x0001, 0x0001, 0x2200, 1, 0x0F02): "The given Z position is not valid.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F03, + ): "An axis move was stopped early. See following errors for additional details.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F04, + ): "A coordinated movement was stopped early. See following errors for additional details.", + (0x0001, 0x0001, 0x2200, 1, 0x0F05): "The provided, or calculated, Y position is not valid.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F06, + ): "The calculated path is not possible with the current deck and tip definitions.", + (0x0001, 0x0001, 0x3000, 2, 0x0F01): "An overcurrent condition was detected.", + (0x0001, 0x0001, 0x3100, 1, 0x0F01): "Sequencer Exception.", + (0x0001, 0x0001, 0x3100, 1, 0x0F02): "Static Position Error Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F09): "Position Flag Not Found.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0001, 0x0001, 0x3100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0001, 0x0001, 0x3100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0001, 0x0001, 0x3100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0001, 0x0001, 0x3100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0001, 0x0001, 0x3100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0001, 0x0001, 0x3100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0001, 0x0001, 0x3100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0001, 0x0001, 0x3100, 1, 0x0F17): "Servo Loop Overrun.", + ( + 0x0001, + 0x0001, + 0x4000, + 1, + 0x0F01, + ): "Cannot start a trajectory with zero velocity or acceleration.", + (0x0001, 0x0001, 0x4000, 1, 0x0F02): "The requested motion would exceed a Travel Limit.", + (0x0001, 0x0001, 0x4000, 1, 0x0F03): "The Static Position Error Limit was exceeded.", + (0x0001, 0x0001, 0x4000, 1, 0x0F04): "The Dynamic Position Error Limit was exceeded.", + (0x0001, 0x0001, 0x4000, 1, 0x0F05): "The settling time limit was exceeded.", + (0x0001, 0x0001, 0x4000, 1, 0x0F06): "Servo has not been enabled.", + (0x0001, 0x0001, 0x4000, 1, 0x0F07): "No motion profile has been configured.", + ( + 0x0001, + 0x0001, + 0x4000, + 1, + 0x0F08, + ): "The requested seek event cannot be reached from the current state.", + (0x0001, 0x0001, 0x4000, 1, 0x0F09): "The requested seek event was not reached.", + (0x0001, 0x0001, 0x4000, 2, 0x0F01): "An overcurrent condition was detected.", + (0x0001, 0x0001, 0x4200, 1, 0x8F01): "Unread entries were overwritten.", + ( + 0x0001, + 0x0001, + 0x4300, + 1, + 0x0F01, + ): "Cannot start a trajectory with zero velocity or acceleration.", + ( + 0x0001, + 0x0001, + 0x4310, + 1, + 0x0F01, + ): "Cannot start a trajectory with zero velocity or acceleration.", + (0x0001, 0x0001, 0x1000, 1, 0x0F01): "No Pipettor is present at the provided index.", + (0x0001, 0x0001, 0x1000, 1, 0x0F02): "Command not valid when tips are held.", + (0x0001, 0x0001, 0x1000, 1, 0x0F03): "Command not valid when no tips are held.", + (0x0001, 0x0001, 0x1000, 1, 0x0F04): "Command not valid when a plate is gripped.", + (0x0001, 0x0001, 0x1000, 1, 0x0F05): "Command not valid when no plate is gripped.", + (0x0001, 0x0001, 0x1000, 1, 0x0F06): "Command not valid when a tool is held.", + (0x0001, 0x0001, 0x1000, 1, 0x0F07): "Command not valid when no tool is held.", + ( + 0x0001, + 0x0001, + 0x1000, + 1, + 0x0F08, + ): "Unable to command the MPH from this interface, please use the MPH interface.", + (0x0001, 0x0001, 0x1000, 1, 0x0F09): "The held tool is not of a type supported by the operation.", + (0x0001, 0x0001, 0x1000, 1, 0x0F0A): "The indicated channel's head is not installed.", + ( + 0x0001, + 0x0001, + 0x1000, + 1, + 0x0F0B, + ): "A channel was specified more than once in an operation where each channel can only be used once.", + (0x0001, 0x0001, 0x1000, 1, 0x0F0C): "The same tip type must be held in each channel.", + (0x0001, 0x0001, 0x1100, 1, 0x0F01): "No MPH is installed.", + (0x0001, 0x0001, 0x1100, 1, 0x0F02): "Command not valid when tips are held.", + (0x0001, 0x0001, 0x1100, 1, 0x0F03): "Command not valid when no tips are held.", + (0x0001, 0x0001, 0x1100, 1, 0x0F04): "The MPH cannot pick up a tip with tool definitions.", + ( + 0x0001, + 0x0001, + 0x1100, + 1, + 0x0F05, + ): "Unable to command an Individual Channel from this interface, please use the Pipettor interface.", + (0x0001, 0x0001, 0x1100, 1, 0x0F06): "The MPH head is not installed.", +} diff --git a/pylabrobot/hamilton/tcp/hoi_error.py b/pylabrobot/hamilton/tcp/hoi_error.py new file mode 100644 index 00000000000..8762899a639 --- /dev/null +++ b/pylabrobot/hamilton/tcp/hoi_error.py @@ -0,0 +1,208 @@ +"""HOI exception handling for Hamilton TCP. + +Provides :class:`HoiError` for non-channel ``STATUS_EXCEPTION`` / ``COMMAND_EXCEPTION`` +frames, and parsers that turn HOI exception/warning params into +:class:`~pylabrobot.hamilton.tcp.wire_types.HcResultEntry` rows and human-readable +strings. STATUS/COMMAND exception param walking and semicolon-separated HC-result +strings (warning-prefix fragment 1) live here; framing and success response decode +remain in :mod:`pylabrobot.hamilton.tcp.messages`. +""" + +from __future__ import annotations + +import re +from typing import Dict, List, Optional + +from pylabrobot.hamilton.tcp.wire_types import ( + HamiltonDataType, + HcResultEntry, + decode_fragment, +) + +_ERROR_ENTRY_RE: Optional[re.Pattern[str]] = None + + +def _error_entry_pattern() -> re.Pattern[str]: + global _ERROR_ENTRY_RE + if _ERROR_ENTRY_RE is None: + _ERROR_ENTRY_RE = re.compile( + r"0x([0-9a-fA-F]+)\.0x([0-9a-fA-F]+)\.0x([0-9a-fA-F]+)" + r":0x([0-9a-fA-F]+),0x([0-9a-fA-F]+)(?:,0x([0-9a-fA-F]+))?" + ) + return _ERROR_ENTRY_RE + + +def parse_hamilton_error_entries(params: bytes) -> List[HcResultEntry]: + """Extract every ``HcResultEntry`` from HOI exception params. + + Hamilton ``COMMAND_EXCEPTION`` / ``STATUS_EXCEPTION`` responses can carry + one ``HcResultEntry`` per affected channel, serialized as STRING fragments + of the form ``0xMMMM.0xNNNN.0xOOOO:0xII,0xCCCC,0xRRRR`` (address, + interface_id, method_id, hc_result). On a two-channel tip-pickup where both + channels fail, the firmware emits two such strings — returning only the + first one (as the old ``parse_hamilton_error_entry`` did) silently dropped + the second channel's error. + + This walks every fragment and uses ``re.finditer`` within each STRING so + multi-entry fragments are also covered. Returns entries in wire order — the + backend uses ``_channel_index_for_entry(i, entry)`` on each to map to a PLR + channel, matching the warning-frame prefix's ordinal semantics. + """ + pat = _error_entry_pattern() + out: List[HcResultEntry] = [] + if not params: + return out + offset = 0 + while offset + 4 <= len(params): + type_id = params[offset] + length = int.from_bytes(params[offset + 2 : offset + 4], "little") + payload_end = offset + 4 + length + if payload_end > len(params): + return out + data = params[offset + 4 : payload_end] + if type_id == HamiltonDataType.STRING: + text = data.decode("utf-8", errors="replace").rstrip("\x00").strip() + for m in pat.finditer(text): + out.append( + HcResultEntry( + module_id=int(m.group(1), 16), + node_id=int(m.group(2), 16), + object_id=int(m.group(3), 16), + interface_id=int(m.group(4), 16), + action_id=int(m.group(5), 16), + result=int(m.group(6), 16) if m.group(6) else 0, + ) + ) + offset = payload_end + return out + + +def parse_hamilton_error_entry(params: bytes) -> Optional[HcResultEntry]: + """Back-compat shim: returns the first entry from :func:`parse_hamilton_error_entries`.""" + entries = parse_hamilton_error_entries(params) + return entries[0] if entries else None + + +def parse_hamilton_error_params(params: bytes) -> str: + """Extract a human-readable message from HOI exception params. + + Hamilton COMMAND_EXCEPTION / STATUS_EXCEPTION responses send params as a + sequence of DataFragments. Often the first or second fragment is a STRING + (type_id=15) with a message like "0xE001.0x0001.0x1100:0x01,0x009,0x020A". + This walks the fragment stream, decodes all fragments, and returns a + single string (so you can see error codes and the message). If parsing + fails, returns a safe fallback (hex or generic message). + """ + parts = _parse_hamilton_error_fragments(params) + if not parts: + return params.hex() if params else "(empty)" + return "; ".join(parts) + + +def _parse_hamilton_error_fragments(params: bytes) -> List[str]: + """Decode all DataFragments in exception params. Returns list of "type: value" strings.""" + if not params: + return [] + out: List[str] = [] + offset = 0 + while offset + 4 <= len(params): + type_id = params[offset] + length = int.from_bytes(params[offset + 2 : offset + 4], "little") + payload_end = offset + 4 + length + if payload_end > len(params): + break + data = params[offset + 4 : payload_end] + try: + decoded = decode_fragment(type_id, data) + try: + type_name = HamiltonDataType(type_id).name + except ValueError: + type_name = f"type_{type_id}" + if isinstance(decoded, bytes): + decoded = decoded.decode("utf-8", errors="replace").rstrip("\x00").strip() + elif ( + type_id == HamiltonDataType.U8_ARRAY + and isinstance(decoded, list) + and all(isinstance(x, int) and 0 <= x <= 255 for x in decoded) + ): + b = bytes(decoded) + s = b.decode("utf-8", errors="replace").rstrip("\x00").strip() + # Strip leading control characters (e.g. length or flags before message text) + s = s.lstrip( + "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" + ).strip() + if s and any(c.isprintable() or c.isspace() for c in s): + decoded = s + out.append(f"{type_name}={decoded}") + except Exception: + out.append(f"type_{type_id}=<{length} bytes>") + offset = payload_end + return out + + +def parse_hc_results_from_semicolon_string(text: str) -> list[HcResultEntry]: + """Parse the semicolon-separated HOI result string (e.g. warning-prefix fragment 1). + + Same segment format as ``HoiDecoder2.GetHcResults`` in the vendor stack. + Each segment is ``0xMMMM.0xMMMM.0xMMMM:0xII,0xAAAA,0xRRRR`` (address + iface, action, result). + Malformed segments are skipped, matching the C# try/except behavior. + """ + entries: list[HcResultEntry] = [] + for segment in text.split(";"): + segment = segment.strip() + if not segment: + continue + try: + addr_part, rest = segment.split(":", 1) + addr_part = addr_part.replace("0x", "").replace("0X", "") + rest = rest.replace("0x", "").replace("0X", "") + mod_s, node_s, obj_s = addr_part.split(".", 2) + module_id = int(mod_s, 16) + node_id = int(node_s, 16) + object_id = int(obj_s, 16) + fields = [x.strip() for x in rest.split(",")] + if len(fields) < 3: + continue + interface_id = int(fields[0], 16) + action_id = int(fields[1], 16) + result = int(fields[2], 16) + entries.append( + HcResultEntry( + module_id=module_id, + node_id=node_id, + object_id=object_id, + interface_id=interface_id, + action_id=action_id, + result=result, + ) + ) + except (ValueError, IndexError): + continue + return entries + + +class HoiError(Exception): + """Raised for ``STATUS_EXCEPTION`` / ``COMMAND_EXCEPTION`` when the command wire shape + does not carry per-channel parameters (e.g. void MLPrep queries). + + Wraps the same enriched per-entry exceptions as the channelized path + (``describe_entry`` / error tables); :attr:`exceptions` is keyed by **wire entry + index**, not physical channel index. Use :attr:`entries` for raw + :class:`HcResultEntry` data. + """ + + def __init__( + self, + *, + exceptions: Dict[int, Exception], + entries: List[HcResultEntry], + raw_response: bytes, + ) -> None: + self.exceptions = exceptions + self.entries = entries + self.raw_response = raw_response + super().__init__(self._format_message()) + + def _format_message(self) -> str: + parts = [f"entry[{i}]: {self.exceptions[i]}" for i in sorted(self.exceptions)] + return "HoiError(" + "; ".join(parts) + ")" diff --git a/pylabrobot/hamilton/tcp/interface_bundle.py b/pylabrobot/hamilton/tcp/interface_bundle.py new file mode 100644 index 00000000000..7f304867ac7 --- /dev/null +++ b/pylabrobot/hamilton/tcp/interface_bundle.py @@ -0,0 +1,70 @@ +"""Resolve logical interface roles to firmware :class:`Address` values via dot-paths. + +Drivers supply a mapping of role name → :class:`InterfacePathSpec` (path, required flags). +This module performs the shared ``resolve_path`` loop and logging; product-specific +typed bundles (e.g. ``PrepResolvedInterfaces``) live next to each driver. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Mapping, Optional + +from pylabrobot.hamilton.tcp.packets import Address + +if TYPE_CHECKING: + from pylabrobot.hamilton.tcp.client import HamiltonTCPClient + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class InterfacePathSpec: + """Single logical interface: strict dot-path and resolution policy.""" + + path: str + required: bool + raise_when_missing: bool = True + + +async def resolve_interface_path_specs( + client: HamiltonTCPClient, + specs: Mapping[str, InterfacePathSpec], + *, + instrument_label: str = "instrument", +) -> dict[str, Optional[Address]]: + """Resolve each path; required interfaces fail fast on :exc:`KeyError` from ``resolve_path``.""" + resolved: dict[str, Optional[Address]] = {} + for name, spec in specs.items(): + try: + addr = await client.resolve_path(spec.path) + resolved[name] = addr + logger.debug( + "Resolved %s interface %s → %s (%s)", + instrument_label, + name, + addr, + spec.path, + ) + except KeyError: + if spec.required: + raise RuntimeError( + f"Could not find required interface '{name}' ({spec.path}) on {instrument_label}." + ) from None + resolved[name] = None + if spec.raise_when_missing: + logger.warning( + "Optional %s interface missing: %s (%s)", + instrument_label, + name, + spec.path, + ) + + found = sorted(n for n, a in resolved.items() if a is not None) + missing_opt = sorted(n for n, s in specs.items() if not s.required and resolved.get(n) is None) + logger.info("%s interfaces: %s", instrument_label, ", ".join(found)) + if missing_opt: + logger.info("%s optional not present: %s", instrument_label, ", ".join(missing_opt)) + + return resolved diff --git a/pylabrobot/hamilton/tcp/introspection.py b/pylabrobot/hamilton/tcp/introspection.py index fd6fedb04ab..f4fcc8865d8 100644 --- a/pylabrobot/hamilton/tcp/introspection.py +++ b/pylabrobot/hamilton/tcp/introspection.py @@ -1,29 +1,115 @@ """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. +Provides dynamic discovery via Interface 0 methods (GetObject, GetMethod, +GetStructs, GetEnums, GetInterfaces, GetSubobjectAddress). + +:class:`HamiltonIntrospection` receives its transport dependencies (registry, +send_discovery_command, send_query) as explicit callables — no back-reference +to the client. The client constructs it via the lazy +:attr:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient.introspection` property, +which is the **only** supported entry point from application code. + +**Runtime defaults (lazy, cache-friendly):** + +- :meth:`~HamiltonIntrospection.ensure_method_table` / + :meth:`~HamiltonIntrospection.methods_for_interface` — scan GetMethod once per object. +- :meth:`~HamiltonIntrospection.ensure_structs_enums` — fetch GetStructs/GetEnums per + HO interface when needed (e.g. for signature resolution). +- :meth:`~HamiltonIntrospection.ensure_global_type_pool` — build + :class:`GlobalTypePool` once per session for ``source_id=1`` refs. +- :meth:`~HamiltonIntrospection.resolve_signature` — resolves a method string without + a pre-built :class:`TypeRegistry` (unless you pass one). + +**Export / parity / codegen (eager composed dumps):** + +- :meth:`~HamiltonIntrospection.build_type_registry` — full structs/enums per + interface (same wire as composing :meth:`~HamiltonIntrospection.ensure_structs_enums` + for each iface). +- :meth:`~HamiltonIntrospection.build_global_type_pool` — full global walk (does not + use the session singleton; use :meth:`~HamiltonIntrospection.ensure_global_type_pool` + for lazy ``source_id=1`` resolution). + +Example (typical notebook):: + + client = HamiltonTCPClient(host=..., port=...) + await client.setup() + intro = client.introspection + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9) """ from __future__ import annotations import logging from dataclasses import dataclass, field -from typing import Any, Dict, List +from enum import IntEnum +from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Set, Tuple, Union, cast -from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.messages import ( + PADDED_FLAG, HoiParams, HoiParamsParser, + inspect_hoi_params, ) from pylabrobot.hamilton.tcp.packets import Address -from pylabrobot.hamilton.tcp.protocol import ( +from pylabrobot.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.hamilton.tcp.wire_types import ( + U16, + U32, HamiltonDataType, - HamiltonProtocol, + I8Array, + I32Array, + Str, + StrArray, + U8Array, + U32Array, ) logger = logging.getLogger(__name__) + +class Direction(IntEnum): + """Direction of a method parameter in the HOI introspection type system. + + Column order matches ``_HOI_TYPE_ROWS`` ids tuple: ``ids[Direction]`` gives the + direction-encoded HOI type ID for that row and direction. + """ + + In = 0 + Out = 1 + InOut = 2 + RetVal = 3 + + + +async def _subobject_address_and_info( + intro: "HamiltonIntrospection", parent_addr: Address, index: int +) -> Tuple[Address, ObjectInfo]: + """Resolve one subobject index to ``(address, ObjectInfo)`` (shared resolve/tree path).""" + sub_addr = await intro.get_subobject_address(parent_addr, index) + sub_info = await intro.get_object(sub_addr) + return sub_addr, sub_info + + +# Connection/transport errors that should propagate immediately rather than +# being swallowed by introspection catch blocks. A dead connection would +# otherwise cause N individual timeouts (one per method) before the caller +# sees any error. +_TRANSIENT_ERRORS = ( + TimeoutError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + BrokenPipeError, + OSError, +) + +# Known network/built-in structs (source_id=3). These types are not queryable +# via introspection — their wire format was determined empirically by calling +# methods that return them (e.g. GetDeckCalibration on PipettorCalibration). +# Populated lazily below after StructInfo is defined. +_NETWORK_STRUCTS: Dict[int, "StructInfo"] = {} + # ============================================================================ # TYPE RESOLUTION HELPERS # ============================================================================ @@ -39,143 +125,108 @@ def resolve_type_id(type_id: int) -> str: Human-readable type name """ try: - return HamiltonDataType(type_id).name + return cast(str, 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 MAPPING (2D table from HoiObject.mHoiParamTypes) # ============================================================================ -# 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. +# Each row maps one wire kind (HamiltonDataType) × 4 directions (Direction enum) +# to the direction-encoded HOI type IDs the firmware uses in GetMethod responses. +# Source: vendor protocol reference mHoiParamTypes[31,4]. - 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" +@dataclass(frozen=True) +class _HoiTypeRow: + """One row in vendor mHoiParamTypes[31,4] with readable display metadata. -def resolve_introspection_type_name(type_id: int) -> str: - """Resolve introspection type ID to readable name. + ``ids`` always follows the interface-0 type table column order: + ``(In, Out, InOut, RetVal)``. These columns are specific to the firmware's + interface-0 HOI type system, a unique typing scheme separate from the + standard ``HamiltonDataType`` wire type IDs. - Args: - type_id: Introspection type ID + ``wire_type``: the ``HamiltonDataType`` that this HOI kind maps to on the wire. + This is the bridge between the two type systems: HOI introspection IDs are + direction-encoded variants of a ``wire_type`` kind. - Returns: - Human-readable type name + ``is_complex``: type requires additional source_id/ref_id bytes in method param encoding. + ``is_struct_kind``: type references a struct definition (subset of complex). + ``is_enum_kind``: type references an enum definition (subset of complex). """ - return _INTROSPECTION_TYPE_NAMES.get(type_id, f"UNKNOWN_TYPE_{type_id}") + display_name: str + ids: tuple[int, int, int, int] # Interface-0 column order: [In, Out, InOut, RetVal] + wire_type: HamiltonDataType = HamiltonDataType.VOID + is_complex: bool = False + is_struct_kind: bool = False + is_enum_kind: bool = False + + +_HOI_TYPE_ROWS: tuple[_HoiTypeRow, ...] = ( + _HoiTypeRow("i8", (1, 17, 9, 25), HamiltonDataType.I8), + _HoiTypeRow("i16", (3, 19, 11, 27), HamiltonDataType.I16), + _HoiTypeRow("i32", (5, 21, 13, 29), HamiltonDataType.I32), + _HoiTypeRow("u8", (2, 18, 10, 26), HamiltonDataType.U8), + _HoiTypeRow("u16", (4, 20, 12, 28), HamiltonDataType.U16), + _HoiTypeRow("u32", (6, 22, 14, 30), HamiltonDataType.U32), + _HoiTypeRow("str", (7, 23, 15, 31), HamiltonDataType.STRING), + _HoiTypeRow("bool", (33, 35, 34, 36), HamiltonDataType.BOOL), + _HoiTypeRow("List[i8]", (37, 39, 38, 40), HamiltonDataType.I8_ARRAY), + _HoiTypeRow("List[i16]", (41, 43, 42, 44), HamiltonDataType.I16_ARRAY), + _HoiTypeRow("List[i32]", (49, 51, 50, 52), HamiltonDataType.I32_ARRAY), + _HoiTypeRow("bytes", (8, 24, 16, 32), HamiltonDataType.U8_ARRAY), + _HoiTypeRow("List[u16]", (45, 47, 46, 48), HamiltonDataType.U16_ARRAY), + _HoiTypeRow("List[u32]", (53, 55, 54, 56), HamiltonDataType.U32_ARRAY), + _HoiTypeRow("List[bool]", (66, 68, 67, 69), HamiltonDataType.BOOL_ARRAY), + _HoiTypeRow("HcResult", (70, 72, 71, 73), HamiltonDataType.HC_RESULT, is_complex=True), + _HoiTypeRow("struct", (57, 59, 58, 60), HamiltonDataType.STRUCTURE, is_complex=True, is_struct_kind=True), + _HoiTypeRow("List[struct]",(61, 63, 62, 64), HamiltonDataType.STRUCTURE_ARRAY, is_complex=True, is_struct_kind=True), + _HoiTypeRow("List[str]", (74, 76, 75, 77), HamiltonDataType.STRING_ARRAY, is_complex=True), + _HoiTypeRow("enum", (78, 80, 79, 81), HamiltonDataType.ENUM, is_complex=True, is_enum_kind=True), + _HoiTypeRow("List[enum]", (82, 84, 83, 85), HamiltonDataType.ENUM_ARRAY, is_complex=True, is_enum_kind=True), + _HoiTypeRow("i64", (86, 88, 87, 89), HamiltonDataType.I64), + _HoiTypeRow("u64", (90, 92, 91, 93), HamiltonDataType.U64), + _HoiTypeRow("f32", (94, 96, 95, 97), HamiltonDataType.F32), + _HoiTypeRow("f64", (98, 100, 99, 101), HamiltonDataType.F64), + _HoiTypeRow("List[i64]", (102, 104, 103, 105), HamiltonDataType.I64_ARRAY), + _HoiTypeRow("List[u64]", (106, 108, 107, 109), HamiltonDataType.U64_ARRAY), + _HoiTypeRow("List[f32]", (110, 112, 111, 113), HamiltonDataType.F32_ARRAY), + _HoiTypeRow("List[f64]", (114, 116, 115, 117), HamiltonDataType.F64_ARRAY), + _HoiTypeRow("HoiResult", (118, 120, 119, 121), HamiltonDataType.HOI_RESULT, is_complex=True), + _HoiTypeRow("padding", (0, 0, 0, 0), HamiltonDataType.VOID), +) -def is_complex_introspection_type(type_id: int) -> bool: - """Check if introspection type is complex (needs additional bytes). +# HOI method-param type IDs that require extra source_id/ref_id bytes on the wire +# (rows where is_complex=True). Used as a parsing guard in _parse_method_param_types. +_COMPLEX_METHOD_TYPE_IDS: frozenset[int] = frozenset( + tid for row in _HOI_TYPE_ROWS if row.is_complex for tid in row.ids if tid != 0 +) - Complex types require 3 bytes total: type_id, source_id, struct_id/enum_id +# GetStructs wire sentinels for complex field types (HamiltonDataType namespace, not HOI). +# Used as a parsing guard in _parse_struct_field_types. +_COMPLEX_STRUCT_TYPE_IDS: frozenset[int] = frozenset( + { + HamiltonDataType.STRUCTURE, + HamiltonDataType.STRUCTURE_ARRAY, + HamiltonDataType.ENUM, + HamiltonDataType.ENUM_ARRAY, + } +) - Args: - type_id: Introspection type ID +# Reverse lookup: direction-encoded HOI ID → (wire_type, Direction). +# Built from _HOI_TYPE_ROWS: each row encodes one wire kind × 4 directions. +# This is the bridge between the HOI introspection namespace and HamiltonDataType. +_HOI_ID_TO_WIRE: Dict[int, Tuple[HamiltonDataType, Direction]] = {} +for _row in _HOI_TYPE_ROWS: + for _ci, _tid in enumerate(_row.ids): + if _tid != 0: + _HOI_ID_TO_WIRE[_tid] = (_row.wire_type, Direction(_ci)) +# Empirical: ID 113 (List[f32] RetVal column) observed as In argument on some firmware. +# TODO: Re-validate against hardware captures and remove if no longer observed. +_HOI_ID_TO_WIRE[113] = (HamiltonDataType.F32_ARRAY, Direction.In) - Returns: - True if type is complex - """ - return type_id in _COMPLEX_TYPE_IDS # ============================================================================ @@ -192,6 +243,361 @@ class ObjectInfo: method_count: int subobject_count: int address: Address + children: Dict[str, "ObjectInfo"] = field(default_factory=dict) + + +class ObjectRegistry: + """Pure key-value cache: path ↔ ObjectInfo and address → path. + + No async logic; all traversal lives in :class:`HamiltonIntrospection`. + """ + + def __init__(self): + self._objects: Dict[str, ObjectInfo] = {} + self._address_to_path: Dict[Address, str] = {} + self._root_address: Optional[Address] = None + + def set_root_address(self, address: Address) -> None: + self._root_address = address + + def get_root_address(self) -> Optional[Address]: + return self._root_address + + def register(self, path: str, obj: ObjectInfo) -> None: + self._objects[path] = obj + self._address_to_path[obj.address] = path + + def address_for(self, path: str) -> Optional[Address]: + obj = self._objects.get(path) + return obj.address if obj is not None else None + + def path(self, address: Address) -> Optional[str]: + return self._address_to_path.get(address) + + +@dataclass +class FirmwareTreeNode: + """One node in a discovered firmware object tree.""" + + path: str + address: Address + object_info: ObjectInfo + supported_interface0_methods: Set[int] = field(default_factory=set) + children: List["FirmwareTreeNode"] = field(default_factory=list) + + def format_lines( + self, prefix: str = "", is_last: bool = True, is_root: bool = False + ) -> List[str]: + # Most objects expose the full Interface-0 contract (1..6). Hide it in + # default rendering to keep large trees readable; only show deviations. + full_i0_contract = {1, 2, 3, 4, 5, 6} + show_i0 = self.supported_interface0_methods != full_i0_contract + i0_suffix = "" + if show_i0: + method_ids = ",".join(str(v) for v in sorted(self.supported_interface0_methods)) + i0_suffix = f", i0=[{method_ids}]" + branch = "" if is_root else ("└─ " if is_last else "├─ ") + lines = [ + f"{prefix}{branch}{self.path} @ {self.address} " + f"(methods={self.object_info.method_count}, subobjects={self.object_info.subobject_count}" + f"{i0_suffix})" + ] + child_prefix = prefix + (" " if is_last or is_root else "│ ") + for idx, child in enumerate(self.children): + child_is_last = idx == len(self.children) - 1 + lines.extend(child.format_lines(prefix=child_prefix, is_last=child_is_last, is_root=False)) + return lines + + def __str__(self) -> str: + return "\n".join(self.format_lines(is_root=True)) + + +def flatten_firmware_tree(node: FirmwareTreeNode) -> List[Tuple[str, Address, ObjectInfo]]: + """Preorder flattening of a :class:`FirmwareTreeNode` for path-keyed lookups. + + Returns ``(dot_path, address, object_info)`` for each node (root first, DFS). + """ + out: List[Tuple[str, Address, ObjectInfo]] = [] + + def walk(n: FirmwareTreeNode) -> None: + out.append((n.path, n.address, n.object_info)) + for child in n.children: + walk(child) + + walk(node) + return out + + + +@dataclass +class MethodParamType: + """A method parameter or return type from GetMethod, in the HOI introspection namespace. + + ``wire_type`` is the ``HamiltonDataType`` this HOI kind maps to on the wire — + the bridge between the direction-encoded HOI IDs and the wire encoding layer. + ``direction`` records whether this entry is In/Out/InOut/RetVal in the method signature. + + Struct/enum references additionally carry source_id and ref_id: + source_id 1=global, 2=local, 3=network, 4=node-global. + ref_id is the struct/enum index within the pool identified by source_id. + """ + + wire_type: HamiltonDataType + direction: Direction + source_id: Optional[int] = None + ref_id: Optional[int] = None + _byte_width: int = 1 # bytes consumed from the wire blob + + @property + def is_struct_ref(self) -> bool: + return self.wire_type in (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY) + + @property + def is_enum_ref(self) -> bool: + return self.wire_type in (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY) + + @property + def is_argument(self) -> bool: + """True if this is an input parameter (In or InOut).""" + return self.direction in (Direction.In, Direction.InOut) + + @property + def is_return(self) -> bool: + """True if this is a return value (Out or RetVal).""" + return self.direction in (Direction.Out, Direction.RetVal) + + def resolve_name( + self, + registry: Optional["TypeRegistry"] = None, + ho_interface_id: Optional[int] = None, + ) -> str: + """Resolve to a human-readable name, optionally using a TypeRegistry for struct/enum names.""" + base = self.wire_type.name.lower() + if self.source_id is None or self.ref_id is None: + return base + if self.is_struct_ref: + if registry is not None: + s = registry.resolve_struct(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if s: + return s.name + return f"{base}(iface={self.source_id}, id={self.ref_id})" + if self.is_enum_ref: + if registry is not None: + e = registry.resolve_enum(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if e: + return e.name + return f"{base}(iface={self.source_id}, id={self.ref_id})" + return f"{base}(iface={self.source_id}, id={self.ref_id})" + + +def _parse_method_param_types( + data: bytes | list[int], +) -> List[MethodParamType]: + """Parse GetMethod parameterTypes byte stream. + + Source: HoiObject.HandleStruct in HoiObject.cs. + + Encoding per entry: + - Simple type (not in _COMPLEX_METHOD_TYPE_IDS): ``[type_id]`` — 1 byte. + - source_id 1/2/3 (global/local/network): ``[type_id, source_id, ref_id]`` — 3 bytes. + - source_id 4 (node-global): ``[type_id, 4, index, '"', FormatAddress_bytes..., '"', ' ']``. + FormatAddress encodes Module+Node as hex byte pairs, wrapped in ASCII double-quotes. + The index byte is the struct/enum index within the node-global pool. + """ + _NODE_GLOBAL = 4 + _QUOTE = 0x22 + _SPACE = 0x20 + + ints = list(data) if isinstance(data, bytes) else data + result: List[MethodParamType] = [] + i = 0 + while i < len(ints): + tid = ints[i] + wire_type, direction = _HOI_ID_TO_WIRE.get(tid, (HamiltonDataType.VOID, Direction.In)) + if tid in _COMPLEX_METHOD_TYPE_IDS and i + 2 < len(ints): + source_id = ints[i + 1] + ref_id = ints[i + 2] + if source_id == _NODE_GLOBAL: + # [type_id, 4, index, '"', FormatAddress_bytes..., '"', ' '] + end = i + 4 # byte after opening '"' + while end < len(ints) and ints[end] != _QUOTE: + end += 1 + end += 1 # consume closing '"' + if end < len(ints) and ints[end] == _SPACE: + end += 1 # consume trailing ' ' + result.append( + MethodParamType(wire_type, direction, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=end - i) + ) + i = end + else: + result.append(MethodParamType(wire_type, direction, source_id=source_id, ref_id=ref_id, _byte_width=3)) + i += 3 + else: + result.append(MethodParamType(wire_type, direction)) + i += 1 + return result + + +@dataclass +class StructFieldType: + """A struct field type from GetStructs, in the HamiltonDataType wire namespace. + + ``type_id`` is a ``HamiltonDataType`` value — the wire encoding type for this field. + Unlike ``MethodParamType``, struct fields have no direction concept. + + Complex references (STRUCTURE/ENUM) additionally carry source_id and ref_id: + source_id 1=global, 2=local, 3=network, 4=node-global. + ref_id is the struct/enum index within the pool identified by source_id. + """ + + type_id: HamiltonDataType + source_id: Optional[int] = None + ref_id: Optional[int] = None + _byte_width: int = 1 # bytes consumed from the wire blob (1=simple, 3=ref, 7=node-global) + + @property + def is_complex(self) -> bool: + return self.type_id in ( + HamiltonDataType.STRUCTURE, + HamiltonDataType.STRUCTURE_ARRAY, + HamiltonDataType.ENUM, + HamiltonDataType.ENUM_ARRAY, + ) + + @property + def is_struct_ref(self) -> bool: + return self.type_id in (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY) + + @property + def is_enum_ref(self) -> bool: + return self.type_id in (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY) + + def resolve_name( + self, + registry: Optional["TypeRegistry"] = None, + ho_interface_id: Optional[int] = None, + ) -> str: + """Resolve to a human-readable type name, optionally using a TypeRegistry for struct/enum names.""" + if self.is_complex and self.source_id is not None and self.ref_id is not None: + if registry is not None: + if self.is_struct_ref: + s = registry.resolve_struct(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if s: + return f"struct({s.name})" + elif self.is_enum_ref: + e = registry.resolve_enum(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if e: + return e.name + return f"ref(iface={self.source_id}, id={self.ref_id})" + return resolve_type_id(self.type_id) + + +def _parse_struct_field_types( + data: bytes | list[int], +) -> List[StructFieldType]: + """Parse GetStructs structureElementTypes byte stream. + + Source: HoiObject.GetStructs in HoiObject.cs. + + Encoding per entry: + - Simple type (not in _COMPLEX_STRUCT_TYPE_IDS): ``[type_id]`` — 1 byte. + - source_id 1/2/3 (global/local/network): ``[type_id, source_id, ref_id]`` — 3 bytes. + - source_id 4 (node-global, scope.mAddress.ModuleID != 0): + ``[type_id, 4, index, ModHi, ModLo, NodeHi, NodeLo]`` — 7 bytes. + The 4 raw address bytes are written when the node-global object has a non-zero + ModuleID, which is always true for real node-global objects on this instrument. + """ + _NODE_GLOBAL = 4 + _NODE_GLOBAL_WIDTH = 7 + + ints = list(data) if isinstance(data, bytes) else data + result: List[StructFieldType] = [] + i = 0 + while i < len(ints): + tid = ints[i] + wire_type = HamiltonDataType(tid) + if tid in _COMPLEX_STRUCT_TYPE_IDS and i + 2 < len(ints): + source_id = ints[i + 1] + ref_id = ints[i + 2] + if source_id == _NODE_GLOBAL: + # [type_id, 4, index, ModHi, ModLo, NodeHi, NodeLo] = 7 bytes + result.append( + StructFieldType(wire_type, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=_NODE_GLOBAL_WIDTH) + ) + i += _NODE_GLOBAL_WIDTH + else: + result.append(StructFieldType(wire_type, source_id=source_id, ref_id=ref_id, _byte_width=3)) + i += 3 + else: + result.append(StructFieldType(wire_type)) + i += 1 + return result + + +def _parse_type_ids(raw: str | bytes | None) -> List[MethodParamType]: + """Parse GetMethod parameterTypes blob. Thin wrapper around _parse_method_param_types. + + Accepts bytes (preferred) or str — the device sends STRING (15) but the + payload is binary, so callers must use parse_next_raw() to avoid UTF-8 errors. + """ + if raw is None: + return [] + data: list[int] = list(raw) if isinstance(raw, bytes) else [ord(c) for c in raw] + return _parse_method_param_types(data) + + +@dataclass +class MethodFieldDescriptor: + """Canonical representation of one method parameter/return field.""" + + name: str + type_name: str + + +@dataclass +class MethodDescriptor: + """Canonical normalized representation of a method signature.""" + + interface_id: int + method_id: int + name: str + params: list[MethodFieldDescriptor] = field(default_factory=list) + returns: list[MethodFieldDescriptor] = field(default_factory=list) + return_shape: Literal["void", "scalar", "record"] = "void" + + @property + def id_string(self) -> str: + return f"[{self.interface_id}:{self.method_id}]" + + def signature_string(self) -> str: + """Render the canonical method descriptor as a signature string.""" + if self.params: + param_str = ", ".join(f"{p.name}: {p.type_name}" for p in self.params) + else: + param_str = "void" + + if self.return_shape == "void" or not self.returns: + return_str = "void" + elif self.return_shape == "scalar" and len(self.returns) == 1: + ret = self.returns[0] + return_str = f"{ret.name}: {ret.type_name}" if ret.name != "ret0" else ret.type_name + else: + return_str = ( + "{ " + + ", ".join(f"{r.name}: {r.type_name}" for r in self.returns) + + " }" + ) + + return f"{self.id_string} {self.name}({param_str}) -> {return_str}" + + def to_dict(self) -> dict: + return { + "name": self.name, + "id": self.id_string, + "signature": self.signature_string(), + "params": [{"name": p.name, "type": p.type_name} for p in self.params], + "returns": [{"name": r.name, "type": r.type_name} for r in self.returns], + } @dataclass @@ -202,65 +608,196 @@ class MethodInfo: 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 + parameter_types: list[MethodParamType] = field(default_factory=list) + parameter_labels: list[str] = field(default_factory=list) + return_types: list[MethodParamType] = field(default_factory=list) + return_labels: list[str] = field(default_factory=list) + + def describe(self, registry: Optional["TypeRegistry"] = None) -> MethodDescriptor: + """Return the canonical normalized method descriptor used by all serializers.""" + iid = self.interface_id + params: list[MethodFieldDescriptor] = [] 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 + param_type_names = [ + pt.resolve_name(registry, ho_interface_id=iid) for pt in self.parameter_types + ] + for i, type_name in enumerate(param_type_names): + label = self.parameter_labels[i] if i < len(self.parameter_labels) else None + params.append(MethodFieldDescriptor(name=label or f"arg{i}", type_name=type_name)) + + returns: list[MethodFieldDescriptor] = [] + return_shape: Literal["void", "scalar", "record"] = "void" 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_type_names = [ + rt.resolve_name(registry, ho_interface_id=iid) for rt in self.return_types + ] + for i, type_name in enumerate(return_type_names): + label = self.return_labels[i] if i < len(self.return_labels) else None + returns.append(MethodFieldDescriptor(name=label or f"ret{i}", type_name=type_name)) + if len(returns) == 1 and not any(rt.direction == Direction.Out for rt in self.return_types): + return_shape = "scalar" + elif len(returns) > 0: + # Includes Out/ReturnElement records and explicit multi-return methods. + return_shape = "record" + + return MethodDescriptor( + interface_id=self.interface_id, + method_id=self.method_id, + name=self.name, + params=params, + returns=returns, + return_shape=return_shape, + ) - return f"{self.name}({param_str}) -> {return_str}" + def get_signature_string(self, registry: Optional["TypeRegistry"] = None) -> str: + """Get method signature as a readable string. + + If a TypeRegistry is provided, struct/enum references are resolved to + their names (e.g. PickupTipParameters instead of structure(source=2, ref=1)). + """ + return self.describe(registry).signature_string() + + def to_dict(self, registry: Optional["TypeRegistry"] = None) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return self.describe(registry).to_dict() + + +@dataclass +class TypeRegistry: + """Resolved type information for one object. + + Built once from introspection during setup. Caches structs, enums, and + interface info so method signatures can be fully resolved without additional + device calls. Use build_type_registry() to create. + + Source ID semantics (from piglet — the middle byte of a struct/enum type triple): + source_id=1: Global pool (shared type definitions from global objects); ref_id is + 1-based into that flat list (see GlobalTypePool.resolve_struct). + source_id=2: Local types on this object; ref_id is 1-based into the per-interface + struct/enum maps in self.structs / self.enums (struct_id / enum_id from GetStructs + / GetEnums is 0-based). This is NOT ``HOI interface id 2``; ``2`` means *local* + in the type encoding; tables are keyed by real interface ids (typically ``1`` for + ``[1:*]`` methods alongside introspection on ``0``). + source_id=3: Built-in / network types (e.g. NetworkResult-shaped); resolve_struct + does not decode these yet — validate behavior vs Piglet or device captures. + + source_id=0 is not emitted by firmware; treat any such ref as unresolvable. + + For source_id=2, pass ``ho_interface_id`` on ``resolve_struct`` / ``resolve_enum`` so + lookup is strict to the owning interface's local table. + + Example (full export registry):: + + registry = await intro.build_type_registry(mph_addr) + method = registry.get_method(interface_id=1, method_id=9) + print(method.get_signature_string(registry)) # PickupTips(tipParameters: PickupTipParameters, ...) + + For notebooks and runtime tooling, prefer :meth:`~HamiltonIntrospection.resolve_signature` + (lazy types) instead of building a full registry first. + """ + + address: Optional[Address] = None + interfaces: Dict[int, "InterfaceInfo"] = field(default_factory=dict) + structs: Dict[int, Dict[int, "StructInfo"]] = field(default_factory=dict) + enums: Dict[int, Dict[int, "EnumInfo"]] = field(default_factory=dict) + methods: List[MethodInfo] = field(default_factory=list) + global_pool: Optional["GlobalTypePool"] = None + + def resolve_struct( + self, + source_id: int, + ref_id: int, + *, + ho_interface_id: Optional[int] = None, + ) -> Optional["StructInfo"]: + """Look up a struct by source_id and ref_id. + + source_id=1: Global pool (1-based ref_id; see GlobalTypePool.resolve_struct). + source_id=2: Local structs (1-based ref_id -> 0-based struct_id in + ``self.structs[ho_interface_id]``). ``ho_interface_id`` is required for + deterministic interface-scoped resolution. + """ + if source_id == 1 and self.global_pool is not None: + return self.global_pool.resolve_struct(ref_id) + if source_id == 2: + idx = ref_id - 1 + if idx < 0: + return None + if ho_interface_id is None: + return None + return self.structs.get(ho_interface_id, {}).get(idx) + if source_id == 3: + return _NETWORK_STRUCTS.get(ref_id) + logger.warning("resolve_struct: unhandled source_id=%d ref_id=%d", source_id, ref_id) + return None + + def resolve_enum( + self, + source_id: int, + ref_id: int, + *, + ho_interface_id: Optional[int] = None, + ) -> Optional["EnumInfo"]: + """Look up an enum by source_id and ref_id. + + source_id=1: Global pool (1-based ref_id). + source_id=2: Local enums (same rules as resolve_struct). ``ho_interface_id`` is + required for strict interface-scoped resolution. + """ + if source_id == 1 and self.global_pool is not None: + return self.global_pool.resolve_enum(ref_id) + if source_id == 2: + idx = ref_id - 1 + if idx < 0: + return None + if ho_interface_id is None: + return None + return self.enums.get(ho_interface_id, {}).get(idx) + return self.enums.get(source_id, {}).get(ref_id) + + def get_method(self, interface_id: int, method_id: int) -> Optional[MethodInfo]: + """Find a method by interface_id and method_id.""" + for m in self.methods: + if m.interface_id == interface_id and m.method_id == method_id: + return m + return None + + def get_interface_ids(self) -> Set[int]: + """Return the set of interface IDs this object implements.""" + return set(self.interfaces.keys()) + + def to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + addr = ( + f"{self.address.module}:{self.address.node}:{self.address.object}" if self.address else None + ) + structs_out: Dict[int, List[dict[str, Any]]] = {} + for iid, struct_table in sorted(self.structs.items()): + structs_out[iid] = [s.to_dict(self) for _, s in sorted(struct_table.items())] + enums_out: Dict[int, List[dict[str, Any]]] = {} + for iid, enum_table in sorted(self.enums.items()): + enums_out[iid] = [e.to_dict() for _, e in sorted(enum_table.items())] + return { + "address": addr, + "interfaces": [info.to_dict() for _, info in sorted(self.interfaces.items())], + "methods": [m.to_dict(self) for m in self.methods], + "structs": structs_out, + "enums": enums_out, + } + + def print_summary(self) -> None: + """Print a summary of all interfaces, structs, enums, and methods.""" + print(f"TypeRegistry for {self.address}") + print(f" Interfaces: {sorted(self.interfaces.keys())}") + for iid, iface in sorted(self.interfaces.items()): + n_structs = len(self.structs.get(iid, {})) + n_enums = len(self.enums.get(iid, {})) + n_methods = sum(1 for m in self.methods if m.interface_id == iid) + print(f" [{iid}] {iface.name}: {n_structs} structs, {n_enums} enums, {n_methods} methods") + for sid, s in sorted(self.structs.get(iid, {}).items()): + print(f" struct {sid}: {s.name} ({len(s.fields)} fields)") + for eid, e in sorted(self.enums.get(iid, {}).items()): + print(f" enum {eid}: {e.name} ({len(e.values)} values)") @dataclass @@ -271,6 +808,10 @@ class InterfaceInfo: name: str version: str + def to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return {"interface_id": self.interface_id, "name": self.name, "version": self.version} + @dataclass class EnumInfo: @@ -280,35 +821,145 @@ class EnumInfo: name: str values: Dict[str, int] + def to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return {"name": self.name, "enum_id": self.enum_id, "values": dict(self.values)} + @dataclass class StructInfo: - """Struct definition from introspection.""" + """Struct definition from introspection. + + ``interface_id`` records which interface this struct was defined on, + enabling ``source_id=0`` (same-interface) resolution in the global pool. + + ``fields`` maps field names to ``StructFieldType`` instances, preserving the + full (type_id, source_id, ref_id) triple for fields that are complex + references (STRUCTURE/ENUM). Call ``get_struct_string(registry)`` + to get human-readable names with struct/enum references resolved. + """ struct_id: int name: str - fields: Dict[str, int] # field_name -> type_id + fields: Dict[str, "StructFieldType"] # field_name -> StructFieldType + interface_id: Optional[int] = None # Interface this struct was defined on @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()} + """Get human-readable field type names using HamiltonDataType resolver.""" + return {name: sft.resolve_name() for name, sft in self.fields.items()} + + def to_dict(self, registry: Optional["TypeRegistry"] = None) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + ho_iid = self.interface_id + fields = { + name: sft.resolve_name(registry, ho_interface_id=ho_iid) + for name, sft in self.fields.items() + } + d: dict = {"name": self.name, "struct_id": self.struct_id, "fields": fields} + if self.interface_id is not None: + d["interface_id"] = self.interface_id + return d + + def get_struct_string(self, registry: Optional["TypeRegistry"] = None) -> str: + """Get struct definition as a readable string. - def get_struct_string(self) -> str: - """Get struct definition as a readable string.""" + If a TypeRegistry is provided, complex references (struct/enum fields) + are resolved to their names. + """ + ho_iid = self.interface_id field_strs = [ - f"{field_name}: {resolve_type_id(type_id)}" for field_name, type_id in self.fields.items() + f"{name}: {sft.resolve_name(registry, ho_interface_id=ho_iid)}" + for name, sft in self.fields.items() ] fields_str = "\n ".join(field_strs) if field_strs else " (empty)" return f"struct {self.name} {{\n {fields_str}\n}}" +# Populate known network structs now that StructInfo is defined. +# ref_id=3: DateTime — 7 fields: year(U16), month(U8), day(U8), hour(U8), +# minute(U8), second(U8), millisecond(U16). Wire format confirmed via +# GetDeckCalibration on PipettorCalibration. +_NETWORK_STRUCTS[3] = StructInfo( + struct_id=3, + name="DateTime", + fields={ + "year": StructFieldType(HamiltonDataType.U16), + "month": StructFieldType(HamiltonDataType.U8), + "day": StructFieldType(HamiltonDataType.U8), + "hour": StructFieldType(HamiltonDataType.U8), + "minute": StructFieldType(HamiltonDataType.U8), + "second": StructFieldType(HamiltonDataType.U8), + "millisecond": StructFieldType(HamiltonDataType.U16), + }, + interface_id=3, +) + + +@dataclass +class GlobalTypePool: + """Flat, sequentially-indexed pool of structs/enums from global objects. + + Piglet builds this by walking ``robot.globals`` objects, iterating each + interface's structs/enums, and inserting them in encounter order. A + ``source_id=1`` reference uses ``ref_id`` as a **1-based** index into this + pool (piglet subtracts 1 for lookup). + """ + + structs: List[StructInfo] = field(default_factory=list) + enums: List[EnumInfo] = field(default_factory=list) + interface_structs: Dict[int, Dict[int, StructInfo]] = field(default_factory=dict) + + def resolve_struct(self, ref_id: int) -> Optional[StructInfo]: + """Look up global struct by 1-based ref_id.""" + idx = ref_id - 1 # 1-based → 0-based + return self.structs[idx] if 0 <= idx < len(self.structs) else None + + def resolve_struct_local(self, interface_id: int, ref_id: int) -> Optional[StructInfo]: + """Resolve a source_id=0 struct ref within a specific interface.""" + return self.interface_structs.get(interface_id, {}).get(ref_id) + + def resolve_enum(self, ref_id: int) -> Optional[EnumInfo]: + """Look up global enum by 1-based ref_id.""" + idx = ref_id - 1 + return self.enums[idx] if 0 <= idx < len(self.enums) else None + + def to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return { + "structs": [s.to_dict() for s in self.structs], + "enums": [e.to_dict() for e in self.enums], + } + + def print_summary(self) -> None: + """Print global pool summary.""" + print(f"GlobalTypePool: {len(self.structs)} structs, {len(self.enums)} enums") + for i, s in enumerate(self.structs): + print(f" struct[{i + 1}]: {s.name} ({len(s.fields)} fields)") + for i, e in enumerate(self.enums): + print(f" enum[{i + 1}]: {e.name} ({len(e.values)} values)") + + +# GetStructs wire format (device sends 4 separate array fragments): +# [0] STRING_ARRAY = struct names (one per struct) +# [1] U32_ARRAY = numberStructureElements — field count for each struct +# [2] U8_ARRAY = structureElementTypes — flat field type bytes (variable width) +# [3] STRING_ARRAY = structureElementDescriptions — flat field names +# +# structureElementTypes byte encoding: +# - Simple types: 1 byte using HamiltonDataType values (40=F32, 23=BOOL, etc.) +# - Complex references: 3 bytes [sentinel, source_id, ref_id] +# sentinel=30 for STRUCTURE, sentinel=32 for ENUM (matches piglet) +# The HamiltonDataType namespace is used here, NOT the introspection type namespace. + + + # ============================================================================ # INTROSPECTION COMMAND CLASSES # ============================================================================ -class GetObjectCommand(HamiltonCommand): +class GetObjectCommand(TCPCommand): """Get object metadata (command_id=1).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -319,26 +970,15 @@ class GetObjectCommand(HamiltonCommand): 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) + @dataclass(frozen=True) + class Response: + name: Str + version: Str + method_count: U32 + subobject_count: U16 - _, 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): +class GetMethodCommand(TCPCommand): """Get method signature (command_id=2).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -364,67 +1004,67 @@ def parse_response_parameters(cls, data: bytes) -> dict: _, 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 + # The remaining fragments are STRING types containing type IDs as bytes. + # Complex types (struct/enum refs): 3 bytes [type_id, source_id, ref_id] for source_id 1–3; + # node-global (source_id=4): variable-length quote-delimited form — see _parse_method_param_types. + # Labels are comma-separated, one per *logical* parameter (matching MethodParamType count). parameter_labels_str = None if parser.has_remaining(): - _, parameter_types_str = parser.parse_next() + # Fragment 4: parameter_types. Wire type is STRING but payload is binary type IDs; + # use parse_next_raw() to avoid UTF-8 decode failure on bytes 0x80-0xFF. + _, flags, _, param_types_payload = parser.parse_next_raw() + if flags & PADDED_FLAG: + param_types_payload = ( + param_types_payload[:-1] if param_types_payload else param_types_payload + ) + param_types_payload = param_types_payload.rstrip(b"\x00") # STRING null terminator + all_types = _parse_type_ids(param_types_payload) + else: + all_types = [] 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_types: list[MethodParamType] = [] parameter_labels: list[str] = [] - return_types: list[int] = [] + return_types: list[MethodParamType] = [] return_labels: list[str] = [] - for i, type_id in enumerate(all_type_ids): - category = get_introspection_type_category(type_id) + for i, pt in enumerate(all_types): label = all_labels[i] if i < len(all_labels) else None - if category == "Argument": - parameter_types.append(type_id) + if pt.is_argument: + parameter_types.append(pt) if label: parameter_labels.append(label) - elif category in ("ReturnElement", "ReturnValue"): - return_types.append(type_id) + elif pt.is_return: + return_types.append(pt) 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) + raise ValueError( + f"Unknown HOI wire_type={pt.wire_type!r} direction={pt.direction!r}; " + "not in _HOI_ID_TO_WIRE — update _HOI_TYPE_ROWS or add an override." + ) 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 + "parameter_types": parameter_types, + "parameter_labels": parameter_labels, + "return_types": return_types, + "return_labels": return_labels, } -class GetSubobjectAddressCommand(HamiltonCommand): +class GetSubobjectAddressCommand(TCPCommand): """Get subobject address (command_id=3).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -438,22 +1078,21 @@ def __init__(self, object_address: Address, subobject_index: int): 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) + return HoiParams().u16(self.subobject_index) - _, module_id = parser.parse_next() - _, node_id = parser.parse_next() - _, object_id = parser.parse_next() + @dataclass(frozen=True) + class Response: + module_id: U16 + node_id: U16 + object_id: U16 - return {"address": Address(module_id, node_id, object_id)} +class GetInterfacesCommand(TCPCommand): + """Get available interfaces (command_id=4). -class GetInterfacesCommand(HamiltonCommand): - """Get available interfaces (command_id=4).""" + Firmware signature: InterfaceDescriptors(()) -> interfaceIds: I8_ARRAY, interfaceDescriptors: STRING_ARRAY + Returns 2 columnar fragments, not count+rows. + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -463,25 +1102,20 @@ class GetInterfacesCommand(HamiltonCommand): 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) + @dataclass(frozen=True) + class Response: + interface_ids: I8Array + interface_names: StrArray - 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}) +class GetEnumsCommand(TCPCommand): + """Get enum definitions (command_id=5). - return {"interfaces": interfaces} - - -class GetEnumsCommand(HamiltonCommand): - """Get enum definitions (command_id=5).""" + Firmware signature: EnumInfo(interfaceId) -> enumerationNames: STRING_ARRAY, + numberEnumerationValues: U32_ARRAY, enumerationValues: I32_ARRAY, + enumerationValueDescriptions: STRING_ARRAY + Returns 4 columnar fragments, not count+rows. + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -496,32 +1130,15 @@ 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 + @dataclass(frozen=True) + class Response: + enum_names: StrArray + value_counts: U32Array + values: I32Array + value_names: StrArray - enums.append({"enum_id": enum_id, "name": name, "values": values}) - return {"enums": enums} - - -class GetStructsCommand(HamiltonCommand): +class GetStructsCommand(TCPCommand): """Get struct definitions (command_id=6).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -537,29 +1154,36 @@ 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) + @dataclass(frozen=True) + class Response: + """GetStructs returns 4 fragments: struct names, per-struct field counts, flat field type IDs, flat field names. - structs = [] - _, struct_count = parser.parse_next() + Fragment layout (device signature: StructInfo): + [0] STRING_ARRAY = struct names (one per struct) + [1] U32_ARRAY = numberStructureElements: field count for each struct (NOT struct IDs) + [2] U8_ARRAY = structureElementTypes: flat field type IDs across all structs + [3] STRING_ARRAY = structureElementDescriptions: flat field names across all structs + Struct IDs are positional (0-indexed); the device does not send them explicitly. + """ - for _ in range(struct_count): - _, struct_id = parser.parse_next() - _, name = parser.parse_next() + struct_names: StrArray + field_counts: U32Array + field_type_ids: U8Array + field_names: StrArray - # 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}) +# ============================================================================ +# INTERFACE 0 METHOD IDS (Object Discovery / Introspection) +# ============================================================================ +# Used to guard calls: only call an Interface 0 method if it is in the set +# returned by get_supported_interface0_method_ids (from the object's method table). - return {"structs": structs} +GET_OBJECT = 1 +GET_METHOD = 2 +GET_SUBOBJECT_ADDRESS = 3 +GET_INTERFACES = 4 +GET_ENUMS = 5 +GET_STRUCTS = 6 # ============================================================================ @@ -568,15 +1192,435 @@ def parse_response_parameters(cls, data: bytes) -> dict: class HamiltonIntrospection: - """High-level API for Hamilton introspection.""" + """High-level API for Hamilton introspection. - def __init__(self, backend): - """Initialize introspection API. + Uses the object's method table (GetMethod) to determine which Interface 0 + methods are supported and only calls those. Interfaces are per-object; + there is no aggregation from children. - Args: - backend: TCPBackend instance + Dependencies are injected as explicit callables rather than a back-reference + to the client, avoiding the circular reference and the need for a Protocol shim. + Prefer :attr:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient.introspection` + over constructing this class directly from application code. + """ + + def __init__( + self, + registry: ObjectRegistry, + global_object_addresses: list[Address], + send_discovery_command: Callable, + send_query: Callable, + ): + self._registry = registry + self._global_object_addresses = global_object_addresses + self._send_discovery_command = send_discovery_command + self._send_query = send_query + # Session caches (invalidated by replacing the HamiltonIntrospection instance, e.g. reconnect). + self._method_table_by_address: Dict[Address, List[MethodInfo]] = {} + self._iface_types: Dict[ + Tuple[Address, int], Tuple[Dict[int, StructInfo], Dict[int, EnumInfo]] + ] = {} + self._interfaces_by_address: Dict[Address, List[InterfaceInfo]] = {} + self._hc_result_text_by_addr_iface: Dict[Tuple[Address, int], Dict[int, str]] = {} + self._supported_i0_by_address: Dict[Address, Set[int]] = {} + self._global_type_pool_singleton: Optional[GlobalTypePool] = None + self._firmware_tree_cache: Optional[FirmwareTreeNode] = None + + def clear_session_caches(self) -> None: + """Drop cached method tables, per-interface structs/enums, and the global type pool.""" + self._method_table_by_address.clear() + self._iface_types.clear() + self._interfaces_by_address.clear() + self._hc_result_text_by_addr_iface.clear() + self._supported_i0_by_address.clear() + self._global_type_pool_singleton = None + self._firmware_tree_cache = None + + def _attach_iface_types_to_registry( + self, registry: TypeRegistry, addr: Address, iface_id: int + ) -> None: + """Copy cached structs/enums for (addr, iface_id) into *registry*.""" + entry = self._iface_types.get((addr, iface_id)) + if entry is not None: + structs_map, enums_map = entry + registry.structs[iface_id] = dict(structs_map) + registry.enums[iface_id] = dict(enums_map) + + async def _ensure_parameter_types_for_signature( + self, + addr: Address, + method: MethodInfo, + registry: TypeRegistry, + ) -> None: + """Load structs/enums needed to resolve *method* signatures (recursive struct walk).""" + seen_structs: Set[Tuple[int, int]] = set() + max_nodes = 256 + + async def walk(types: List[Union[MethodParamType, StructFieldType]], ho_iface: int) -> None: + for pt in types: + if pt.source_id is None or pt.ref_id is None: + continue + if pt.source_id in (1, 3): + continue + if pt.source_id != 2: + continue + if pt.is_enum_ref: + await self.ensure_structs_enums(addr, ho_iface) + self._attach_iface_types_to_registry(registry, addr, ho_iface) + continue + if pt.is_struct_ref: + await self.ensure_structs_enums(addr, ho_iface) + self._attach_iface_types_to_registry(registry, addr, ho_iface) + st = registry.resolve_struct(2, pt.ref_id, ho_interface_id=ho_iface) + if st is None: + continue + field_iface = st.interface_id if st.interface_id is not None else ho_iface + sig = (field_iface, st.struct_id) + if sig in seen_structs: + continue + if len(seen_structs) >= max_nodes: + logger.warning( + "signature struct walk exceeded %d nodes for %s.%s", + max_nodes, + method.name, + st.name, + ) + return + seen_structs.add(sig) + await walk(list(st.fields.values()), field_iface) + + await walk(method.parameter_types, method.interface_id) + await walk(method.return_types, method.interface_id) + + async def _build_minimal_registry_for_signature( + self, addr: Address, method: MethodInfo + ) -> TypeRegistry: + """TypeRegistry with global pool + lazily filled local tables for *method*.""" + pool = await self.ensure_global_type_pool() + registry = TypeRegistry(address=addr, global_pool=pool) + await self._ensure_parameter_types_for_signature(addr, method, registry) + return registry + + async def _build_global_type_pool_impl(self, global_addresses: List[Address]) -> GlobalTypePool: + """Walk global objects and build a :class:`GlobalTypePool` (full firmware-scale pass).""" + pool = GlobalTypePool() + + for addr in global_addresses: + try: + supported = await self.get_supported_interface0_method_ids(addr) + if GET_INTERFACES not in supported: + continue + + interfaces = await self.get_interfaces(addr, _supported=supported) + # source_id=1 refs index into the first non-zero interface's struct/enum list + # (firmware always resolves via interface_id=1; see HoiObject.HandleStruct). + # Populate interface_structs for all interfaces, but only extend the flat pool + # from the first non-zero interface so ref_ids remain valid. + first_nonzero_seen = False + for iface in interfaces: + if iface.interface_id == 0: + continue + if GET_STRUCTS in supported: + structs = await self.get_structs(addr, iface.interface_id) + pool.interface_structs[iface.interface_id] = {s.struct_id: s for s in structs} + if not first_nonzero_seen: + pool.structs.extend(structs) + if GET_ENUMS in supported: + enums = await self.get_enums(addr, iface.interface_id) + if not first_nonzero_seen: + pool.enums.extend(enums) + first_nonzero_seen = True + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.warning("build_global_type_pool failed for %s: %s", addr, e) + + logger.info( + "Global type pool built: %d structs, %d enums from %d global objects", + len(pool.structs), + len(pool.enums), + len(global_addresses), + ) + return pool + + async def ensure_method_table( + self, + address: Union[Address, str], + *, + _supported: Optional[Set[int]] = None, + _object_info: Optional[ObjectInfo] = None, + ) -> List[MethodInfo]: + """Scan Interface 0 GetMethod for *address* once and cache the full ``MethodInfo`` table. + + Pass ``_object_info`` / ``_supported`` when the caller already has them to avoid redundant + Interface-0 queries on the cold path. + """ + addr = await self._resolve_target_address(address) + cached = self._method_table_by_address.get(addr) + if cached is not None: + return cached + cached_supported = self._supported_i0_by_address.get(addr) + if cached_supported is not None and GET_METHOD not in cached_supported: + self._method_table_by_address[addr] = [] + return [] + if _supported is not None and GET_METHOD not in _supported: + self._supported_i0_by_address[addr] = set(_supported) + self._method_table_by_address[addr] = [] + return [] + if _object_info is None: + _object_info = await self.get_object(addr) + methods: List[MethodInfo] = [] + for i in range(_object_info.method_count): + try: + method = await self.get_method(addr, i) + methods.append(method) + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.warning("Failed to get method %d for %s: %s", i, addr, e) + self._method_table_by_address[addr] = methods + self._supported_i0_by_address[addr] = {m.method_id for m in methods if m.interface_id == 0} + return methods + + async def methods_for_interface( + self, address: Union[Address, str], interface_id: int + ) -> List[MethodInfo]: + """Return methods for *interface_id* using the cached method table when warm.""" + addr = await self._resolve_target_address(address) + table = await self.ensure_method_table(addr) + return [m for m in table if m.interface_id == interface_id] + + async def ensure_structs_enums(self, address: Union[Address, str], interface_id: int) -> None: + """Run GetStructs/GetEnums for one HO interface and cache under ``(address, interface_id)``.""" + addr = await self._resolve_target_address(address) + key = (addr, interface_id) + if key in self._iface_types: + return + supported = await self.get_supported_interface0_method_ids(addr) + structs_map: Dict[int, StructInfo] = {} + enums_map: Dict[int, EnumInfo] = {} + if GET_STRUCTS in supported: + structs = await self.get_structs(addr, interface_id) + structs_map = {s.struct_id: s for s in structs} + if GET_ENUMS in supported: + enums = await self.get_enums(addr, interface_id) + enums_map = {e.enum_id: e for e in enums} + self._iface_types[key] = (structs_map, enums_map) + hc_result = next((e for e in enums_map.values() if e.name == "HcResult"), None) + if hc_result is not None: + self._hc_result_text_by_addr_iface[key] = {int(v): n for n, v in hc_result.values.items()} + else: + self._hc_result_text_by_addr_iface[key] = {} + + async def get_interface_name( + self, address: Union[Address, str], interface_id: int + ) -> Optional[str]: + """Return interface name for ``(address, interface_id)`` using session cache.""" + addr = await self._resolve_target_address(address) + infos = self._interfaces_by_address.get(addr) + if infos is None: + infos = await self.get_interfaces(addr) + self._interfaces_by_address[addr] = infos + for info in infos: + if info.interface_id == interface_id: + return info.name + return None + + async def get_hc_result_text( + self, address: Union[Address, str], interface_id: int, code: int + ) -> Optional[str]: + """Resolve HcResult enum text for one interface using cached enums.""" + addr = await self._resolve_target_address(address) + key = (addr, interface_id) + if key not in self._iface_types: + await self.ensure_structs_enums(addr, interface_id) + return self._hc_result_text_by_addr_iface.get(key, {}).get(code) + + async def ensure_global_type_pool( + self, global_addresses: Optional[Sequence[Address]] = None + ) -> GlobalTypePool: + """Return the session-global :class:`GlobalTypePool` (``source_id=1``), building once.""" + if self._global_type_pool_singleton is not None: + return self._global_type_pool_singleton + addrs = ( + list(global_addresses) + if global_addresses is not None + else list(self._global_object_addresses) + ) + self._global_type_pool_singleton = await self._build_global_type_pool_impl(addrs) + return self._global_type_pool_singleton + + async def signature_lines_for_interface( + self, + address: Union[Address, str], + interface_id: int, + *, + max_methods: int = 50, + ) -> List[str]: + """Resolved signature strings for up to *max_methods* methods on *interface_id* (lazy types).""" + addr = await self._resolve_target_address(address) + methods = [m for m in await self.ensure_method_table(addr) if m.interface_id == interface_id][ + :max_methods + ] + lines: List[str] = [] + for m in methods: + reg = await self._build_minimal_registry_for_signature(addr, m) + lines.append(m.get_signature_string(reg)) + return lines + + async def _resolve_target_address(self, addr_or_path: Union[Address, str]) -> Address: + """Resolve Address or dot-path to Address.""" + if isinstance(addr_or_path, str): + return await self.resolve_path(addr_or_path) + return addr_or_path + + async def _walk_node( + self, addr: Address, path: Optional[str], visited: Set[Address] + ) -> Optional[FirmwareTreeNode]: + """Walk one firmware object node, register it, and recurse into children. + + Used by :meth:`_build_firmware_tree` for a full eager DFS. + Pass ``path=None`` to derive the path from the object's own name (root nodes). + Returns ``None`` if *addr* was already visited. """ - self.backend = backend + if addr in visited: + return None + visited.add(addr) + + obj = await self.get_object(addr) + if path is None: + path = obj.name + supported = await self.get_supported_interface0_method_ids(addr) + node = FirmwareTreeNode( + path=path, + address=addr, + object_info=obj, + supported_interface0_methods=supported, + ) + self._registry.register(path, obj) + + # Keep this guard even though Interface-0 method 3 (GetSubobjectAddress) + # appears ubiquitous in current PREP captures. + if GET_SUBOBJECT_ADDRESS not in supported: + return node + + for i in range(obj.subobject_count): + try: + sub_addr, sub_obj = await _subobject_address_and_info(self, addr, i) + obj.children[sub_obj.name] = sub_obj + child = await self._walk_node(sub_addr, f"{path}.{sub_obj.name}", visited) + if child is not None: + node.children.append(child) + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.debug("walk child failed for %s idx=%d: %s", addr, i, e) + return node + + async def resolve_path(self, path: str) -> Address: + """Resolve a dot-path (e.g. ``"MLPrepRoot.MphRoot.MPH"``) to an :class:`Address`. + + Checks the registry cache first. On a miss, resolves one segment at a time — + enumerating only the children needed at each level — so deep paths on large + firmware trees do not trigger a full tree walk. + Raises :exc:`KeyError` if the path cannot be found. + """ + cached = self._registry.address_for(path) + if cached is not None: + return cached + + parts = [p for p in path.split(".") if p] + if not parts: + raise KeyError(f"Invalid path: '{path}'") + + root_addr = self._registry.get_root_address() + if root_addr is None: + raise KeyError(f"No root address registered; cannot resolve path '{path}'") + + root_obj = await self.get_object(root_addr) + self._registry.register(root_obj.name, root_obj) + if root_obj.name != parts[0]: + raise KeyError(f"Root object is '{root_obj.name}', not '{parts[0]}'") + if len(parts) == 1: + return root_addr + + current_addr = root_addr + current_path = parts[0] + for part in parts[1:]: + next_path = f"{current_path}.{part}" + cached = self._registry.address_for(next_path) + if cached is not None: + current_addr = cached + current_path = next_path + continue + + obj = await self.get_object(current_addr) + supported = await self.get_supported_interface0_method_ids(current_addr) + if GET_SUBOBJECT_ADDRESS not in supported: + raise KeyError( + f"'{current_path}' does not support GetSubobjectAddress; cannot resolve child '{part}'" + ) + + found: Optional[Address] = None + for i in range(obj.subobject_count): + sub_addr, sub_obj = await _subobject_address_and_info(self, current_addr, i) + self._registry.register(f"{current_path}.{sub_obj.name}", sub_obj) + if sub_obj.name == part: + found = sub_addr + + if found is None: + raise KeyError(f"Child '{part}' not found under '{current_path}'") + current_addr = found + current_path = next_path + + return current_addr + + async def _build_firmware_tree(self) -> FirmwareTreeNode: + """Build a DFS firmware tree from the single registered root address.""" + root_addr = self._registry.get_root_address() + if root_addr is None: + raise RuntimeError("Cannot build firmware tree: no root address registered") + + visited: Set[Address] = set() + node = await self._walk_node(root_addr, None, visited) + if node is None: + raise RuntimeError(f"Root node walk returned None for address {root_addr}") + return node + + async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTreeNode: + """Return cached firmware tree, or build and cache it when missing.""" + if not refresh and self._firmware_tree_cache is not None: + return self._firmware_tree_cache + + self._firmware_tree_cache = await self._build_firmware_tree() + return self._firmware_tree_cache + + async def get_firmware_tree_flat( + self, refresh: bool = False + ) -> List[Tuple[str, Address, ObjectInfo]]: + """Firmware tree as a flat preorder list of ``(path, address, object_info)``.""" + tree = await self.get_firmware_tree(refresh=refresh) + return flatten_firmware_tree(tree) + + async def get_supported_interface0_method_ids(self, address: Address) -> Set[int]: + """Return the set of Interface 0 method IDs this object supports. + + Calls GetObject to get method_count, then GetMethod(address, i) for each + index and collects method_id for every method where interface_id == 0. + Used to guard calls so we never send an Interface 0 command the object + did not advertise. + """ + cached = self._supported_i0_by_address.get(address) + if cached is not None: + return set(cached) + + methods = self._method_table_by_address.get(address) + if methods is None: + obj = await self.get_object(address) + methods = await self.ensure_method_table(address, _object_info=obj) + supported = {m.method_id for m in methods if m.interface_id == 0} + self._supported_i0_by_address[address] = set(supported) + return set(supported) async def get_object(self, address: Address) -> ObjectInfo: """Get object metadata. @@ -588,13 +1632,15 @@ async def get_object(self, address: Address) -> ObjectInfo: Object metadata """ command = GetObjectCommand(address) - response = await self.backend.send_command(command) + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetObjectCommand returned None") return ObjectInfo( - name=response["name"], - version=response["version"], - method_count=response["method_count"], - subobject_count=response["subobject_count"], + name=response.name, + version=response.version, + method_count=int(response.method_count), + subobject_count=int(response.subobject_count), address=address, ) @@ -609,7 +1655,7 @@ async def get_method(self, address: Address, method_index: int) -> MethodInfo: Method signature """ command = GetMethodCommand(address, method_index) - response = await self.backend.send_command(command) + response = await self._send_discovery_command(command) return MethodInfo( interface_id=response["interface_id"], @@ -633,34 +1679,65 @@ async def get_subobject_address(self, address: Address, subobject_index: int) -> 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]: + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetSubobjectAddressCommand returned None") + + return Address(response.module_id, response.node_id, response.object_id) + + async def get_interfaces( + self, + address: Address, + *, + _supported: Optional[Set[int]] = None, + ) -> List[InterfaceInfo]: """Get available interfaces. + The device returns 2 columnar fragments: interface_ids (I8_ARRAY) and + interface_names (STRING_ARRAY). Returns [] if the object does not support + GetInterfaces (interface 0, method 4). + Args: address: Object address + _supported: Pre-computed supported Interface 0 method IDs (internal; + avoids redundant device queries when the caller already has them). Returns: List of interface information """ + if _supported is None: + _supported = await self.get_supported_interface0_method_ids(address) + if GET_INTERFACES not in _supported: + logger.debug( + "Object at %s does not support GetInterfaces (interface 0, method 4); returning []", + address, + ) + return [] command = GetInterfacesCommand(address) - response = await self.backend.send_command(command) + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetInterfacesCommand returned None") - return [ + ids = list(response.interface_ids) + names = list(response.interface_names) + infos = [ InterfaceInfo( - interface_id=iface["interface_id"], name=iface["name"], version=iface["version"] + interface_id=int(ids[i]), + name=names[i] if i < len(names) else f"Interface_{ids[i]}", + version="", ) - for iface in response["interfaces"] + for i in range(len(ids)) ] + self._interfaces_by_address[address] = infos + return infos async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo]: """Get enum definitions. + The device returns 4 columnar fragments: enum_names (STRING_ARRAY), + value_counts (U32_ARRAY), values (I32_ARRAY), value_names (STRING_ARRAY). + Values/names are split across enums using the value_counts. + Args: address: Object address interface_id: Interface ID @@ -669,16 +1746,58 @@ async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo] 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"] - ] + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetEnumsCommand returned None") + + enum_names = list(response.enum_names) + value_counts = list(response.value_counts) + all_values = list(response.values) + all_value_names = list(response.value_names) + n_enums = len(enum_names) + if n_enums == 0: + return [] + offset = 0 + result: List[EnumInfo] = [] + for i in range(n_enums): + cnt = int(value_counts[i]) if i < len(value_counts) else 0 + names_slice = all_value_names[offset : offset + cnt] + values_slice = all_values[offset : offset + cnt] + vals = dict(zip(names_slice, values_slice)) + result.append(EnumInfo(enum_id=i, name=enum_names[i], values=vals)) + offset += cnt + return result + + async def _get_structs_raw(self, address: Address, interface_id: int) -> tuple[bytes, List[dict]]: + """Get raw GetStructs response bytes and a fragment-by-fragment breakdown. + + Use this to see exactly what the device sends so response parsing can + match the wire format. Returns (params_bytes, inspect_hoi_params(params)). + + Example: + raw, fragments = await intro.get_structs_raw(mph_addr, 1) + for i, f in enumerate(fragments): + print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") + """ + command = GetStructsCommand(address, interface_id) + result = await self._send_query(command) + if result is None: + raise RuntimeError("GetStructs query returned no data.") + (params,) = result + return params, inspect_hoi_params(params) async def get_structs(self, address: Address, interface_id: int) -> List[StructInfo]: """Get struct definitions. + The device returns 4 fragments per the StructInfo signature: + [0] struct_names (StrArray): one name per struct + [1] field_counts (U32Array): numberStructureElements — how many fields each struct has + [2] field_type_ids (U8Array): flat field type IDs across all structs + [3] field_names (StrArray): flat field names across all structs + + Struct IDs are positional (0-indexed); the device does not send them explicitly. + field_counts drives the field-to-struct assignment (no even-split heuristic). + Args: address: Object address interface_id: Interface ID @@ -687,146 +1806,233 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI 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. + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetStructsCommand returned None") + + struct_names = list(response.struct_names) + # field_counts = numberStructureElements from the device: logical fields per struct. + # Struct IDs are positional (0-indexed); the device does not send them. + field_counts = [int(c) for c in response.field_counts] + type_bytes = list(response.field_type_ids) # flat byte array; entries are 1, 3, or 7 bytes wide + field_names = list(response.field_names) + n_structs = len(field_counts) + if n_structs == 0: + return [] + + # Walk type_bytes with a byte-level cursor. Width varies: 1=simple, 3=ref (source_id 1–3), + # 7=node-global (source_id=4). field_counts gives logical field count per struct, + # not bytes — _parse_struct_field_types tracks exact byte consumption via _byte_width. + byte_offset = 0 # cursor into type_bytes + name_offset = 0 # cursor into field_names + result: List[StructInfo] = [] + for i, cnt in enumerate(field_counts): + name = struct_names[i] if i < len(struct_names) else f"Struct_{i}" + parsed = _parse_struct_field_types(type_bytes[byte_offset:]) + # Consume exactly `cnt` logical entries; advance byte_offset by the bytes used. + type_entries = parsed[:cnt] + bytes_used = sum(pt._byte_width for pt in type_entries) + names_slice = field_names[name_offset : name_offset + cnt] + fields = dict(zip(names_slice, type_entries)) + result.append(StructInfo(struct_id=i, name=name, fields=fields, interface_id=interface_id)) + byte_offset += bytes_used + name_offset += cnt + return result + + async def build_type_registry( + self, + address: Union[Address, str], + global_pool: Optional[GlobalTypePool] = None, + *, + _supported: Optional[Set[int]] = None, + ) -> TypeRegistry: + """Build a complete TypeRegistry for an object. + + Uses InterfaceDescriptors (get_interfaces) as the canonical source of + interface IDs; then queries structs and enums only for those interfaces. + Only calls Interface 0 methods that the object supports; skips unsupported + commands and builds a partial registry. Args: - root_address: Root object address + address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. If omitted, + uses :meth:`ensure_global_type_pool` (same as :meth:`_build_minimal_registry_for_signature`). + _supported: Pre-computed supported Interface 0 method IDs (internal; + avoids redundant device queries when the caller already has them). Returns: - Nested dictionary of discovered objects + TypeRegistry with all type information for this object """ - 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}") + address = await self._resolve_target_address(address) + if global_pool is None: + global_pool = await self.ensure_global_type_pool() + registry = TypeRegistry(address=address, global_pool=global_pool) + if _supported is None: + _supported = await self.get_supported_interface0_method_ids(address) + + if GET_INTERFACES in _supported: + interfaces = await self.get_interfaces(address, _supported=_supported) + for iface in interfaces: + registry.interfaces[iface.interface_id] = iface + else: + interfaces = [] - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["subobjects"] = subobjects # type: ignore[assignment] + if GET_METHOD in _supported: + registry.methods = await self.ensure_method_table(address) + else: + registry.methods = [] - # 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] + for iface in interfaces: + if GET_STRUCTS in _supported or GET_ENUMS in _supported: + await self.ensure_structs_enums(address, iface.interface_id) + self._attach_iface_types_to_registry(registry, address, iface.interface_id) - 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 registry - return hierarchy + async def build_type_registry_with_children( + self, + address: Union[Address, str], + subobject_addresses: Optional[List[Address]] = None, + global_pool: Optional[GlobalTypePool] = None, + ) -> TypeRegistry: + """Build a TypeRegistry that includes structs/enums from child objects. - async def discover_all_objects(self, root_addresses: List[Address]) -> Dict[str, Any]: - """Discover all objects starting from root addresses. + Complex type references (e.g. type_57 = PickupTipParameters) may be + defined on a child object's interface rather than the parent. This method + builds the parent's registry, then merges in types from each child so + that MethodParamType.resolve_name() can find them. Args: - root_addresses: List of root addresses to start discovery from + address: Parent object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + subobject_addresses: Optional list of child addresses to include. + If None, all direct subobjects are discovered automatically. + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. If omitted, + :meth:`build_type_registry` attaches the session pool automatically. Returns: - Dictionary mapping address strings to discovered hierarchies + TypeRegistry that can resolve types from both parent and children. """ - all_objects = {} + address = await self._resolve_target_address(address) + supported = await self.get_supported_interface0_method_ids(address) + registry = await self.build_type_registry( + address, global_pool=global_pool, _supported=supported + ) - for root_address in root_addresses: + if subobject_addresses is None: + if GET_SUBOBJECT_ADDRESS not in supported: + subobject_addresses = [] + else: + obj_info = await self.get_object(address) + subobject_addresses = [] + for i in range(obj_info.subobject_count): + try: + sub_addr = await self.get_subobject_address(address, i) + subobject_addresses.append(sub_addr) + except _TRANSIENT_ERRORS: + raise + except Exception: + logger.debug("get_subobject_address(%d) failed for %s", i, address) + + for sub_addr in subobject_addresses: try: - hierarchy = await self.discover_hierarchy(root_address) - all_objects[str(root_address)] = hierarchy + child_reg = await self.build_type_registry(sub_addr) + for iid, struct_map in child_reg.structs.items(): + registry.structs.setdefault(iid, {}).update(struct_map) + for iid, enum_map in child_reg.enums.items(): + registry.enums.setdefault(iid, {}).update(enum_map) + except _TRANSIENT_ERRORS: + raise 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 + logger.debug("build_type_registry failed for child %s: %s", sub_addr, e) - def print_method_signatures(self, methods: List[MethodInfo]) -> None: - """Print method signatures in a readable format. + return registry - 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() + async def build_global_type_pool( + self, + global_addresses: List[Address], + ) -> GlobalTypePool: + """Build a fresh global type pool from *global_addresses* (full walk; not the session singleton). - def print_struct_definitions(self, structs: List[StructInfo]) -> None: - """Print struct definitions in a readable format. + Mirrors piglet: walk each global object, iterate interfaces, collect structs/enums in + encounter order for ``source_id=1`` lookups. For lazy signature resolution on a live + session, use :meth:`ensure_global_type_pool` so the pool is built once and reused. Args: - structs: List of StructInfo objects to print + global_addresses: List of global object addresses + (from :attr:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient.global_object_addresses`). + + Returns: + GlobalTypePool with all global structs and enums. """ - print("Struct Definitions:") - print("=" * 50) - for struct in structs: - print(struct.get_struct_string()) - print() + return await self._build_global_type_pool_impl(global_addresses) - def get_methods_by_name(self, methods: List[MethodInfo], name_pattern: str) -> List[MethodInfo]: - """Filter methods by name pattern. + async def get_method_by_id( + self, + address: Union[Address, str], + interface_id: int, + method_id: int, + registry: Optional[TypeRegistry] = None, + ) -> Optional[MethodInfo]: + """Return the method with the given interface_id and method_id (action id). + + When a TypeRegistry is provided and contains the method, returns it + without any device round-trips. Falls back to a full device scan only + when no registry is available or the method isn't in it. Args: - methods: List of MethodInfo objects to filter - name_pattern: Name pattern to search for (case-insensitive) + address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + interface_id: Interface ID (e.g. 1 for IChannel/IMph). + method_id: Method/command ID (e.g. 9 for PickupTips). + registry: Optional TypeRegistry with cached methods. Returns: - List of methods matching the name pattern + MethodInfo for the matching method, or None if not found. """ - 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 + if registry is not None: + cached = registry.get_method(interface_id, method_id) + if cached is not None: + return cached + address = await self._resolve_target_address(address) + methods = await self.ensure_method_table(address) + for m in methods: + if m.interface_id == interface_id and m.method_id == method_id: + return m + return None + + async def resolve_signature( + self, + address: Union[Address, str], + interface_id: int, + method_id: int, + registry: Optional[TypeRegistry] = None, + ) -> str: + """Return a fully resolved method signature string. + + When *registry* is omitted, loads only the + method table, global pool, and structs/enums needed for this signature (no full + :meth:`build_type_registry`). Pass an explicit *registry* for export/golden parity. + + Example:: + + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9) + print(sig) + # PickupTips(tipParameters: PickupTipParameters, finalZ: f32, ...) -> ... Returns: - List of methods from the specified interface + Human-readable signature string, or a descriptive error string. """ - return [method for method in methods if method.interface_id == interface_id] + address = await self._resolve_target_address(address) + if registry is not None: + method = await self.get_method_by_id(address, interface_id, method_id, registry=registry) + if method is None: + return f"" + return method.get_signature_string(registry) + methods = await self.ensure_method_table(address) + method = next( + (m for m in methods if m.interface_id == interface_id and m.method_id == method_id), + None, + ) + if method is None: + return f"" + reg = await self._build_minimal_registry_for_signature(address, method) + return method.get_signature_string(reg) diff --git a/pylabrobot/hamilton/tcp/messages.py b/pylabrobot/hamilton/tcp/messages.py index 75fe0fb974a..dea7d4c08e4 100644 --- a/pylabrobot/hamilton/tcp/messages.py +++ b/pylabrobot/hamilton/tcp/messages.py @@ -1,43 +1,28 @@ -"""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 +"""Framing and protocol message layer for Hamilton TCP. + +HoiParams is a fragment accumulator with add(value, wire_type) and +from_struct(obj); it has no type-specific encoding logic and delegates all +encoding to WireType.encode_into in wire_types. HoiParamsParser is a thin +cursor over sequential DataFragments; it reads [type_id:1][flags:1][length:2] +[data:N] headers and delegates value decoding to wire_types.decode_fragment(). +parse_into_struct() is the dataclass codec that uses WireType annotations to +decode fragment sequences into typed instances. + +Also: message builders (CommandMessage, InitMessage, RegistrationMessage) and +response parsers (CommandResponse, InitResponse, RegistrationResponse). + +STATUS/COMMAND exception param parsing and :class:`~pylabrobot.hamilton.tcp.hoi_error.HoiError` +live in :mod:`pylabrobot.hamilton.tcp.hoi_error`. """ from __future__ import annotations +import logging from dataclasses import dataclass -from typing import Any +from dataclasses import fields as dc_fields +from typing import Any, List, cast, get_args, get_origin, get_type_hints -from pylabrobot.io.binary import Reader, Writer +from pylabrobot.hamilton.tcp.hoi_error import parse_hc_results_from_semicolon_string from pylabrobot.hamilton.tcp.packets import ( Address, HarpPacket, @@ -46,10 +31,19 @@ RegistrationPacket, ) from pylabrobot.hamilton.tcp.protocol import ( - HamiltonDataType, HarpTransportableProtocol, + Hoi2Action, RegistrationOptionType, ) +from pylabrobot.hamilton.tcp.wire_types import ( + HcResultEntry, + decode_fragment, +) +from pylabrobot.io.binary import Reader, Writer + +PADDED_FLAG = 0x01 + +logger = logging.getLogger(__name__) # ============================================================================ # HOI PARAMETER ENCODING - DataFragment wrapping for HOI protocol @@ -75,9 +69,9 @@ class HoiParams: [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]) + .add(100, I32) + .add("test", Str) + .add([1, 2, 3], U32Array) .build()) """ @@ -89,200 +83,122 @@ def _add_fragment(self, type_id: int, data: bytes, flags: int = 0) -> "HoiParams Creates: [type_id:1][flags:1][length:2][data:n] + When flags & PADDED_FLAG, appends a trailing pad byte (Prep convention). + Callers pass unpadded data; _add_fragment centralizes pad handling. + Args: type_id: Data type ID - data: Fragment data bytes - flags: Fragment flags (default: 0, but BOOL_ARRAY uses 0x01) + data: Fragment data bytes (unpadded; pad added here when flags set) + flags: Fragment flags (default: 0; PADDED_FLAG for BoolArray, PaddedBool, PaddedU8) """ + if flags & PADDED_FLAG: + data = data + b"\x00" 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 add(self, value: Any, wire_type: Any) -> "HoiParams": + """Encode a value using its WireType and append the DataFragment. - 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) + wire_type may be a WireType instance or an Annotated alias (e.g. I32, Str). + """ + if hasattr(wire_type, "__metadata__"): + wire_type = wire_type.__metadata__[0] + return cast("HoiParams", wire_type.encode_into(value, self)) - def u8(self, value: int) -> "HoiParams": - """Add unsigned 8-bit integer parameter.""" - data = Writer().u8(value).finish() - return self._add_fragment(HamiltonDataType.U8, data) + # ------------------------------------------------------------------ + # Ergonomic shims — each delegates to add() with the matching alias + # from wire_types. No encoding logic lives here. + # ------------------------------------------------------------------ - 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 i8(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import I8 - def u32(self, value: int) -> "HoiParams": - """Add unsigned 32-bit integer parameter.""" - data = Writer().u32(value).finish() - return self._add_fragment(HamiltonDataType.U32, data) + return self.add(value, I8) - def u64(self, value: int) -> "HoiParams": - """Add unsigned 64-bit integer parameter.""" - data = Writer().u64(value).finish() - return self._add_fragment(HamiltonDataType.U64, data) + def i16(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import I16 - # 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) + return self.add(value, I16) - 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. + def i32(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import I32 - 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()) + return self.add(value, I32) - def i32_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 32-bit integers. + def i64(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import I64 - 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()) + return self.add(value, I64) - def i64_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 64-bit integers. + def u8(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import U8 - 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()) + return self.add(value, U8) - def u8_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 8-bit integers. + def u16(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import U16 - 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()) + return self.add(value, U16) - def u16_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 16-bit integers. + def u32(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import U32 - 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()) + return self.add(value, U32) - def u32_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 32-bit integers. + def u64(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import U64 - 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()) + return self.add(value, U64) - def u64_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 64-bit integers. + def f32(self, value: float) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import F32 - 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()) + return self.add(value, F32) - def f32_array(self, values: list[float]) -> "HoiParams": - """Add array of 32-bit floats. + def f64(self, value: float) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import F64 - 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()) + return self.add(value, F64) - def f64_array(self, values: list[float]) -> "HoiParams": - """Add array of 64-bit doubles. + def bool_(self, value: bool) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import Bool - 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()) + return self.add(value, Bool) - def bool_array(self, values: list[bool]) -> "HoiParams": - """Add array of booleans (stored as u8: 0 or 1). + def str_(self, value: str) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import Str - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + return self.add(value, Str) - 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) + # ------------------------------------------------------------------ + # Generic dataclass serialiser (wire_types.py Annotated metadata) + # ------------------------------------------------------------------ - def string_array(self, values: list[str]) -> "HoiParams": - """Add array of null-terminated strings. + @classmethod + def from_struct(cls, obj) -> "HoiParams": + """Serialize any dataclass whose fields use ``Annotated`` wire-type metadata. - Format: [count:4][str0\0][str1\0]... + Fields without ``Annotated`` metadata (e.g. plain ``Address``) are skipped. + The polymorphic ``WireType.encode_into`` on each annotation handles all + dispatch -- no if/elif required here. """ - writer = Writer().u32(len(values)) - for val in values: - writer.string(val) - return self._add_fragment(HamiltonDataType.STRING_ARRAY, writer.finish()) + from dataclasses import fields as dc_fields + from typing import get_type_hints + + from pylabrobot.hamilton.tcp.wire_types import WireType + + hints = get_type_hints(type(obj), include_extras=True) + params = cls() + for f in dc_fields(obj): + ann = hints.get(f.name) + if ann is None or not hasattr(ann, "__metadata__"): + continue + meta = ann.__metadata__[0] + if not isinstance(meta, WireType): + continue + params = meta.encode_into(getattr(obj, f.name), params) + return cast("HoiParams", params) def build(self) -> bytes: """Return concatenated DataFragments.""" @@ -294,162 +210,301 @@ def count(self) -> int: class HoiParamsParser: - """Parser for HOI DataFragment parameters. + """Cursor over sequential DataFragments in an HOI payload. - Parses DataFragment-wrapped values from HOI response payloads. + Reads [type_id:1][flags:1][length:2][data:N] headers and delegates + value decoding to the unified codec in wire_types.decode_fragment(). """ def __init__(self, data: bytes): + if not isinstance(data, bytes): + raise TypeError( + f"HoiParamsParser requires bytes, got {type(data).__name__}. " + "Use get_structs_raw() and inspect_hoi_params() to see the wire format." + ) 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 at offset {self._offset}") + type_id = self._data[self._offset] + flags = self._data[self._offset + 1] + length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") + payload_end = self._offset + 4 + length + if payload_end > len(self._data): + raise ValueError( + f"DataFragment data extends beyond buffer: need {payload_end}, have {len(self._data)}" + ) + data = self._data[self._offset + 4 : payload_end] + self._offset = payload_end + if (flags & PADDED_FLAG) and len(data) > 0: + data = data[:-1] + return type_id, decode_fragment(type_id, data) + + def parse_next_raw(self) -> tuple[int, int, int, bytes]: + """Return (type_id, flags, length, payload_bytes) without decoding. + + Use when the wire declares STRING (type_id=15) but the payload is binary + (e.g. GetMethod parameter_types). Normal parse_next() would UTF-8 decode + and fail on bytes like 0xaa. """ 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"Insufficient data at offset {self._offset}") + type_id = self._data[self._offset] + flags = self._data[self._offset + 1] + length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") + payload_end = self._offset + 4 + length + if payload_end > len(self._data): raise ValueError( - f"DataFragment data extends beyond buffer: need {data_end}, have {len(self._data)}" + f"DataFragment data extends beyond buffer: need {payload_end}, have {len(self._data)}" ) - - # Extract data payload - fragment_data = self._data[data_start:data_end] - value = self._parse_value(type_id, fragment_data) - - # Move offset past this fragment - self._offset = data_end - - return (type_id, value) - - def _parse_value(self, type_id: int, data: bytes) -> Any: - """Parse value based on type_id using dispatch table.""" - reader = Reader(data) - - # Dispatch table for scalar types - scalar_parsers = { - HamiltonDataType.I8: reader.i8, - HamiltonDataType.I16: reader.i16, - HamiltonDataType.I32: reader.i32, - HamiltonDataType.I64: reader.i64, - HamiltonDataType.U8: reader.u8, - HamiltonDataType.U16: reader.u16, - HamiltonDataType.U32: reader.u32, - HamiltonDataType.U64: reader.u64, - HamiltonDataType.F32: reader.f32, - HamiltonDataType.F64: reader.f64, - HamiltonDataType.STRING: reader.string, - } - - # Check scalar types first - # Cast int to HamiltonDataType enum for dict lookup - try: - data_type = HamiltonDataType(type_id) - if data_type in scalar_parsers: - return scalar_parsers[data_type]() - except ValueError: - pass # Not a valid enum value, continue to other checks - - # Special case: bool - if type_id == HamiltonDataType.BOOL: - return reader.u8() == 1 - - # Dispatch table for array element parsers - array_element_parsers = { - HamiltonDataType.I8_ARRAY: reader.i8, - HamiltonDataType.I16_ARRAY: reader.i16, - HamiltonDataType.I32_ARRAY: reader.i32, - HamiltonDataType.I64_ARRAY: reader.i64, - HamiltonDataType.U8_ARRAY: reader.u8, - HamiltonDataType.U16_ARRAY: reader.u16, - HamiltonDataType.U32_ARRAY: reader.u32, - HamiltonDataType.U64_ARRAY: reader.u64, - HamiltonDataType.F32_ARRAY: reader.f32, - HamiltonDataType.F64_ARRAY: reader.f64, - HamiltonDataType.STRING_ARRAY: reader.string, - } - - # Handle arrays - # Arrays don't have a count prefix - count is derived from DataFragment length - # Calculate element size based on type - element_sizes = { - HamiltonDataType.I8_ARRAY: 1, - HamiltonDataType.I16_ARRAY: 2, - HamiltonDataType.I32_ARRAY: 4, - HamiltonDataType.I64_ARRAY: 8, - HamiltonDataType.U8_ARRAY: 1, - HamiltonDataType.U16_ARRAY: 2, - HamiltonDataType.U32_ARRAY: 4, - HamiltonDataType.U64_ARRAY: 8, - HamiltonDataType.F32_ARRAY: 4, - HamiltonDataType.F64_ARRAY: 8, - HamiltonDataType.STRING_ARRAY: None, # Variable length, handled separately - } - - # Cast int to HamiltonDataType enum for dict lookup - try: - data_type = HamiltonDataType(type_id) - if data_type in array_element_parsers: - element_size = element_sizes.get(data_type) - if element_size is not None: - # Fixed-size elements: calculate count from data length - count = len(data) // element_size - return [array_element_parsers[data_type]() for _ in range(count)] - elif data_type == HamiltonDataType.STRING_ARRAY: - # String arrays: [count:4][str0\0][str1\0]... - count = Reader(data[:4]).u32() - strings = [] - offset = 4 - for _ in range(count): - end = data.index(0, offset) - strings.append(data[offset:end].decode("utf-8", errors="replace")) - offset = end + 1 - return strings - except ValueError: - # Not a valid enum value, continue to other checks - # This shouldn't happen for valid Hamilton types, but we continue anyway - pass - - # Special case: bool array (1 byte per element) - if type_id == HamiltonDataType.BOOL_ARRAY: - count = len(data) // 1 # Each bool is 1 byte - return [reader.u8() == 1 for _ in range(count)] - - # Unknown type - raise ValueError(f"Unknown or unsupported type_id: {type_id}") + payload = self._data[self._offset + 4 : payload_end] + self._offset = payload_end + return type_id, flags, length, payload 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. + def remaining(self) -> bytes: + """Unconsumed payload bytes (from current cursor to end).""" + return self._data[self._offset :] - Returns: - List of (type_id, value) tuples - """ + def skip_next(self) -> None: + """Advance past one DataFragment without decoding the payload.""" + if self._offset + 4 > len(self._data): + raise ValueError(f"Insufficient data at offset {self._offset}") + length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") + payload_end = self._offset + 4 + length + if payload_end > len(self._data): + raise ValueError( + f"DataFragment data extends beyond buffer: need {payload_end}, have {len(self._data)}" + ) + self._offset = payload_end + + def parse_all(self) -> list[tuple[int, Any]]: results = [] while self.has_remaining(): results.append(self.parse_next()) return results +def inspect_hoi_params(params: bytes) -> List[dict]: + """Inspect raw HOI params bytes fragment-by-fragment for debugging. + + Walks the DataFragment stream [type_id:1][flags:1][length:2][data:N] and + returns a list of dicts with: type_id, flags, length, payload_hex (first 80 + chars), payload_len, decoded (decode_fragment result or exception message). + Use this to see exactly what the device sends and fix response parsing. + + Example: + raw, fragments = await intro.get_structs_raw(mph_addr, 1) + for i, f in enumerate(fragments): + print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") + """ + if not params: + return [] + out: List[dict] = [] + offset = 0 + while offset + 4 <= len(params): + type_id = params[offset] + flags = params[offset + 1] + length = int.from_bytes(params[offset + 2 : offset + 4], "little") + payload_end = offset + 4 + length + if payload_end > len(params): + out.append( + { + "type_id": type_id, + "flags": flags, + "length": length, + "payload_hex": "", + "payload_len": 0, + "decoded": f"", + } + ) + break + data = params[offset + 4 : payload_end] + hex_preview = data.hex() if len(data) <= 40 else data[:40].hex() + "..." + try: + decoded = decode_fragment(type_id, data) + if isinstance(decoded, bytes): + decoded = ( + decoded.decode("utf-8", errors="replace").rstrip("\x00") or f"" + ) + decoded_repr = ( + repr(decoded) if not isinstance(decoded, (str, int, float, bool)) else str(decoded) + ) + if isinstance(decoded, list): + decoded_repr = ( + f"list[len={len(decoded)}](elem0_type={type(decoded[0]).__name__ if decoded else 'n/a'})" + ) + except Exception as e: + decoded_repr = f"" + out.append( + { + "type_id": type_id, + "flags": flags, + "length": length, + "payload_hex": hex_preview, + "payload_len": len(data), + "decoded": decoded_repr, + } + ) + offset = payload_end + return out + + +def hoi_action_code_base(action_byte: int) -> int: + """Lower 4 bits of HOI action field (response-required bit is 0x10).""" + return action_byte & 0x0F + + +def split_hoi_params_after_warning_prefix( + action_code: int, params: bytes +) -> tuple[bytes, list[HcResultEntry]]: + """If action is StatusWarning/CommandWarning, drop the first two fragments and parse the string aggregate. + + Mirrors ``SystemController.SendAndReceive``: out-parameters start at fragment index 2; fragment 1 holds + the formatted warning list consumed by ``HoiResult(HoiPacket2)`` / ``GetHcResults``. + """ + if not params: + return params, [] + base = hoi_action_code_base(action_code) + if base not in (Hoi2Action.STATUS_WARNING, Hoi2Action.COMMAND_WARNING): + return params, [] + + parser = HoiParamsParser(params) + if not parser.has_remaining(): + return params, [] + try: + _tid0, _v0 = parser.parse_next() + if not parser.has_remaining(): + return params, [] + _tid1, v1 = parser.parse_next() + except ValueError: + return params, [] + + rest = parser.remaining() + prefix_entries: list[HcResultEntry] = [] + if isinstance(v1, str): + prefix_entries = parse_hc_results_from_semicolon_string(v1) + elif isinstance(v1, (bytes, bytearray)): + prefix_entries = parse_hc_results_from_semicolon_string( + bytes(v1).decode("utf-8", errors="replace") + ) + return rest, prefix_entries + + +def log_hoi_result_entries(command_name: str, entries: list[HcResultEntry], *, source: str) -> None: + """Log non-success ``HcResultEntry`` rows (0x0000 skipped).""" + for entry in entries: + if entry.result == 0: + continue + logger.warning( + "%s %s channel result at %d:%d:%d iface=%d action=%d: 0x%04X (%s)", + command_name, + source, + entry.module_id, + entry.node_id, + entry.object_id, + entry.interface_id, + entry.action_id, + entry.result, + "warning" if entry.is_warning else "error", + ) + + +def interpret_hoi_success_payload(command: Any, params_bytes: bytes) -> Any: + """Decode command ``Response`` from HOI params. + + Used for CommandResponse / StatusResponse payloads after exception and + warning-prefix handling. Success frames carry only the fields declared in + the Response dataclass — no HoiResult trailer (see firmware yaml dumps and + protocol decoder behavior; HoiResult only rides on warning-prefix or exception + frames). + """ + cls = type(command) + if not params_bytes: + return None + + if hasattr(cls, "Response"): + return parse_into_struct(HoiParamsParser(params_bytes), cls.Response) + + return command.parse_response_parameters(params_bytes) + + +def parse_into_struct(parser: HoiParamsParser, cls: type) -> Any: + """Decode a sequence of DataFragments into a dataclass instance using its wire-type annotations. + + Mirrors HoiParams.from_struct: walks the same Annotated field metadata and, for each field in + order, consumes one fragment (via parser.parse_next()). Scalars/arrays/string yield the value + as returned by the parser; Struct recurses on the payload bytes; StructArray yields a list of + recursively decoded instances. + + Args: + parser: Parser positioned at the start of the fragment sequence (e.g. response payload). + cls: Dataclass type whose fields are annotated with wire_types (F32, Struct(), etc.). + + Returns: + An instance of cls with fields populated from the parsed fragments. + + Raises: + ValueError: If data is malformed or insufficient. + """ + from pylabrobot.hamilton.tcp.wire_types import ( + CountedFlatArray, + Struct, + StructArray, + WireType, + ) + + hints = get_type_hints(cls, include_extras=True) + values: dict[str, Any] = {} + for f in dc_fields(cls): + ann = hints.get(f.name) + if ann is None or not hasattr(ann, "__metadata__"): + continue + meta = ann.__metadata__[0] + if not isinstance(meta, WireType): + continue + + if isinstance(meta, CountedFlatArray): + _, raw = parser.parse_next() + element_type = get_args(get_args(ann)[0])[0] + if isinstance(raw, list): + # Single fragment was STRUCTURE_ARRAY: list of payload bytes per element + if raw and not isinstance(raw[0], bytes): + raise ValueError( + f"CountedFlatArray decoded to list of {type(raw[0]).__name__}, expected " + "list of bytes (STRUCTURE_ARRAY). Use get_structs_raw() and " + "inspect_hoi_params() to see the exact wire format." + ) + values[f.name] = [parse_into_struct(HoiParamsParser(p), element_type) for p in raw] + else: + # Count then N flat fragments (count-prefixed stream) + count = int(raw) + values[f.name] = [parse_into_struct(parser, element_type) for _ in range(count)] + continue + + type_id, value = parser.parse_next() + + if isinstance(meta, Struct): + inner_type = get_args(ann)[0] + value = parse_into_struct(HoiParamsParser(value), inner_type) + elif isinstance(meta, StructArray): + inner_ann = get_args(ann)[0] + if get_origin(inner_ann) is list: + element_type = get_args(inner_ann)[0] + else: + element_type = inner_ann + value = [parse_into_struct(HoiParamsParser(p), element_type) for p in value] + # else: decode_fragment() already returned correctly-typed value + + values[f.name] = value + + return cls(**values) + + # ============================================================================ # MESSAGE BUILDERS # ============================================================================ @@ -681,10 +736,10 @@ def build(self) -> bytes: Returns: Complete packet bytes ready to send over TCP """ - # Build raw connection parameters (NOT DataFragments) + # Build raw Protocol-7 connection blob (NOT DataFragments — distinct from HoiParams). # Frame: [version:1][message_id:1][count:1][unknown:1] # Parameters: [id:1][type:1][reserved:2][value:2] repeated - params = ( + connection_blob = ( Writer() # Frame .u8(0) # version @@ -710,7 +765,7 @@ def build(self) -> bytes: ) # Build IP packet - packet_size = 1 + 1 + 2 + len(params) # protocol + version + opts_len + params + packet_size = 1 + 1 + 2 + len(connection_blob) # protocol + version + opts_len + blob return ( Writer() @@ -718,7 +773,7 @@ def build(self) -> bytes: .u8(self.ip_protocol) .u8(self.protocol_version) .u16(0) # options_length - .raw_bytes(params) + .raw_bytes(connection_blob) .finish() ) diff --git a/pylabrobot/hamilton/tcp/packets.py b/pylabrobot/hamilton/tcp/packets.py index 42d308f1c48..fb301cfbef6 100644 --- a/pylabrobot/hamilton/tcp/packets.py +++ b/pylabrobot/hamilton/tcp/packets.py @@ -12,14 +12,11 @@ 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 @@ -40,14 +37,14 @@ def encode_version_byte(major: int, minor: int) -> int: return version_byte -def decode_version_byte(version_byte: int) -> tuple[int, int]: +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_byte & 0xF - major = (version_byte >> 4) & 0xF + minor = version_bite & 0xF + major = (version_bite >> 4) & 0xF return (major, minor) @@ -116,13 +113,8 @@ def unpack(cls, data: bytes) -> "IpPacket": # Validate version if major != HAMILTON_PROTOCOL_VERSION_MAJOR or minor != HAMILTON_PROTOCOL_VERSION_MINOR: - logger.warning( - "Hamilton protocol version mismatch: expected %d.%d, got %d.%d", - HAMILTON_PROTOCOL_VERSION_MAJOR, - HAMILTON_PROTOCOL_VERSION_MINOR, - major, - minor, - ) + # Warning but not fatal + pass opts_len = r.u16() options = r.raw_bytes(opts_len) if opts_len > 0 else b"" diff --git a/pylabrobot/hamilton/tcp/protocol.py b/pylabrobot/hamilton/tcp/protocol.py index 9e916e91db3..60b89b61dee 100644 --- a/pylabrobot/hamilton/tcp/protocol.py +++ b/pylabrobot/hamilton/tcp/protocol.py @@ -1,7 +1,8 @@ -"""Hamilton TCP protocol constants and enumerations. +"""Transport-level protocol constants only. -This module contains all protocol-level constants, enumerations, and type definitions -used throughout the Hamilton TCP communication stack. +HamiltonProtocol, Hoi2Action, HarpTransportableProtocol, RegistrationActionCode, +RegistrationOptionType, HoiRequestId. DataFragment type IDs (I8, I32, STRUCTURE, +etc.) are defined in wire_types.HamiltonDataType. """ from __future__ import annotations @@ -124,49 +125,6 @@ class RegistrationOptionType(IntEnum): 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). diff --git a/pylabrobot/hamilton/tcp/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py new file mode 100644 index 00000000000..38cb0b7a628 --- /dev/null +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -0,0 +1,949 @@ +"""Curated tests for Hamilton TCP protocol implementation. + +Focused on high-value invariants: +- packet/frame wire shape and round-trip parsing +- DataFragment encode/decode and parser behavior +- warning/exception payload semantics +- command response auto-decode contract +""" + +from __future__ import annotations + +import asyncio +import struct +import unittest +from dataclasses import dataclass +from typing import Annotated, cast +from unittest.mock import AsyncMock + +import pylabrobot.hamilton.tcp.introspection as introspection_mod +from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError +from pylabrobot.hamilton.tcp.client import HamiltonTCPClient +from pylabrobot.hamilton.tcp.commands import TCPCommand +from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES +from pylabrobot.hamilton.tcp.hoi_error import ( + HoiError, + parse_hamilton_error_entries, + parse_hamilton_error_entry, +) +from pylabrobot.hamilton.tcp.introspection import ( + EnumInfo, + FirmwareTreeNode, + GlobalTypePool, + HamiltonIntrospection, + InterfaceInfo, + MethodInfo, + ObjectInfo, + ObjectRegistry, + StructInfo, + TypeRegistry, + flatten_firmware_tree, +) +from pylabrobot.hamilton.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParams, + HoiParamsParser, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, + parse_into_struct, + split_hoi_params_after_warning_prefix, +) +from pylabrobot.hamilton.tcp.packets import ( + Address, + HarpPacket, + HoiPacket, + IpPacket, + RegistrationPacket, + decode_version_byte, + encode_version_byte, +) +from pylabrobot.hamilton.tcp.protocol import ( + HamiltonProtocol, + Hoi2Action, + RegistrationActionCode, + RegistrationOptionType, +) +from pylabrobot.hamilton.tcp.wire_types import ( + I32, + I64, + U16, + Bool, + BoolArray, + CountedFlatArray, + HamiltonDataType, + HcResultEntry, + Str, + StrArray, + decode_fragment, +) + + +@dataclass +class _EnumValueWire: + name: Str + value: I64 + + +@dataclass +class _EnumWire: + enum_id: I64 + name: Str + values: Annotated[list[_EnumValueWire], CountedFlatArray()] + + +@dataclass +class _GetEnumsResponse: + enums: Annotated[list[_EnumWire], CountedFlatArray()] + + +class TestVersionByte(unittest.TestCase): + def test_encode_decode_roundtrip(self): + for major in range(16): + for minor in range(16): + encoded = encode_version_byte(major, minor) + got_major, got_minor = decode_version_byte(encoded) + self.assertEqual((got_major, got_minor), (major, minor)) + + def test_encode_version_byte_invalid(self): + with self.assertRaises(ValueError): + encode_version_byte(16, 0) + with self.assertRaises(ValueError): + encode_version_byte(0, 16) + + +class TestPacketWireShape(unittest.TestCase): + def test_ip_packet_roundtrip(self): + original = IpPacket(protocol=6, payload=b"\xaa\xbb", options=b"\x10\x20") + packed = original.pack() + unpacked = IpPacket.unpack(packed) + self.assertEqual(unpacked.protocol, 6) + self.assertEqual(unpacked.options, b"\x10\x20") + self.assertEqual(unpacked.payload, b"\xaa\xbb") + + def test_harp_action_bit_and_roundtrip(self): + original = HarpPacket( + src=Address(2, 1, 65535), + dst=Address(1, 1, 257), + seq=7, + protocol=2, + action_code=3, + payload=b"\x01", + response_required=True, + ) + self.assertEqual(original.action, 0x13) + unpacked = HarpPacket.unpack(original.pack()) + self.assertEqual(unpacked.action_code, 3) + self.assertTrue(unpacked.response_required) + + def test_hoi_fragment_count_reflects_fragmented_params(self): + frag1 = b"\x03\x00\x04\x00" + b"\x01\x02\x03\x04" + frag2 = b"\x04\x00\x01\x00" + b"\x05" + packet = HoiPacket(interface_id=1, action_code=3, action_id=9, params=frag1 + frag2) + packed = packet.pack() + self.assertEqual(packed[5], 2) + + def test_registration_packet_roundtrip(self): + original = RegistrationPacket( + action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST, + response_code=0, + req_address=Address(2, 5, 65535), + res_address=Address(0, 0, 0), + options=b"\x05\x02\x02\x01", + ) + unpacked = RegistrationPacket.unpack(original.pack()) + self.assertEqual(unpacked.action_code, original.action_code) + self.assertEqual(unpacked.req_address, original.req_address) + self.assertEqual(unpacked.options, original.options) + + +class TestHoiParamsAndParser(unittest.TestCase): + def test_bool_array_wire_shape_keeps_padding_semantics(self): + params = HoiParams().add([True, False, True], BoolArray).build() + self.assertEqual(params[0], HamiltonDataType.BOOL_ARRAY) + self.assertEqual(params[1], 0x01) # padded flag required by protocol + self.assertEqual(params[2:4], b"\x04\x00") + self.assertEqual(params[4:], b"\x01\x00\x01\x00") + + def test_string_array_wire_shape(self): + params = HoiParams().add(["a", "bc"], StrArray).build() + self.assertEqual(params[0], HamiltonDataType.STRING_ARRAY) + self.assertEqual(params[2:4], b"\x05\x00") + self.assertEqual(params[4:], b"a\x00bc\x00") + + def test_parser_roundtrip_mixed_payload(self): + payload = HoiParams().add(42, I32).add("ok", Str).add(True, Bool).build() + parser = HoiParamsParser(payload) + values = [parser.parse_next()[1], parser.parse_next()[1], parser.parse_next()[1]] + self.assertEqual(values, [42, "ok", True]) + self.assertFalse(parser.has_remaining()) + + def test_decode_fragment_structure_array(self): + p1 = b"a" + p2 = b"bc" + inner = ( + bytes([HamiltonDataType.STRUCTURE, 0]) + + struct.pack(" Address: + self.assertEqual(path, "Root.Child") + return Address(1, 1, 999) + + client.resolve_path = _fake_resolve_path # type: ignore[method-assign] + got = asyncio.run( + client.resolve_target("pipettor_service", aliases={"pipettor_service": "Root.Child"}) + ) + self.assertEqual(got, Address(1, 1, 999)) + + def test_send_query_returns_hoi_payload_tuple(self): + class Cmd(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 1 + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + payload = HoiParams().add(123, I32).build() + hoi = HoiPacket( + interface_id=0, action_code=Hoi2Action.COMMAND_RESPONSE, action_id=1, params=payload + ) + harp = HarpPacket( + src=Address(1, 1, 257), + dst=Address(2, 1, 65535), + seq=1, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + raw = asyncio.run(client.send_query(Cmd(Address(1, 1, 257)))) + assert raw is not None + self.assertIsInstance(raw, tuple) + self.assertEqual(raw[0], HoiParams().add(123, I32).build()) + + def test_get_firmware_tree_uses_cache_and_refresh(self): + registry = ObjectRegistry() + registry.set_root_address(Address(1, 1, 100)) + + async def _unused(*a, **k): + raise RuntimeError("unused in this test") + + intro = HamiltonIntrospection( + registry=registry, + global_object_addresses=[], + send_discovery_command=_unused, + send_query=_unused, + ) + counts = {"obj": 0, "sub": 0} + root = Address(1, 1, 100) + child = Address(1, 1, 101) + + async def fake_get_object(addr: Address) -> ObjectInfo: + counts["obj"] += 1 + if addr == root: + return ObjectInfo("Root", "", method_count=2, subobject_count=1, address=addr) + return ObjectInfo("Child", "", method_count=1, subobject_count=0, address=addr) + + async def fake_get_supported(addr: Address): + return {1, 3} if addr == root else {1} + + async def fake_get_subobject_address(_addr: Address, idx: int) -> Address: + counts["sub"] += 1 + self.assertEqual(idx, 0) + return child + + intro.get_object = fake_get_object # type: ignore[method-assign, assignment] + intro.get_supported_interface0_method_ids = fake_get_supported # type: ignore[method-assign, assignment] + intro.get_subobject_address = fake_get_subobject_address # type: ignore[method-assign, assignment] + + t1 = asyncio.run(intro.get_firmware_tree()) + t2 = asyncio.run(intro.get_firmware_tree()) + t3 = asyncio.run(intro.get_firmware_tree(refresh=True)) + + self.assertIs(t1, t2) + self.assertIsNot(t1, t3) + self.assertEqual(t1.path, "Root") + self.assertEqual(len(t1.children), 1) + self.assertIn("Root.Child", str(t1)) + self.assertGreaterEqual(counts["obj"], 4) # built twice (initial + refresh) + self.assertGreaterEqual(counts["sub"], 2) + + def test_flatten_firmware_tree_preorder(self): + a0 = Address(1, 1, 10) + a1 = Address(1, 1, 11) + a2 = Address(1, 1, 12) + o0 = ObjectInfo(name="root", version="v", method_count=1, subobject_count=2, address=a0) + o1 = ObjectInfo(name="child", version="v", method_count=1, subobject_count=0, address=a1) + o2 = ObjectInfo(name="other", version="v", method_count=1, subobject_count=0, address=a2) + c1 = FirmwareTreeNode(path="R.child", address=a1, object_info=o1, children=[]) + c2 = FirmwareTreeNode(path="R.other", address=a2, object_info=o2, children=[]) + root = FirmwareTreeNode(path="R", address=a0, object_info=o0, children=[c1, c2]) + flat = flatten_firmware_tree(root) + self.assertEqual([p for p, _, _ in flat], ["R", "R.child", "R.other"]) + + def test_get_firmware_tree_flat_delegates_to_flatten(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + a0 = Address(1, 1, 20) + o0 = ObjectInfo(name="only", version="v", method_count=0, subobject_count=0, address=a0) + root = FirmwareTreeNode(path="Only", address=a0, object_info=o0, children=[]) + + async def fake_get_firmware_tree(refresh: bool = False): + del refresh + return root + + client.introspection.get_firmware_tree = fake_get_firmware_tree # type: ignore[method-assign] + got = asyncio.run(client.introspection.get_firmware_tree_flat()) + self.assertEqual(len(got), 1) + self.assertEqual(got[0][0], "Only") + self.assertEqual(got[0][1], a0) + self.assertIs(got[0][2], o0) + + +class TestHcResultHelperUsesIntrospection(unittest.IsolatedAsyncioTestCase): + async def test_describe_entry_routes_to_introspection(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + entry = HcResultEntry(1, 1, 257, 1, 6, 0xF08) + client.introspection.get_interface_name = AsyncMock(return_value="ITest") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock( # type: ignore[method-assign] + return_value="Simulated" + ) + + iface_name, desc = await client._describe_entry(entry) + self.assertEqual(iface_name, "ITest") + self.assertEqual(desc, "Simulated") + + async def test_format_entry_context_uses_method_lookup_from_introspection(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + addr = Address(1, 1, 257) + client.registry.register( + "Root.Channel", + ObjectInfo(name="Channel", version="", method_count=0, subobject_count=0, address=addr), + ) + method = MethodInfo(interface_id=1, call_type=0, method_id=6, name="DoThing") + client.introspection.get_method_by_id = AsyncMock(return_value=method) # type: ignore[method-assign] + entry = HcResultEntry(1, 1, 257, 1, 6, 0xF08) + + context = await client._format_entry_context(entry) + assert context is not None + self.assertIn("path=Root.Channel", context) + self.assertIn("DoThing(void) -> void", context) + + +class TestWarningAndExceptionSemantics(unittest.TestCase): + @staticmethod + def _format_entry(entry: HcResultEntry) -> str: + return ( + f"0x{entry.module_id:04X}.0x{entry.node_id:04X}.0x{entry.object_id:04X}:" + f"0x{entry.interface_id:02X},0x{entry.action_id:04X},0x{entry.result:04X}" + ) + + @classmethod + def _build_warning_params(cls, entries: list[HcResultEntry], tail: bytes = b"") -> bytes: + summary = HoiParams().add(len(entries), U16).build() + entries_frag = HoiParams().add(";".join(cls._format_entry(e) for e in entries), Str).build() + return cast(bytes, summary + entries_frag + tail) + + def test_non_warning_action_does_not_strip(self): + payload = HoiParams().add(True, Bool).build() + rest, entries = split_hoi_params_after_warning_prefix(Hoi2Action.COMMAND_RESPONSE, payload) + self.assertEqual(rest, payload) + self.assertEqual(entries, []) + + def test_warning_prefix_strip_and_parse_entries(self): + entries = [HcResultEntry(1, 1, 257, 1, 6, 0x8001)] + tail = HoiParams().add(99, I32).build() + params = self._build_warning_params(entries, tail=tail) + rest, parsed = split_hoi_params_after_warning_prefix(Hoi2Action.COMMAND_WARNING, params) + self.assertEqual(rest, tail) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0].result, 0x8001) + self.assertTrue(parsed[0].is_warning) + + def test_parse_hamilton_error_entry_and_entries(self): + e1 = HcResultEntry(1, 1, 257, 1, 6, 0x0F08) + e2 = HcResultEntry(1, 1, 257, 1, 6, 0x0F09) + + one = HoiParams().add(self._format_entry(e1), Str).build() + got_one = parse_hamilton_error_entry(one) + assert got_one is not None + self.assertEqual(got_one.result, 0x0F08) + + two = HoiParams().add(self._format_entry(e1), Str).add(self._format_entry(e2), Str).build() + got_two = parse_hamilton_error_entries(two) + self.assertEqual([e.result for e in got_two], [0x0F08, 0x0F09]) + + +class TestErrorEntryChannelDetection(unittest.TestCase): + @dataclass + class _Ap: + channel: int + + @dataclass + class _CmdPrep(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 1 + dest: Address + aspirate_parameters: list + + def __post_init__(self): + super().__init__(self.dest) + + def test_true_when_struct_array_has_channel(self): + c = TestErrorEntryChannelDetection._CmdPrep( + Address(1, 1, 1), aspirate_parameters=[TestErrorEntryChannelDetection._Ap(0)] + ) + self.assertTrue(c.error_entries_use_physical_channels()) + + @dataclass + class _CmdVoid(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 35 + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + def test_false_for_void_command(self): + c = TestErrorEntryChannelDetection._CmdVoid(Address(1, 1, 1)) + self.assertFalse(c.error_entries_use_physical_channels()) + + @dataclass + class _CmdNimbus(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 4 + dest: Address + channels_involved: tuple + + def __post_init__(self): + super().__init__(self.dest) + + def test_true_when_channels_involved_present(self): + c = TestErrorEntryChannelDetection._CmdNimbus(Address(1, 1, 1), (1, 0)) + self.assertTrue(c.error_entries_use_physical_channels()) + + +class TestSendCommandStatusException(unittest.IsolatedAsyncioTestCase): + @staticmethod + def _format_wire_entry(entry: HcResultEntry) -> str: + return ( + f"0x{entry.module_id:04X}.0x{entry.node_id:04X}.0x{entry.object_id:04X}:" + f"0x{entry.interface_id:02X},0x{entry.action_id:04X},0x{entry.result:04X}" + ) + + async def test_void_command_raises_hoi_error(self): + entry = HcResultEntry(1, 1, 5376, 1, 35, 0x0206) + err_params = HoiParams().add(self._format_wire_entry(entry), Str).build() + + @dataclass + class CmdVoid(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 35 + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.STATUS_EXCEPTION, + action_id=0, + params=err_params, + ) + harp = HarpPacket( + src=Address(1, 1, 5376), + dst=Address(2, 1, 65535), + seq=1, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + client.introspection.get_interface_name = AsyncMock(return_value="MLPrep") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] + + cmd = CmdVoid(Address(1, 1, 5376)) + with self.assertRaises(HoiError) as ctx: + await client.send_command(cmd) + self.assertIn(0, ctx.exception.exceptions) + self.assertEqual(ctx.exception.entries[0].result, 0x0206) + + async def test_channels_involved_raises_channelized_error(self): + entry = HcResultEntry(1, 1, 257, 1, 6, 0x0F08) + err_params = HoiParams().add(self._format_wire_entry(entry), Str).build() + + @dataclass + class CmdPick(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 4 + dest: Address + channels_involved: tuple + + def __post_init__(self): + super().__init__(self.dest) + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.STATUS_EXCEPTION, + action_id=0, + params=err_params, + ) + harp = HarpPacket( + src=Address(1, 1, 257), + dst=Address(2, 1, 65535), + seq=1, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + client.introspection.get_interface_name = AsyncMock(return_value="Pipette") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] + + cmd = CmdPick(Address(1, 1, 257), (1, 0)) + with self.assertRaises(ChannelizedError) as ctx: + await client.send_command(cmd) + self.assertIn(0, ctx.exception.errors) + self.assertEqual(len(ctx.exception.kwargs["hoi_entries"]), 1) + self.assertIn(0, ctx.exception.kwargs["hoi_exceptions"]) + + +class TestHcResultDescriptionNimbusTable(unittest.IsolatedAsyncioTestCase): + """NIMBUS_ERROR_CODES keys use interface_id in the 4th slot; describe_entry must match that.""" + + async def test_lookup_uses_interface_id_not_method_id(self): + class _NimbusClient(HamiltonTCPClient): + _ERROR_CODES = NIMBUS_ERROR_CODES + + client = _NimbusClient(host="127.0.0.1", port=0) + client.introspection.get_interface_name = AsyncMock(return_value="Pipette") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] + entry = HcResultEntry(0x0001, 0x0001, 0x0110, 1, 6, 0x0F4E) + _iface, desc = await client._describe_entry(entry) + self.assertIn("Tip Detected Not Correct Tip", desc) + entry_b = HcResultEntry(0x0001, 0x0001, 0x0110, 1, 6, 0x0F4B) + _iface_b, desc_b = await client._describe_entry(entry_b) + self.assertIn("No Tip Picked Up", desc_b) + + +class TestCountedFlatArrayDecode(unittest.TestCase): + def test_counted_flat_array_nested_decode(self): + data = ( + HoiParams() + .add(1, I64) # enum_count + .add(1, I64) # enum_id + .add("E1", Str) + .add(2, I64) # value_count + .add("v1", Str) + .add(10, I64) + .add("v2", Str) + .add(20, I64) + .build() + ) + + parsed = parse_into_struct(HoiParamsParser(data), _GetEnumsResponse) + self.assertEqual(len(parsed.enums), 1) + self.assertEqual(parsed.enums[0].name, "E1") + self.assertEqual([v.name for v in parsed.enums[0].values], ["v1", "v2"]) + self.assertEqual([v.value for v in parsed.enums[0].values], [10, 20]) + + def test_i16_array_roundtrip_decode_fragment(self): + payload = struct.pack(" tuple[int, ...]: + """Collect all IDs from rows matching a boolean flag (is_struct_kind, is_enum_kind, etc.).""" + ids: list[int] = [] + for row in introspection_mod._HOI_TYPE_ROWS: + if getattr(row, flag): + ids.extend(tid for tid in row.ids if tid != 0) + return tuple(ids) + + def test_complex_method_and_struct_sets_are_disjoint(self): + self.assertTrue( + introspection_mod._COMPLEX_METHOD_TYPE_IDS.isdisjoint( + introspection_mod._COMPLEX_STRUCT_TYPE_IDS + ) + ) + + def test_method_param_struct_and_enum_ref_types_are_disjoint(self): + struct_wire = {HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY} + enum_wire = {HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY} + self.assertTrue(struct_wire.isdisjoint(enum_wire)) + + def test_method_param_type_struct_refs_cover_all_directions(self): + for row in introspection_mod._HOI_TYPE_ROWS: + if not row.is_struct_kind: + continue + for direction, tid in zip(introspection_mod.Direction, row.ids): + pt = introspection_mod.MethodParamType(row.wire_type, direction, source_id=2, ref_id=1) + self.assertTrue(pt.is_struct_ref) + self.assertFalse(pt.is_enum_ref) + + def test_struct_field_type_struct_refs_cover_wire_sentinels(self): + for wire_type in (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY): + sft = introspection_mod.StructFieldType(wire_type, source_id=2, ref_id=1) + self.assertTrue(sft.is_complex) + self.assertTrue(sft.is_struct_ref) + self.assertFalse(sft.is_enum_ref) + + def test_method_param_type_enum_refs_cover_all_directions(self): + for row in introspection_mod._HOI_TYPE_ROWS: + if not row.is_enum_kind: + continue + for direction, tid in zip(introspection_mod.Direction, row.ids): + pt = introspection_mod.MethodParamType(row.wire_type, direction, source_id=2, ref_id=1) + self.assertTrue(pt.is_enum_ref) + self.assertFalse(pt.is_struct_ref) + + def test_struct_field_type_enum_refs_cover_wire_sentinels(self): + for wire_type in (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY): + sft = introspection_mod.StructFieldType(wire_type, source_id=2, ref_id=1) + self.assertTrue(sft.is_complex) + self.assertTrue(sft.is_enum_ref) + self.assertFalse(sft.is_struct_ref) + + def test_scalar_method_param_type_is_not_a_reference(self): + row = next(r for r in introspection_mod._HOI_TYPE_ROWS if r.display_name == "i32") + pt = introspection_mod.MethodParamType(row.wire_type, introspection_mod.Direction.In) + self.assertFalse(pt.is_struct_ref) + self.assertFalse(pt.is_enum_ref) + + def test_scalar_struct_field_type_is_not_complex_or_reference(self): + sft = introspection_mod.StructFieldType(HamiltonDataType.F32) + self.assertFalse(sft.is_complex) + self.assertFalse(sft.is_struct_ref) + self.assertFalse(sft.is_enum_ref) + + +class TestIntrospectionTypeParsers(unittest.TestCase): + def test_parse_method_param_types_supports_simple_ref_and_node_global(self): + # [i8 In] + [struct In source=2 id=1] + [struct In source=4 id=9 "01" ] + raw = [1, 57, 2, 1, 57, 4, 9, 0x22, 0x30, 0x31, 0x22, 0x20] + parsed = introspection_mod._parse_method_param_types(raw) + self.assertEqual(len(parsed), 3) + self.assertEqual([pt.wire_type for pt in parsed], [HamiltonDataType.I8, HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE]) + self.assertEqual([pt.direction for pt in parsed], [introspection_mod.Direction.In, introspection_mod.Direction.In, introspection_mod.Direction.In]) + self.assertEqual([pt._byte_width for pt in parsed], [1, 3, 8]) + self.assertEqual((parsed[1].source_id, parsed[1].ref_id), (2, 1)) + self.assertEqual((parsed[2].source_id, parsed[2].ref_id), (4, 9)) + + def test_parse_struct_field_types_supports_simple_ref_and_node_global(self): + # [F32 simple] + [STRUCT source=2 id=3] + [STRUCT source=4 id=7 ModHi ModLo NodeHi NodeLo] + raw = [40, 30, 2, 3, 30, 4, 7, 0x00, 0x01, 0x00, 0x02] + parsed = introspection_mod._parse_struct_field_types(raw) + self.assertEqual(len(parsed), 3) + self.assertEqual([pt.type_id for pt in parsed], [HamiltonDataType.F32, HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE]) + self.assertEqual([pt._byte_width for pt in parsed], [1, 3, 7]) + self.assertEqual((parsed[1].source_id, parsed[1].ref_id), (2, 3)) + self.assertEqual((parsed[2].source_id, parsed[2].ref_id), (4, 7)) + + def test_struct_parser_byte_width_sum_matches_cursor_advance(self): + raw = [40, 30, 2, 3, 30, 4, 7, 0x00, 0x01, 0x00, 0x02] + parsed = introspection_mod._parse_struct_field_types(raw) + bytes_used = sum(pt._byte_width for pt in parsed[:3]) + self.assertEqual(bytes_used, len(raw)) + + +class TestHamiltonIntrospectionLazyCaches(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.addr = Address(1, 1, 99) + + async def _should_not_be_called(*a, **k): + raise AssertionError("transport should be patched out in introspection cache tests") + + self.intro = HamiltonIntrospection( + registry=ObjectRegistry(), + global_object_addresses=[], + send_discovery_command=_should_not_be_called, + send_query=_should_not_be_called, + ) + + async def test_second_ensure_method_table_skips_get_method(self): + info = ObjectInfo(name="O", version="", method_count=2, subobject_count=0, address=self.addr) + self.intro.get_object = AsyncMock(return_value=info) # type: ignore[method-assign] + self.intro.get_supported_interface0_method_ids = AsyncMock( # type: ignore[method-assign] + return_value={1, 2, 4, 5, 6} + ) + gm = AsyncMock( + side_effect=[ + MethodInfo(1, 0, 0, "a", [], [], [], []), + MethodInfo(1, 0, 1, "b", [], [], [], []), + ] + ) + self.intro.get_method = gm # type: ignore[method-assign] + r1 = await self.intro.ensure_method_table(self.addr) + self.assertEqual(len(r1), 2) + self.assertEqual(gm.call_count, 2) + r2 = await self.intro.methods_for_interface(self.addr, 1) + self.assertEqual(len(r2), 2) + self.assertEqual(gm.call_count, 2) + r3 = await self.intro.ensure_method_table(self.addr) + self.assertIs(r1, r3) + + async def test_lazy_signature_loads_only_referenced_iface(self): + st = StructInfo(struct_id=0, name="TipParams", fields={}, interface_id=1) + pt = introspection_mod.MethodParamType(HamiltonDataType.STRUCTURE, introspection_mod.Direction.In, source_id=2, ref_id=1) + m = MethodInfo(1, 0, 3, "Foo", [pt], ["p"], [], []) + info = ObjectInfo(name="O", version="", method_count=1, subobject_count=0, address=self.addr) + self.intro.get_object = AsyncMock(return_value=info) # type: ignore[method-assign] + self.intro.get_supported_interface0_method_ids = AsyncMock( # type: ignore[method-assign] + return_value={1, 2, 4, 5, 6} + ) + self.intro.get_method = AsyncMock(return_value=m) # type: ignore[method-assign] + self.intro.ensure_global_type_pool = AsyncMock( # type: ignore[method-assign] + return_value=GlobalTypePool() + ) + touched: list[int] = [] + + async def fake_ensure(addr, iface_id): + touched.append(iface_id) + key = (addr, iface_id) + self.intro._iface_types[key] = ({0: st}, {}) + + self.intro.ensure_structs_enums = fake_ensure # type: ignore[method-assign] + + sig = await self.intro.resolve_signature(self.addr, 1, 3) + self.assertIn("TipParams", sig) + self.assertEqual(touched, [1]) + + async def test_lazy_signature_matches_full_registry_for_local_struct(self): + st = StructInfo(struct_id=0, name="TipParams", fields={}, interface_id=1) + pt = introspection_mod.MethodParamType(HamiltonDataType.STRUCTURE, introspection_mod.Direction.In, source_id=2, ref_id=1) + m = MethodInfo(1, 0, 3, "Foo", [pt], ["p"], [], []) + info = ObjectInfo(name="O", version="", method_count=1, subobject_count=0, address=self.addr) + self.intro.get_object = AsyncMock(return_value=info) # type: ignore[method-assign] + self.intro.get_supported_interface0_method_ids = AsyncMock( # type: ignore[method-assign] + return_value={1, 2, 4, 5, 6} + ) + self.intro.get_method = AsyncMock(return_value=m) # type: ignore[method-assign] + self.intro.get_structs = AsyncMock(return_value=[st]) # type: ignore[method-assign] + self.intro.get_enums = AsyncMock(return_value=[]) # type: ignore[method-assign] + self.intro.ensure_global_type_pool = AsyncMock( # type: ignore[method-assign] + return_value=GlobalTypePool() + ) + + lazy_sig = await self.intro.resolve_signature(self.addr, 1, 3) + + full = TypeRegistry(address=self.addr, global_pool=GlobalTypePool()) + full.methods = [m] + full.structs[1] = {0: st} + full_sig = m.get_signature_string(full) + self.assertEqual(lazy_sig, full_sig) + + async def test_interface_name_and_hc_result_text_use_introspection_session_cache(self): + self.intro.get_interfaces = AsyncMock( # type: ignore[method-assign] + return_value=[InterfaceInfo(interface_id=1, name="ITest", version="")] + ) + name1 = await self.intro.get_interface_name(self.addr, 1) + name2 = await self.intro.get_interface_name(self.addr, 1) + self.assertEqual(name1, "ITest") + self.assertEqual(name2, "ITest") + self.assertEqual(self.intro.get_interfaces.call_count, 1) + + self.intro.get_supported_interface0_method_ids = AsyncMock(return_value={5, 6}) # type: ignore[method-assign] + self.intro.get_structs = AsyncMock(return_value=[]) # type: ignore[method-assign] + self.intro.get_enums = AsyncMock( # type: ignore[method-assign] + return_value=[ + EnumInfo( + enum_id=0, + name="HcResult", + values={"OK": 0, "SomethingFailed": 0xF08}, + ) + ] + ) + text1 = await self.intro.get_hc_result_text(self.addr, 1, 0xF08) + text2 = await self.intro.get_hc_result_text(self.addr, 1, 0xF08) + self.assertEqual(text1, "SomethingFailed") + self.assertEqual(text2, "SomethingFailed") + self.assertEqual(self.intro.get_enums.call_count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/hamilton/tcp/tests/__init__.py b/pylabrobot/hamilton/tcp/tests/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/pylabrobot/hamilton/tcp/wire_types.py b/pylabrobot/hamilton/tcp/wire_types.py new file mode 100644 index 00000000000..2bb6445ea08 --- /dev/null +++ b/pylabrobot/hamilton/tcp/wire_types.py @@ -0,0 +1,393 @@ +"""Unified bidirectional codec layer for Hamilton DataFragments. + +Single source of truth for DataFragment type IDs, encoding, and decoding. Each +WireType handles both encode (encode_into) and decode (decode_from) via the +same format; no separate dispatch tables or coercion blocks. + +Layout: (1) HamiltonDataType enum (type IDs for [type_id:1][flags:1][length:2] +[data:N]); (2) WireType hierarchy and Annotated type aliases (I32, F32, Bool, +Str, I32Array, etc.); (3) type registry and decode_fragment(). HoiParams and +HoiParamsParser delegate to this layer exclusively. +""" + +from __future__ import annotations + +import struct as _struct +from dataclasses import dataclass +from enum import IntEnum +from typing import TYPE_CHECKING, Annotated, Any + +if TYPE_CHECKING: + from pylabrobot.hamilton.tcp.messages import HoiParams + + +# --------------------------------------------------------------------------- +# Hamilton DataFragment type IDs (codec layer) +# --------------------------------------------------------------------------- +# Type identifiers for the DataFragment wire format [type_id:1][flags:1][length:2][data:N]. +# From Hamilton.Components.TransportLayer.Protocols.Parameter.ParameterTypes. + + +class HamiltonDataType(IntEnum): + """Hamilton parameter data types for wire encoding in DataFragments.""" + + VOID = 0 + # 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 + + # Structure and enum types (Prep and introspection) + STRUCTURE = 30 + STRUCTURE_ARRAY = 31 + ENUM = 32 + HC_RESULT = 33 # Same wire format as U16, used for error codes + ENUM_ARRAY = 35 + + # Introspection-only compound result type (no wire codec; used for HOI_RESULT method returns) + HOI_RESULT = 44 + + # 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 + + +# --------------------------------------------------------------------------- +# WireType hierarchy +# --------------------------------------------------------------------------- + + +class WireType: + """Base class: a wire-format type that can encode and decode HoiParams fragments.""" + + __slots__ = ("type_id",) + + def __init__(self, type_id: int): + self.type_id = type_id + + def encode_into(self, value, params: HoiParams) -> HoiParams: + raise NotImplementedError + + def decode_from(self, data: bytes) -> Any: + raise NotImplementedError + + +class Scalar(WireType): + """Fixed-size scalar encoded via ``struct.pack(fmt, value)``. + + When *padded* is ``True`` the Prep convention is used: flags byte = 0x01 + and one ``\\x00`` pad byte is appended after the value. + """ + + __slots__ = ("fmt", "padded") + + def __init__(self, type_id: int, fmt: str, padded: bool = False): + super().__init__(type_id) + self.fmt = fmt + self.padded = padded + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = _struct.pack(self.fmt, value) + return params._add_fragment(self.type_id, data, 0x01 if self.padded else 0) + + def decode_from(self, data: bytes) -> Any: + size = _struct.calcsize(self.fmt) + val = _struct.unpack(self.fmt, data[:size])[0] + if self.type_id == HamiltonDataType.BOOL: + return bool(val) + if self.type_id in ( + HamiltonDataType.F32, + HamiltonDataType.F64, + ): + return float(val) + return int(val) + + +class Array(WireType): + """Homogeneous array of packed scalars (no length prefix on the wire).""" + + __slots__ = ("element_fmt",) + + def __init__(self, type_id: int, element_fmt: str): + super().__init__(type_id) + self.element_fmt = element_fmt + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = _struct.pack(f"{len(value)}{self.element_fmt}", *value) + flags = 0x01 if self.type_id == HamiltonDataType.BOOL_ARRAY else 0 + return params._add_fragment(self.type_id, data, flags) + + def decode_from(self, data: bytes) -> Any: + el_size = _struct.calcsize(self.element_fmt) + count = len(data) // el_size + values = _struct.unpack(f"{count}{self.element_fmt}", data[: count * el_size]) + if self.type_id == HamiltonDataType.BOOL_ARRAY: + return [bool(v) for v in values] + return list(values) + + +class Struct(WireType): + """Nested structure -- recurse via ``HoiParams.from_struct``.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRUCTURE) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + from pylabrobot.hamilton.tcp.messages import HoiParams as HP + + return params._add_fragment(self.type_id, HP.from_struct(value).build()) + + def decode_from(self, data: bytes) -> Any: + return data + + +class StructArray(WireType): + """Array of nested structures.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRUCTURE_ARRAY) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + from pylabrobot.hamilton.tcp.messages import HoiParams as HP + + inner = b"" + for v in value: + payload = HP.from_struct(v).build() + inner += _struct.pack(" Any: + # Parse concatenated Structure sub-fragments: [type_id:1][flags:1][length:2][data:N] + out: list[bytes] = [] + off = 0 + while off + 4 <= len(data): + type_id = data[off] + length = int.from_bytes(data[off + 2 : off + 4], "little") + off += 4 + if off + length > len(data): + break + if type_id == HamiltonDataType.STRUCTURE: + out.append(data[off : off + length]) + off += length + return out + + +class CountedFlatArray(WireType): + """Count-prefix array where elements share the caller's parser stream. + + Decode-only (introspection protocol uses this; domain commands use StructArray). + """ + + __slots__ = () + + def __init__(self): + super().__init__(type_id=-1) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + raise NotImplementedError("CountedFlatArray is decode-only (introspection protocol)") + + +@dataclass(frozen=True) +class HcResultEntry: + """One channel's entry in a multi-channel ``NetworkType::HoiResult``. + + Source: vendor protocol reference (6 parallel arrays + HcResultEx bit layout). + ``result`` is the raw u16 HcResult code; + the high bit (0x8000) flags a warning, bits 8-11 encode error category. + """ + + module_id: int + node_id: int + object_id: int + interface_id: int + action_id: int + result: int + + @property + def is_warning(self) -> bool: + return bool(self.result & 0x8000) + + @property + def is_success(self) -> bool: + return self.result == 0 or self.is_warning + + @property + def address(self) -> tuple[int, int, int]: + return (self.module_id, self.node_id, self.object_id) + + +class StringType(WireType): + """Null-terminated ASCII string.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRING) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = value.encode("utf-8") + b"\x00" + return params._add_fragment(self.type_id, data) + + def decode_from(self, data: bytes) -> Any: + return data.rstrip(b"\x00").decode("utf-8") + + +class StringArrayType(WireType): + """Array of null-terminated strings (type_id=34). + + Wire format: payload is a concatenation of null-terminated UTF-8 strings with + no leading element count. Fragment length in the HOI header defines the + payload boundary. + """ + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRING_ARRAY) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = b"" + for s in value: + data += s.encode("utf-8") + b"\x00" + return params._add_fragment(self.type_id, data) + + def decode_from(self, data: bytes) -> Any: + if not data: + return [] + out: list[str] = [] + off = 0 + while off < len(data): + null_pos = data.find(b"\x00", off) + if null_pos == -1: + break + out.append(data[off:null_pos].decode("utf-8")) + off = null_pos + 1 + return out + + +# --------------------------------------------------------------------------- +# Annotated type aliases +# --------------------------------------------------------------------------- + +# Scalars (mypy sees the base Python type: int / float / bool / str) +I8 = Annotated[int, Scalar(HamiltonDataType.I8, "b")] +I16 = Annotated[int, Scalar(HamiltonDataType.I16, "h")] +I32 = Annotated[int, Scalar(HamiltonDataType.I32, "i")] +I64 = Annotated[int, Scalar(HamiltonDataType.I64, "q")] +U8 = Annotated[int, Scalar(HamiltonDataType.U8, "B")] +U16 = Annotated[int, Scalar(HamiltonDataType.U16, "H")] +U32 = Annotated[int, Scalar(HamiltonDataType.U32, "I")] +U64 = Annotated[int, Scalar(HamiltonDataType.U64, "Q")] +F32 = Annotated[float, Scalar(HamiltonDataType.F32, "f")] +F64 = Annotated[float, Scalar(HamiltonDataType.F64, "d")] +Bool = Annotated[bool, Scalar(HamiltonDataType.BOOL, "?")] +Enum = Annotated[int, Scalar(HamiltonDataType.ENUM, "I")] +HcResult = Annotated[int, Scalar(HamiltonDataType.HC_RESULT, "H")] +Str = Annotated[str, StringType()] + +# Prep-padded variants (Bool and U8 are always padded on Prep hardware) +PaddedBool = Annotated[bool, Scalar(HamiltonDataType.BOOL, "?", padded=True)] +PaddedU8 = Annotated[int, Scalar(HamiltonDataType.U8, "B", padded=True)] + +# Arrays (mypy sees ``list``) +I8Array = Annotated[list, Array(HamiltonDataType.I8_ARRAY, "b")] +I16Array = Annotated[list, Array(HamiltonDataType.I16_ARRAY, "h")] +I32Array = Annotated[list, Array(HamiltonDataType.I32_ARRAY, "i")] +I64Array = Annotated[list, Array(HamiltonDataType.I64_ARRAY, "q")] +U8Array = Annotated[list, Array(HamiltonDataType.U8_ARRAY, "B")] +U16Array = Annotated[list, Array(HamiltonDataType.U16_ARRAY, "H")] +U32Array = Annotated[list, Array(HamiltonDataType.U32_ARRAY, "I")] +U64Array = Annotated[list, Array(HamiltonDataType.U64_ARRAY, "Q")] +F32Array = Annotated[list, Array(HamiltonDataType.F32_ARRAY, "f")] +F64Array = Annotated[list, Array(HamiltonDataType.F64_ARRAY, "d")] +BoolArray = Annotated[list, Array(HamiltonDataType.BOOL_ARRAY, "?")] +EnumArray = Annotated[list, Array(HamiltonDataType.ENUM_ARRAY, "I")] +StrArray = Annotated[list, StringArrayType()] + +# Compound types: Structure and StructureArray do NOT have simple aliases +# because ``Annotated[object, Struct()]`` would erase the concrete type for +# mypy. Use inline ``Annotated[ConcreteType, Struct()]`` on each field to +# preserve full type safety. The class singletons are exported so call-sites +# only need ``Struct()`` and ``StructArray()``. + +# --------------------------------------------------------------------------- +# Type registry and decode_fragment +# --------------------------------------------------------------------------- + +_WIRE_TYPE_REGISTRY: dict[int, WireType] = {} + + +def _register(alias: type) -> None: + meta = getattr(alias, "__metadata__", (None,))[0] + assert meta is not None, f"Expected Annotated alias with metadata: {alias}" + _WIRE_TYPE_REGISTRY[meta.type_id] = meta + + +for _alias in [ + I8, + I16, + I32, + I64, + U8, + U16, + U32, + U64, + F32, + F64, + Bool, + Enum, + HcResult, + Str, + I8Array, + I16Array, + I32Array, + I64Array, + U8Array, + U16Array, + U32Array, + U64Array, + F32Array, + F64Array, + BoolArray, + EnumArray, + StrArray, +]: + _register(_alias) + +_WIRE_TYPE_REGISTRY[HamiltonDataType.STRUCTURE] = Struct() +_WIRE_TYPE_REGISTRY[HamiltonDataType.STRUCTURE_ARRAY] = StructArray() + + +def decode_fragment(type_id: int, data: bytes) -> Any: + """Decode a DataFragment payload using the unified type registry.""" + wt = _WIRE_TYPE_REGISTRY.get(type_id) + if wt is None: + raise ValueError(f"Unknown DataFragment type_id: {type_id}") + return wt.decode_from(data)