From 4cb96eeac83696012fa90d8a246b3be259de22b5 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:44:53 -0700 Subject: [PATCH 01/14] Hamilton TCP Layer: v1 Migration --- pylabrobot/hamilton/tcp/__init__.py | 65 + pylabrobot/hamilton/tcp/client.py | 401 ++++ pylabrobot/hamilton/tcp/commands.py | 245 +++ pylabrobot/hamilton/tcp/error_tables.py | 1858 +++++++++++++++++++ pylabrobot/hamilton/tcp/introspection.py | 2165 ++++++++++++++++++++++ pylabrobot/hamilton/tcp/messages.py | 1001 ++++++++++ pylabrobot/hamilton/tcp/packets.py | 419 +++++ pylabrobot/hamilton/tcp/protocol.py | 136 ++ pylabrobot/hamilton/tcp/tcp_tests.py | 436 +++++ pylabrobot/hamilton/tcp/wire_types.py | 390 ++++ 10 files changed, 7116 insertions(+) create mode 100644 pylabrobot/hamilton/tcp/__init__.py create mode 100644 pylabrobot/hamilton/tcp/client.py create mode 100644 pylabrobot/hamilton/tcp/commands.py create mode 100644 pylabrobot/hamilton/tcp/error_tables.py create mode 100644 pylabrobot/hamilton/tcp/introspection.py create mode 100644 pylabrobot/hamilton/tcp/messages.py create mode 100644 pylabrobot/hamilton/tcp/packets.py create mode 100644 pylabrobot/hamilton/tcp/protocol.py create mode 100644 pylabrobot/hamilton/tcp/tcp_tests.py create mode 100644 pylabrobot/hamilton/tcp/wire_types.py diff --git a/pylabrobot/hamilton/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py new file mode 100644 index 00000000000..62589f6e83d --- /dev/null +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -0,0 +1,65 @@ +"""Canonical v1 Hamilton TCP namespace.""" + +from pylabrobot.hamilton.tcp.client import HamiltonTCPClient +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection, MethodInfo, ObjectInfo +from pylabrobot.hamilton.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParams, + HoiParamsParser, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, +) +from pylabrobot.hamilton.tcp.packets import ( + Address, + ConnectionPacket, + HarpPacket, + HoiPacket, + IpPacket, + RegistrationPacket, +) +from pylabrobot.hamilton.tcp.protocol import ( + HAMILTON_PROTOCOL_VERSION_MAJOR, + HAMILTON_PROTOCOL_VERSION_MINOR, + HamiltonProtocol, + HarpTransportableProtocol, + Hoi2Action, + HoiRequestId, + RegistrationActionCode, + RegistrationOptionType, +) +from pylabrobot.hamilton.tcp.wire_types import HamiltonDataType + +__all__ = [ + "Address", + "CommandMessage", + "CommandResponse", + "ConnectionPacket", + "HAMILTON_PROTOCOL_VERSION_MAJOR", + "HAMILTON_PROTOCOL_VERSION_MINOR", + "HamiltonCommand", + "HamiltonTCPClient", + "HamiltonDataType", + "HamiltonIntrospection", + "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..4041e00080d --- /dev/null +++ b/pylabrobot/hamilton/tcp/client.py @@ -0,0 +1,401 @@ +"""Hamilton TCP client for TCP-based instruments (Nimbus, Prep, etc.).""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Any, Dict, Optional, Set, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.messages import ( + CommandResponse, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, +) +from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection, ObjectRegistry +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.hamilton.tcp.protocol import ( + Hoi2Action, + HoiRequestId, + RegistrationActionCode, + RegistrationOptionType, +) +from pylabrobot.io.binary import Reader +from pylabrobot.io.socket import Socket + +logger = logging.getLogger(__name__) + + +@dataclass +class HamiltonError: + """Hamilton error response.""" + + error_code: int + error_message: str + interface_id: int + action_id: int + + +class ErrorParser: + """Parse Hamilton error responses.""" + + @staticmethod + def parse_error(data: bytes) -> HamiltonError: + """Parse error response from Hamilton instrument.""" + if len(data) < 8: + raise ValueError("Error response too short") + + error_code = Reader(data).u32() + error_message = data[4:].decode("utf-8", errors="replace") + + return HamiltonError( + error_code=error_code, error_message=error_message, interface_id=0, action_id=0 + ) + + +class HamiltonTCPClient(Driver): + """Standalone transport + discovery/introspection client for Hamilton TCP devices.""" + + def __init__( + self, + host: str, + port: int, + read_timeout: float = 30.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + ): + 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._client_id: Optional[int] = None + self.client_address: Optional[Address] = None + self._sequence_numbers: Dict[Address, int] = {} + self._discovered_objects: Dict[str, list[Address]] = {} + self._instrument_addresses: Dict[str, Address] = {} + self._registry = ObjectRegistry() + self._supported_interface0_method_ids: Dict[Address, Set[int]] = {} + + 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 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 data + except (ConnectionError, OSError, TimeoutError): + self._connected = False + raise + + @property + def is_connected(self) -> bool: + return self._connected + + async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse]: + size_data = await self.read_exact(2) + packet_size = Reader(size_data).u16() + + payload_data = await self.read_exact(packet_size) + 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: + return CommandResponse.from_bytes(complete_data) + 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 # reserved for capability-level startup params + await self.io.setup() + self._connected = True + self._reconnect_attempts = 0 + await self._initialize_connection() + await self._register_client() + await self._discover_root() + logger.info(f"Hamilton TCP client setup complete. Client ID: {self._client_id}") + + async def _initialize_connection(self): + logger.info("Initializing Hamilton connection...") + + packet = InitMessage(timeout=30).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) + self._discovered_objects["root"] = root_objects + self._registry.set_root_addresses(root_objects) + + 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: HamiltonCommand, + timeout: float = 10.0, + ensure_connection: bool = True, + return_raw: bool = False, + ) -> Optional[Any]: + del ensure_connection # The client enforces connection checks internally. + 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() + await self.write(message) + + if timeout is None: + response_message = await self._read_one_message() + else: + response_message = await asyncio.wait_for(self._read_one_message(), timeout) + assert isinstance(response_message, CommandResponse) + + action = Hoi2Action(response_message.hoi.action_code) + if action in ( + Hoi2Action.STATUS_EXCEPTION, + Hoi2Action.COMMAND_EXCEPTION, + Hoi2Action.INVALID_ACTION_RESPONSE, + ): + error_message = f"Error response (action={action:#x}): {response_message.hoi.params.hex()}" + logger.error(f"Hamilton error {action}: {error_message}") + raise RuntimeError(f"Hamilton error {action}: {error_message}") + + if return_raw: + return (response_message.hoi.params,) + + return command.interpret_response(response_message) + + async def resolve_path(self, path: str) -> Address: + """Resolve strict dot-path target to Address.""" + return await self._registry.resolve(path, self) + + 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 get_supported_interface0_method_ids(self, address: Address) -> Set[int]: + """Return cached supported Interface-0 methods for an address.""" + if address in self._supported_interface0_method_ids: + return set(self._supported_interface0_method_ids[address]) + + introspection = HamiltonIntrospection(self) + obj = await introspection.get_object(address) + supported: Set[int] = set() + for i in range(obj.method_count): + try: + method = await introspection.get_method(address, i) + except Exception as e: + logger.debug("get_method(%s, %d) failed: %s", address, i, e) + continue + if method.interface_id == 0: + supported.add(method.method_id) + self._supported_interface0_method_ids[address] = supported + return set(supported) + + async def get_firmware_tree(self, refresh: bool = False): + """Return cached firmware tree, or build it through introspection.""" + return await HamiltonIntrospection(self).get_firmware_tree(refresh=refresh) + + async def print_firmware_tree(self, refresh: bool = False): + """Print firmware tree text and return the tree object.""" + return await HamiltonIntrospection(self).print_firmware_tree(refresh=refresh) + + async def stop(self): + try: + await self.io.stop() + except Exception as e: + logger.warning(f"Error during stop: {e}") + finally: + self._connected = False + logger.info("Hamilton TCP client stopped") + diff --git a/pylabrobot/hamilton/tcp/commands.py b/pylabrobot/hamilton/tcp/commands.py new file mode 100644 index 00000000000..ca40e42138f --- /dev/null +++ b/pylabrobot/hamilton/tcp/commands.py @@ -0,0 +1,245 @@ +"""Command layer for Hamilton TCP. + +HamiltonCommand 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 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. + + This replaces the old HamiltonCommand from tcp_codec.py with a cleaner design: + - Explicitly uses CommandMessage for building packets + - build_parameters() returns HoiParams object (not bytes) + - Uses Address instead of ObjectAddress + - Cleaner separation of concerns + + Example: + class MyCommand(HamiltonCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 42 + + def __init__(self, dest: Address, value: int): + super().__init__(dest) + self.value = value + + def build_parameters(self) -> HoiParams: + return HoiParams().add(self.value, I32) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + parser = HoiParamsParser(data) + _, result = parser.parse_next() + return {'result': result} + """ + + # Class-level attributes that subclasses must override + protocol: Optional[HamiltonProtocol] = None + interface_id: Optional[int] = None + command_id: Optional[int] = None + + # Action configuration (can be overridden by subclasses) + action_code: int = 3 # Default: COMMAND_REQUEST + harp_protocol: int = 2 # Default: HOI2 + ip_protocol: int = 6 # Default: OBJECT_DISCOVERY + + def __init__(self, dest: Address): + """Initialize Hamilton command. + + Args: + dest: Destination address for this command + """ + if self.protocol is None: + raise ValueError(f"{self.__class__.__name__} must define protocol") + if self.interface_id is None: + raise ValueError(f"{self.__class__.__name__} must define interface_id") + if self.command_id is None: + raise ValueError(f"{self.__class__.__name__} must define command_id") + + self.dest = dest + self.dest_address = dest # Alias for compatibility + self.sequence_number = 0 + self.source_address: Optional[Address] = None + + def build_parameters(self) -> HoiParams: + """Build HOI parameters for this command. + + Override this method in subclasses to provide command-specific parameters. + Return a HoiParams object (not bytes!). + + Returns: + HoiParams object with command parameters + """ + return HoiParams() + + def get_log_params(self) -> dict: + """Get parameters to log for this command. + + Lazily computes the parameters by inspecting the __init__ signature + and reading current attribute values from self. + + Subclasses can override to customize formatting (e.g., unit conversions, + array truncation). + + Returns: + Dictionary of parameter names to values + """ + exclude = {"self", "dest"} + sig = inspect.signature(type(self).__init__) + params = {} + for param_name in sig.parameters: + if param_name not in exclude and hasattr(self, param_name): + params[param_name] = getattr(self, param_name) + return params + + def build( + self, src: Optional[Address] = None, seq: Optional[int] = None, response_required: bool = True + ) -> bytes: + """Build complete Hamilton message using CommandMessage. + + Args: + src: Source address (uses self.source_address if None) + seq: Sequence number (uses self.sequence_number if None) + response_required: Whether a response is expected + + Returns: + Complete packet bytes ready to send over TCP + """ + # Use instance attributes if not provided + source = src if src is not None else self.source_address + sequence = seq if seq is not None else self.sequence_number + + if source is None: + raise ValueError("Source address not set - backend should set this before building") + + # Ensure required attributes are set (they should be by subclasses) + if self.interface_id is None: + raise ValueError(f"{self.__class__.__name__} must define interface_id") + if self.command_id is None: + raise ValueError(f"{self.__class__.__name__} must define command_id") + + # Build parameters using command-specific logic + params = self.build_parameters() + + # Create CommandMessage and set parameters directly + # This avoids wasteful serialization/parsing round-trip + msg = CommandMessage( + dest=self.dest, + interface_id=self.interface_id, + method_id=self.command_id, + params=params, + action_code=self.action_code, + harp_protocol=self.harp_protocol, + ip_protocol=self.ip_protocol, + ) + + # Build final packet + return msg.build(source, sequence, harp_response_required=response_required) + + def _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. + + 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 + + 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 ``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]: + """Parse response parameters from HOI payload. + + Override this method in subclasses to parse command-specific responses. + + Args: + data: Raw bytes from HOI fragments field + + Returns: + Dictionary with parsed response data, or None if no data to extract + """ + return None + + +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..306fd08dfc7 --- /dev/null +++ b/pylabrobot/hamilton/tcp/error_tables.py @@ -0,0 +1,1858 @@ +"""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, action_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`` : empty placeholder. Prep-specific codes surface as hex + until a Prep reference table is available and the generator is re-run. +""" + +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.", +} + +# Placeholder: awaiting a Prep-module decompile with AddErrorData calls. +PREP_ERROR_CODES: Dict[Tuple[int, int, int, int, int], str] = {} diff --git a/pylabrobot/hamilton/tcp/introspection.py b/pylabrobot/hamilton/tcp/introspection.py new file mode 100644 index 00000000000..a11a2544313 --- /dev/null +++ b/pylabrobot/hamilton/tcp/introspection.py @@ -0,0 +1,2165 @@ +"""Hamilton TCP Introspection API. + +Wraps HamiltonTCPClient to provide dynamic discovery of instrument capabilities +via Interface 0 methods (GetObject, GetMethod, GetStructs, GetEnums, +GetInterfaces, GetSubobjectAddress). + +Canonical usage:: + + intro = HamiltonIntrospection(client) # standalone + intro = HamiltonIntrospection(lh.backend.client) # from LiquidHandler + + # Build a cached registry for one object (uses InterfaceDescriptors): + registry = await intro.build_type_registry("MLPrepRoot.MphRoot.MPH") + registry.print_summary() + + # Resolve a method signature: + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9, registry) +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Literal, Optional, Set, TypeVar, Union, cast + +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +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 HamiltonProtocol +from pylabrobot.hamilton.tcp.wire_types import ( + U8, + U16, + U32, + HamiltonDataType, + I8Array, + I32Array, + Str, + StrArray, + U8Array, + U32Array, +) + +logger = logging.getLogger(__name__) + +# 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 +# ============================================================================ + + +def resolve_type_id(type_id: int) -> str: + """Resolve Hamilton type ID to readable name. + + Args: + type_id: Hamilton data type ID + + Returns: + Human-readable type name + """ + try: + return HamiltonDataType(type_id).name + except ValueError: + return f"UNKNOWN_TYPE_{type_id}" + + +# ============================================================================ +# INTROSPECTION TYPE MAPPING (2D table from HoiObject.mHoiParamTypes) +# ============================================================================ +# Introspection type IDs are separate from HamiltonDataType wire encoding types. +# Rows = firmware scalar or array kinds; columns = In, Out, InOut, RetVal +# (HoiParameterType.Direction). Source: vendor protocol reference mHoiParamTypes[31,4]. + +_HOI_DOTNET_TYPE_ROWS: tuple[str, ...] = ( + "i8", + "i16", + "i32", + "u8", + "u16", + "u32", + "str", + "bool", + "i8[]", + "i16[]", + "i32[]", + "u8[]", + "u16[]", + "u32[]", + "bool[]", + "HcResult", + "struct", + "struct[]", + "str[]", + "enum", + "enum[]", + "i64", + "u64", + "f32", + "f64", + "i64[]", + "u64[]", + "f32[]", + "f64[]", + "HoiResult", + "padding", +) + +_HOI_PARAM_DIRECTION: tuple[str, ...] = ("In", "Out", "InOut", "RetVal") + +# type_ids per row [In, Out, InOut, RetVal] — ord() of each C# string cell (row 30 unused). +_HOI_PARAM_TYPE_GRID: tuple[tuple[int, int, int, int], ...] = ( + (1, 17, 9, 25), + (3, 19, 11, 27), + (5, 21, 13, 29), + (2, 18, 10, 26), + (4, 20, 12, 28), + (6, 22, 14, 30), + (7, 23, 15, 31), + (33, 35, 34, 36), + (37, 39, 38, 40), + (41, 43, 42, 44), + (49, 51, 50, 52), + (8, 24, 16, 32), + (45, 47, 46, 48), + (53, 55, 54, 56), + (66, 68, 67, 69), + (70, 72, 71, 73), + (57, 59, 58, 60), + (61, 63, 62, 64), + (74, 76, 75, 77), + (78, 80, 79, 81), + (82, 84, 83, 85), + (86, 88, 87, 89), + (90, 92, 91, 93), + (94, 96, 95, 97), + (98, 100, 99, 101), + (102, 104, 103, 105), + (106, 108, 107, 109), + (110, 112, 111, 113), + (114, 116, 115, 117), + (118, 120, 119, 121), + (0, 0, 0, 0), +) + +_ROW_DISPLAY: dict[str, str] = { + "i8": "i8", + "i16": "i16", + "i32": "i32", + "u8": "u8", + "u16": "u16", + "u32": "u32", + "str": "str", + "bool": "bool", + "i8[]": "List[i8]", + "i16[]": "List[i16]", + "i32[]": "List[i32]", + "u8[]": "bytes", + "u16[]": "List[u16]", + "u32[]": "List[u32]", + "bool[]": "List[bool]", + "HcResult": "HcResult", + "struct": "struct", + "struct[]": "List[struct]", + "str[]": "List[str]", + "enum": "enum", + "enum[]": "List[enum]", + "i64": "i64", + "u64": "u64", + "f32": "f32", + "f64": "f64", + "i64[]": "List[i64]", + "u64[]": "List[u64]", + "f32[]": "List[f32]", + "f64[]": "List[f64]", + "HoiResult": "HoiResult", + "padding": "padding", +} + + +def _build_introspection_maps() -> tuple[dict[int, str], set[int], set[int], set[int], set[int]]: + names: dict[int, str] = {0: "void"} + arg_ids: set[int] = set() + ret_el_ids: set[int] = set() + ret_val_ids: set[int] = set() + complex_method_ids: set[int] = set() + complex_rows = frozenset({15, 16, 17, 18, 19, 20, 29}) + + for ri, row in enumerate(_HOI_PARAM_TYPE_GRID): + base_key = _HOI_DOTNET_TYPE_ROWS[ri] + for ci, tid in enumerate(row): + if tid == 0: + continue + d = _HOI_PARAM_DIRECTION[ci] + disp = _ROW_DISPLAY.get(base_key, base_key) + names[tid] = f"{disp} [{d}]" + if ci in (0, 2): + arg_ids.add(tid) + elif ci == 1: + ret_el_ids.add(tid) + elif ci == 3: + ret_val_ids.add(tid) + if ri in complex_rows: + complex_method_ids.add(tid) + + return names, arg_ids, ret_el_ids, ret_val_ids, complex_method_ids + + +( + _INTROSPECTION_TYPE_NAMES, + _ARGUMENT_TYPE_IDS, + _RETURN_ELEMENT_TYPE_IDS, + _RETURN_VALUE_TYPE_IDS, + _COMPLEX_METHOD_TYPE_IDS, +) = _build_introspection_maps() + +# Empirical / device-specific id observed in the wild (not in HoiObject 31×4 grid). +_INTROSPECTION_TYPE_NAMES[113] = "List[f32] [In] (empirical)" +_ARGUMENT_TYPE_IDS.add(113) + +_COMPLEX_STRUCT_TYPE_IDS = {30, 31, 32, 35} # STRUCTURE=30, STRUCT_ARRAY=31, ENUM=32, ENUM_ARRAY=35 +# Backward-compat alias (used by ParameterType.is_complex for method parameters) +_COMPLEX_TYPE_IDS = _COMPLEX_METHOD_TYPE_IDS + + +def get_introspection_type_category(type_id: int) -> str: + """Get category for introspection type ID. + + Args: + type_id: Introspection type ID + + Returns: + Category: "Argument", "ReturnElement", "ReturnValue", or "Unknown" + """ + if type_id in _ARGUMENT_TYPE_IDS: + return "Argument" + elif type_id in _RETURN_ELEMENT_TYPE_IDS: + return "ReturnElement" + elif type_id in _RETURN_VALUE_TYPE_IDS: + return "ReturnValue" + else: + return "Unknown" + + +def resolve_introspection_type_name(type_id: int) -> str: + """Resolve introspection type ID to readable name. + + Args: + type_id: Introspection type ID + + Returns: + Human-readable type name + """ + return _INTROSPECTION_TYPE_NAMES.get(type_id, f"UNKNOWN_TYPE_{type_id}") + + +# ============================================================================ +# DATA STRUCTURES +# ============================================================================ + + +@dataclass +class ObjectInfo: + """Object metadata from introspection.""" + + name: str + version: str + method_count: int + subobject_count: int + address: Address + children: Dict[str, "ObjectInfo"] = field(default_factory=dict) + + +class ObjectRegistry: + """Object graph cache keyed by both path and address.""" + + def __init__(self): + self._objects: Dict[str, ObjectInfo] = {} + self._address_to_path: Dict[Address, str] = {} + self._root_addresses: List[Address] = [] + + def set_root_addresses(self, addresses: List[Address]) -> None: + self._root_addresses = list(addresses) + + def get_root_addresses(self) -> List[Address]: + return list(self._root_addresses) + + def register(self, path: str, obj: ObjectInfo) -> None: + self._objects[path] = obj + self._address_to_path[obj.address] = path + + def path(self, address: Address) -> Optional[str]: + return self._address_to_path.get(address) + + async def resolve(self, path: str, transport: Any) -> Address: + """Resolve dot-path to address via lazy introspection.""" + if path in self._objects: + return cast(Address, self._objects[path].address) + + parts = [p for p in path.split(".") if p] + if not parts: + raise KeyError(f"Invalid path: '{path}'") + + parent_path = ".".join(parts[:-1]) + child_name = parts[-1] + + introspection = HamiltonIntrospection(transport) + if not parent_path: + if not self._root_addresses: + raise KeyError("No root addresses; run discovery first") + parent_addr = self._root_addresses[0] + parent_info = await introspection.get_object(parent_addr) + parent_info.children = {} + self.register(parent_info.name, parent_info) + if parent_info.name == child_name: + return parent_info.address + raise KeyError(f"Root object is '{parent_info.name}', not '{child_name}'") + + parent_addr = await self.resolve(parent_path, transport) + parent_info = self._objects[parent_path] + supported = await transport.get_supported_interface0_method_ids(parent_addr) + if GET_SUBOBJECT_ADDRESS not in supported: + raise KeyError( + f"Object at path '{parent_path}' does not support GetSubobjectAddress " + f"(interface 0, method 3); cannot resolve child '{child_name}'" + ) + + for i in range(parent_info.subobject_count): + sub_addr = await introspection.get_subobject_address(parent_addr, i) + sub_info = await introspection.get_object(sub_addr) + sub_info.children = {} + child_path = f"{parent_path}.{sub_info.name}" + parent_info.children[sub_info.name] = sub_info + self.register(child_path, sub_info) + if sub_info.name == child_name: + return sub_info.address + + raise KeyError(f"Child '{child_name}' not found under '{parent_path}'") + + +@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()) + + +@dataclass +class FirmwareTree: + """Structured firmware tree produced by introspection traversal.""" + + roots: List[FirmwareTreeNode] = field(default_factory=list) + + def format(self) -> str: + if not self.roots: + return "" + lines: List[str] = [] + for idx, root in enumerate(self.roots): + root_is_last = idx == len(self.roots) - 1 + lines.extend(root.format_lines(prefix="", is_last=root_is_last, is_root=True)) + return "\n".join(lines) + + def __str__(self) -> str: + return self.format() + + +@dataclass +class ParameterType: + """A resolved type reference used for both method parameters and struct fields. + + Simple types (i8, f32, etc.) have only type_id set. + Complex references additionally carry source_id (the interface defining the + struct/enum) and ref_id (struct_id or enum_id within that interface). + These are encoded as 3-byte triples [type_id, source_id, ref_id] in two + distinct contexts that each use a different sentinel byte: + + - GetMethod parameterTypes: sentinels in _COMPLEX_METHOD_TYPE_IDS (57, 61 …) + - GetStructs structureElementTypes: sentinel 0xE8 (_COMPLEX_STRUCT_TYPE_IDS) + """ + + type_id: int + source_id: Optional[int] = None + ref_id: Optional[int] = None + _byte_width: int = 1 # Bytes consumed in struct element_types (1=simple, 3=ref, 7+=inline) + + @property + def is_complex(self) -> bool: + """True if this is a 3-byte complex reference (method param or struct field).""" + return self.type_id in (_COMPLEX_METHOD_TYPE_IDS | _COMPLEX_STRUCT_TYPE_IDS) + + @property + def is_struct_ref(self) -> bool: + """True if this is a struct reference (type 30 in struct context, 57/61 in method context).""" + return self.type_id in {30, 31, 57, 60, 61, 63, 64} + + @property + def is_enum_ref(self) -> bool: + """True if this is an enum reference (type 32 in struct context, 78/81/82/85 in method).""" + return self.type_id in {32, 35, 78, 81, 82, 85} + + 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 source_id=2 (local) refs, pass ``ho_interface_id`` (the HOI interface id + of the method or struct owning this type) so resolution uses that interface's + table only. If omitted, registry falls back to multi-interface heuristics. + """ + base = resolve_introspection_type_name(self.type_id) + if not self.is_complex or self.source_id is None or self.ref_id is None: + return base + if registry is None: + return f"{base}(iface={self.source_id}, id={self.ref_id})" + if self.is_struct_ref: + s = registry.resolve_struct(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + return s.name if s else f"{base}(iface={self.source_id}, id={self.ref_id})" + if self.is_enum_ref: + e = registry.resolve_enum(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + return e.name if e else f"{base}(iface={self.source_id}, id={self.ref_id})" + return f"{base}(iface={self.source_id}, id={self.ref_id})" + + +def _parse_type_seq( + data: bytes | list[int], + complex_ids: set[int], +) -> List[ParameterType]: + """Shared variable-width parser for Hamilton type-ID byte sequences. + + Both GetMethod parameterTypes and GetStructs structureElementTypes encode types + as a byte stream where simple types occupy 1 byte and complex references have + variable width. + + For struct element types (complex_ids = _COMPLEX_STRUCT_TYPE_IDS), complex + sentinels (30=STRUCTURE, 31=STRUCT_ARRAY, 32=ENUM, 35=ENUM_ARRAY) have two + encoding formats determined by the second byte: + + - **Reference** (second byte ≤ 3): 3 bytes ``[sentinel, source_id, ref_id]`` + where source 1=global, 2=local, 3=network. + - **Inline definition** (second byte = 4): variable width, terminated by + ``0xEE`` (238). Typically 7 bytes: ``[sentinel, 4, base_type, 0, 1, 0, 0xEE]``. + The ``base_type`` specifies the underlying wire type (1=I8, 2=I16, 3=I32). + + For method parameter types, only the 3-byte reference format is used. + + Args: + data: Raw bytes or list of ints to parse. + complex_ids: Set of type_id values that introduce a multi-byte entry. + + Returns: + List of ParameterType, one per logical type entry. + """ + _INLINE_MARKER = 4 + _INLINE_TERMINATOR = 0xEE # 238 + + ints = list(data) if isinstance(data, bytes) else data + result: List[ParameterType] = [] + i = 0 + while i < len(ints): + tid = ints[i] + if tid in complex_ids and i + 2 < len(ints): + second = ints[i + 1] + if second == _INLINE_MARKER: + # Inline type definition: scan forward to 0xEE terminator + end = i + 2 + while end < len(ints) and ints[end] != _INLINE_TERMINATOR: + end += 1 + end += 1 # consume the 0xEE byte itself + # Store as ParameterType with the base wire type from byte [i+2] + width = end - i + base_type = ints[i + 2] if i + 2 < len(ints) else 0 + result.append( + ParameterType(tid, source_id=_INLINE_MARKER, ref_id=base_type, _byte_width=width) + ) + i = end + else: + # Standard 3-byte reference: [sentinel, source_id, ref_id] + result.append(ParameterType(tid, source_id=second, ref_id=ints[i + 2], _byte_width=3)) + i += 3 + else: + result.append(ParameterType(tid)) + i += 1 + return result + + +def _parse_type_ids(raw: str | bytes | None) -> List[ParameterType]: + """Parse GetMethod parameterTypes blob. Thin wrapper around _parse_type_seq. + + 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_type_seq(data, _COMPLEX_METHOD_TYPE_IDS) + + +@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}]" + + @staticmethod + def _signature_type_name(type_name: str) -> str: + """Strip direction markers for human-readable signatures.""" + cleaned = type_name + for marker in ("In", "Out", "RetVal"): + cleaned = cleaned.replace(f" [{marker}]", "") + return cleaned.strip() + + def signature_string(self) -> str: + """Render the canonical method descriptor as a signature string.""" + if self.params: + param_str = ", ".join( + f"{p.name}: {self._signature_type_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] + ret_type = self._signature_type_name(ret.type_name) + return_str = f"{ret.name}: {ret_type}" if ret.name != "ret0" else ret_type + else: + return_str = ( + "{ " + + ", ".join(f"{r.name}: {self._signature_type_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 +class MethodInfo: + """Method signature from introspection.""" + + interface_id: int + call_type: int + method_id: int + name: str + parameter_types: list[ParameterType] = field(default_factory=list) + parameter_labels: list[str] = field(default_factory=list) + return_types: list[ParameterType] = 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 = [ + 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 = [ + rt.resolve_name(registry, ho_interface_id=iid) for rt in self.return_types + ] + return_categories = [get_introspection_type_category(rt.type_id) 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(cat == "ReturnElement" for cat in return_categories): + return_shape = "scalar" + elif len(returns) > 0: + # Includes 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, + ) + + 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 struct(iface=1, id=57)). + """ + 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() + + +_TLocal = TypeVar("_TLocal") + + +def _lookup_local_table_entry( + tables: Dict[int, Dict[int, _TLocal]], + ref_id: int, +) -> Optional[_TLocal]: + """Resolve source_id=2 (local) refs when HOI interface id is unknown. + + ref_id is 1-based; struct/enum_id in tables is 0-based. Tables are keyed by HOI + interface id from InterfaceDescriptors (e.g. 1 for API, 0 for introspection), not + by the literal ``2`` in the wire triple. Prefers interface 1 when present, then + scans other non-zero keys — unsafe if two interfaces reuse the same local index + with different meanings. Prefer ``TypeRegistry.resolve_*(..., ho_interface_id=…)``. + """ + idx = ref_id - 1 + if idx < 0: + return None + if 1 in tables: + hit = tables[1].get(idx) + if hit is not None: + return hit + for iid in sorted(k for k in tables if k != 0 and k != 1): + hit = tables[iid].get(idx) + if hit is not None: + return hit + return None + + +@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 (same-interface references) appears in nested struct field type bytes; + indexing for method-level params and whether to use GlobalTypePool.resolve_struct_local + vs. this registry should be validated on hardware — do not assume the same 1-based rule + as source_id=2 locals. + + For source_id=2, pass ``ho_interface_id`` on ``resolve_struct`` / ``resolve_enum`` whenever + the owning method or struct's interface is known (strict table lookup). Omitting it uses + a legacy multi-interface fallback that may be ambiguous if two interfaces share a local index. + + Example: + 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, ...) + """ + + 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]``). Pass ``ho_interface_id`` for strict, + interface-scoped resolution; if omitted, uses multi-interface fallback + (see _lookup_local_table_entry). + """ + 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 not None: + return self.structs.get(ho_interface_id, {}).get(idx) + return _lookup_local_table_entry(self.structs, ref_id) + 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). Pass ``ho_interface_id`` + for strict resolution; if omitted, multi-interface fallback applies. + """ + 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 not None: + return self.enums.get(ho_interface_id, {}).get(idx) + return _lookup_local_table_entry(self.enums, ref_id) + 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 +class InterfaceInfo: + """Interface metadata from introspection.""" + + interface_id: int + 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: + """Enum definition from introspection.""" + + enum_id: int + 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. + + ``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 ``ParameterType`` instances, preserving the + full (type_id, source_id, ref_id) triple for fields that are complex + references (type 30=STRUCTURE, 32=ENUM). Call ``get_struct_string(registry)`` + to get human-readable names with struct/enum references resolved. + """ + + struct_id: int + name: str + fields: Dict[str, "ParameterType"] # field_name -> ParameterType + 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 using HamiltonDataType resolver.""" + return {name: _resolve_struct_field_type(pt) for name, pt 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: _resolve_struct_field_type(pt, registry, ho_interface_id=ho_iid) + for name, pt 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. + + If a TypeRegistry is provided, complex references (struct/enum fields) + are resolved to their names. + """ + ho_iid = self.interface_id + field_strs = [ + f"{name}: {_resolve_struct_field_type(pt, registry, ho_interface_id=ho_iid)}" + for name, pt 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": ParameterType(type_id=5), # U16 + "month": ParameterType(type_id=4), # U8 (padded) + "day": ParameterType(type_id=4), + "hour": ParameterType(type_id=4), + "minute": ParameterType(type_id=4), + "second": ParameterType(type_id=4), + "millisecond": ParameterType(type_id=5), # 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. + + +def _resolve_struct_field_type( + pt: ParameterType, + registry: Optional["TypeRegistry"] = None, + *, + ho_interface_id: Optional[int] = None, +) -> str: + """Resolve a struct field's ParameterType to a human-readable type name. + + Struct field type_ids use the HamiltonDataType wire namespace (e.g. 40=F32, + 23=BOOL) -- not the method-parameter introspection namespace. Complex + references (30=STRUCTURE, 32=ENUM) are resolved via the TypeRegistry when provided. + + Pass ``ho_interface_id`` as the owning struct's HOI interface id for local + (source_id=2) field references. + """ + if pt.is_complex and pt.source_id is not None and pt.ref_id is not None: + if registry is not None: + if pt.is_struct_ref: + s = registry.resolve_struct(pt.source_id, pt.ref_id, ho_interface_id=ho_interface_id) + if s: + return f"struct({s.name})" + elif pt.is_enum_ref: + e = registry.resolve_enum(pt.source_id, pt.ref_id, ho_interface_id=ho_interface_id) + if e: + return e.name + return f"ref(iface={pt.source_id}, id={pt.ref_id})" + return resolve_type_id(pt.type_id) # HamiltonDataType resolver + + +# ============================================================================ +# INTROSPECTION COMMAND CLASSES +# ============================================================================ + + +class GetObjectCommand(HamiltonCommand): + """Get object metadata (command_id=1).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 1 + action_code = 0 # QUERY + + def __init__(self, object_address: Address): + super().__init__(object_address) + + @dataclass(frozen=True) + class Response: + name: Str + version: Str + method_count: U32 + subobject_count: U16 + + +class GetMethodCommand(HamiltonCommand): + """Get method signature (command_id=2).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 2 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, method_index: int): + super().__init__(object_address) + self.method_index = method_index + + def build_parameters(self) -> HoiParams: + """Build parameters for get_method command.""" + return HoiParams().add(self.method_index, U32) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse get_method response.""" + parser = HoiParamsParser(data) + + _, interface_id = parser.parse_next() + _, call_type = parser.parse_next() + _, method_id = parser.parse_next() + _, name = parser.parse_next() + + # The remaining fragments are STRING types containing type IDs as bytes. + # Complex types (struct/enum refs) are 3-byte triples [type_id, source_id, ref_id]. + # Labels are comma-separated, one per *logical* parameter (matching ParameterType count). + parameter_labels_str = None + + if parser.has_remaining(): + # 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() + + all_labels: list[str] = [] + if parameter_labels_str: + all_labels = [label.strip() for label in parameter_labels_str.split(",") if label.strip()] + + parameter_types: list[ParameterType] = [] + parameter_labels: list[str] = [] + return_types: list[ParameterType] = [] + return_labels: list[str] = [] + + for i, pt in enumerate(all_types): + category = get_introspection_type_category(pt.type_id) + label = all_labels[i] if i < len(all_labels) else None + + if category == "Argument": + parameter_types.append(pt) + if label: + parameter_labels.append(label) + elif category in ("ReturnElement", "ReturnValue"): + return_types.append(pt) + if label: + return_labels.append(label) + else: + raise ValueError( + f"Unknown introspection type_id={pt.type_id} ({resolve_introspection_type_name(pt.type_id)}); " + "not in HoiObject mHoiParamTypes grid — update _HOI_PARAM_TYPE_GRID or add an override." + ) + + return { + "interface_id": interface_id, + "call_type": call_type, + "method_id": method_id, + "name": name, + "parameter_types": parameter_types, + "parameter_labels": parameter_labels, + "return_types": return_types, + "return_labels": return_labels, + } + + +class GetSubobjectAddressCommand(HamiltonCommand): + """Get subobject address (command_id=3).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 3 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, subobject_index: int): + super().__init__(object_address) + self.subobject_index = subobject_index + + def build_parameters(self) -> HoiParams: + """Build parameters for get_subobject_address command.""" + return HoiParams().add(self.subobject_index, U16) + + @dataclass(frozen=True) + class Response: + module_id: U16 + node_id: U16 + object_id: U16 + + +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 + command_id = 4 + action_code = 0 # QUERY + + def __init__(self, object_address: Address): + super().__init__(object_address) + + @dataclass(frozen=True) + class Response: + interface_ids: I8Array + interface_names: StrArray + + +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 + command_id = 5 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, target_interface_id: int): + super().__init__(object_address) + self.target_interface_id = target_interface_id + + def build_parameters(self) -> HoiParams: + """Build parameters for get_enums command.""" + return HoiParams().add(self.target_interface_id, U8) + + @dataclass(frozen=True) + class Response: + enum_names: StrArray + value_counts: U32Array + values: I32Array + value_names: StrArray + + +class GetStructsCommand(HamiltonCommand): + """Get struct definitions (command_id=6).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 6 + action_code = 0 # QUERY + + def __init__(self, object_address: Address, target_interface_id: int): + super().__init__(object_address) + self.target_interface_id = target_interface_id + + def build_parameters(self) -> HoiParams: + """Build parameters for get_structs command.""" + return HoiParams().add(self.target_interface_id, U8) + + @dataclass(frozen=True) + class Response: + """GetStructs returns 4 fragments: struct names, per-struct field counts, flat field type IDs, flat field names. + + 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. + """ + + struct_names: StrArray + field_counts: U32Array + field_type_ids: U8Array + field_names: StrArray + + +# ============================================================================ +# 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). + +GET_OBJECT = 1 +GET_METHOD = 2 +GET_SUBOBJECT_ADDRESS = 3 +GET_INTERFACES = 4 +GET_ENUMS = 5 +GET_STRUCTS = 6 + + +# ============================================================================ +# HIGH-LEVEL INTROSPECTION API +# ============================================================================ + + +class HamiltonIntrospection: + """High-level API for Hamilton introspection. + + 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. + """ + + def __init__(self, backend): + """Initialize introspection API. + + Args: + backend: TCPBackend instance + """ + self.backend = backend + + async def _resolve_target_address(self, addr_or_path: Union[Address, str]) -> Address: + """Resolve Address or dot-path through the backend resolver consistently.""" + if isinstance(addr_or_path, str): + return cast(Address, await self.backend.resolve_path(addr_or_path)) + return addr_or_path + + async def _build_firmware_tree(self) -> FirmwareTree: + """Build a DFS firmware tree from discovered root addresses.""" + roots = list(getattr(self.backend, "_discovered_objects", {}).get("root", [])) + if not roots: + registry = getattr(self.backend, "_registry", None) + if registry is not None and hasattr(registry, "get_root_addresses"): + roots = list(registry.get_root_addresses()) + + tree = FirmwareTree() + if not roots: + return tree + + visited: Set[Address] = set() + + async def walk(addr: Address, path: Optional[str] = None) -> Optional[FirmwareTreeNode]: + 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, + ) + + registry = getattr(self.backend, "_registry", None) + if registry is not None and hasattr(registry, "register"): + registry.register(path, obj) + + # Keep this guard even though Interface-0 method 3 (GetSubobjectAddress) + # appears ubiquitous in current PREP captures. If this remains stable + # across instruments/firmware, we can consider relaxing this check later. + if GET_SUBOBJECT_ADDRESS not in supported: + return node + + for i in range(obj.subobject_count): + try: + sub_addr = await self.get_subobject_address(addr, i) + sub_obj = await self.get_object(sub_addr) + obj.children[sub_obj.name] = sub_obj + child_path = f"{path}.{sub_obj.name}" + child = await walk(sub_addr, child_path) + 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 + + for addr in roots: + root_node = await walk(addr) + if root_node is not None: + tree.roots.append(root_node) + return tree + + async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTree: + """Return cached firmware tree, or build and cache it when missing.""" + if not refresh: + cached = getattr(self.backend, "_firmware_tree_cache", None) + if isinstance(cached, FirmwareTree): + return cached + + tree = await self._build_firmware_tree() + setattr(self.backend, "_firmware_tree_cache", tree) + return tree + + async def print_firmware_tree(self, refresh: bool = False) -> FirmwareTree: + """Print firmware tree text and return the tree object.""" + tree = await self.get_firmware_tree(refresh=refresh) + print(tree) + return 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 = getattr(self.backend, "get_supported_interface0_method_ids", None) + cache_store = getattr(self.backend, "_supported_interface0_method_ids", None) + has_capability_cache = isinstance(cache_store, dict) + if cached is not None and has_capability_cache: + return cast(Set[int], await cached(address)) + + obj = await self.get_object(address) + supported: Set[int] = set() + for i in range(obj.method_count): + try: + method = await self.get_method(address, i) + if method.interface_id == 0: + supported.add(method.method_id) + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.debug("get_method(%s, %d) failed: %s", address, i, e) + return supported + + async def get_object(self, address: Address) -> ObjectInfo: + """Get object metadata. + + Args: + address: Object address to query + + Returns: + Object metadata + """ + command = GetObjectCommand(address) + response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetObjectCommand returned None") + + return ObjectInfo( + name=response.name, + version=response.version, + method_count=int(response.method_count), + subobject_count=int(response.subobject_count), + address=address, + ) + + async def get_method(self, address: Address, method_index: int) -> MethodInfo: + """Get method signature. + + Args: + address: Object address + method_index: Method index to query + + Returns: + Method signature + """ + command = GetMethodCommand(address, method_index) + response = await self.backend.send_command(command, ensure_connection=False) + + return MethodInfo( + interface_id=response["interface_id"], + call_type=response["call_type"], + method_id=response["method_id"], + name=response["name"], + parameter_types=response.get("parameter_types", []), + parameter_labels=response.get("parameter_labels", []), + return_types=response.get("return_types", []), + return_labels=response.get("return_labels", []), + ) + + async def get_subobject_address(self, address: Address, subobject_index: int) -> Address: + """Get subobject address. + + Args: + address: Parent object address + subobject_index: Subobject index + + Returns: + Subobject address + """ + command = GetSubobjectAddressCommand(address, subobject_index) + response = await self.backend.send_command(command, ensure_connection=False) + 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, ensure_connection=False) + if response is None: + raise RuntimeError("GetInterfacesCommand returned None") + + ids = list(response.interface_ids) + names = list(response.interface_names) + return [ + InterfaceInfo( + interface_id=int(ids[i]), + name=names[i] if i < len(names) else f"Interface_{ids[i]}", + version="", + ) + for i in range(len(ids)) + ] + + 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 + + Returns: + List of enum definitions + """ + command = GetEnumsCommand(address, interface_id) + response = await self.backend.send_command(command, ensure_connection=False) + 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.backend.send_command(command, ensure_connection=False, return_raw=True) + (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 + + Returns: + List of struct definitions + """ + command = GetStructsCommand(address, interface_id) + response = await self.backend.send_command(command, ensure_connection=False) + 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; some entries are 3-byte triples + field_names = list(response.field_names) + n_structs = len(field_counts) + if n_structs == 0: + return [] + + # Walk type_bytes with a byte-level cursor (variable width: 1 byte for simple + # types, 3 bytes for 0xE8 complex references). field_counts gives the number + # of *logical* fields per struct, not the number of bytes to consume. + 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_type_seq(type_bytes[byte_offset:], _COMPLEX_STRUCT_TYPE_IDS) + # 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 get_all_methods( + self, + address: Address, + *, + _supported: Optional[Set[int]] = None, + _object_info: Optional[ObjectInfo] = None, + ) -> List[MethodInfo]: + """Get all methods for an object. + + Returns [] if the object does not support GetMethod (interface 0, method 2). + + Args: + address: Object address + _supported: Pre-computed supported Interface 0 method IDs (internal). + _object_info: Pre-fetched ObjectInfo (internal; avoids redundant GetObject). + + Returns: + List of all method signatures + """ + if _object_info is None: + _object_info = await self.get_object(address) + if _supported is None: + _supported = await self.get_supported_interface0_method_ids(address) + if GET_METHOD not in _supported: + logger.debug( + "Object at %s does not support GetMethod (interface 0, method 2); returning []", + address, + ) + return [] + + methods = [] + for i in range(_object_info.method_count): + try: + method = await self.get_method(address, i) + methods.append(method) + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.warning(f"Failed to get method {i} for {address}: {e}") + + return methods + + 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: + address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. + _supported: Pre-computed supported Interface 0 method IDs (internal; + avoids redundant device queries when the caller already has them). + + Returns: + TypeRegistry with all type information for this object + """ + address = await self._resolve_target_address(address) + 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 = [] + + if GET_METHOD in _supported: + registry.methods = await self.get_all_methods(address, _supported=_supported) + else: + registry.methods = [] + + for iface in interfaces: + if GET_STRUCTS in _supported: + structs = await self.get_structs(address, iface.interface_id) + if structs: + registry.structs[iface.interface_id] = {s.struct_id: s for s in structs} + if GET_ENUMS in _supported: + enums = await self.get_enums(address, iface.interface_id) + if enums: + registry.enums[iface.interface_id] = {e.enum_id: e for e in enums} + + return registry + + 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. + + 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 ParameterType.resolve_name() can find them. + + Args: + 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. + + Returns: + TypeRegistry that can resolve types from both parent and children. + """ + 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 + ) + + 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: + 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.debug("build_type_registry failed for child %s: %s", sub_addr, e) + + return registry + + async def build_global_type_pool( + self, + global_addresses: List[Address], + ) -> GlobalTypePool: + """Build the global type pool from global objects. + + This mirrors piglet's approach: walk each global object, iterate its + interfaces, and collect all structs/enums in sequential encounter order. + The resulting flat pool is used for source_id=1 lookups (1-based indexing). + + Args: + global_addresses: List of global object addresses + (from HamiltonTCPClient._global_object_addresses). + + Returns: + GlobalTypePool with all global structs and enums. + """ + 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) + for iface in interfaces: + if GET_STRUCTS in supported: + structs = await self.get_structs(addr, iface.interface_id) + pool.structs.extend(structs) + pool.interface_structs[iface.interface_id] = {s.struct_id: s for s in structs} + if GET_ENUMS in supported: + enums = await self.get_enums(addr, iface.interface_id) + pool.enums.extend(enums) + 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 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: + 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: + MethodInfo for the matching method, or None if not found. + """ + 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.get_all_methods(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: + """One-liner: return a fully resolved method signature string. + + Looks up the method and resolves struct/enum references using the + provided TypeRegistry (or falls back to unresolved names). + + Example:: + + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9, mph_registry) + print(sig) + # PickupTips(tipParameters: PickupTipParameters, finalZ: f32, ...) -> ... + + Returns: + Human-readable signature string, or a descriptive error string. + """ + address = await self._resolve_target_address(address) + 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) + + +# ============================================================================ +# STRUCT / COMMAND VALIDATION +# ============================================================================ + + +def _normalize_name(name: str) -> str: + """Normalize a name for comparison (remove underscores, make lowercase). + + Allows Pythonic `snake_case` (e.g. `z_liquid_exit_speed`) to match + Hamilton's arbitrary PascalCase (`ZLiquidExitSpeed` or `Zliquidexitspeed`). + """ + return name.replace("_", "").lower() + + +def _get_wire_type_id(annotation) -> Optional[int]: + """Extract HamiltonDataType type_id from an Annotated type alias. + + Works for all our wire types: F32, PaddedBool, U32, WEnum, Str, + Annotated[X, Struct()], Annotated[list[X], StructArray()], etc. + + Returns None if the annotation doesn't carry a WireType. + """ + # Handle typing.Annotated + metadata = getattr(annotation, "__metadata__", None) + if metadata: + for m in metadata: + if hasattr(m, "type_id"): + return cast(int, m.type_id) + return None + + +def _get_nested_dataclass(annotation): + """For Annotated[SomeDataclass, Struct()], return SomeDataclass. Else None.""" + args = getattr(annotation, "__args__", None) + if not args: + return None + base_type = args[0] + # For Annotated[list[X], StructArray()], dig into the list's inner type + inner_args = getattr(base_type, "__args__", None) + if inner_args: + base_type = inner_args[0] + import dataclasses + + if dataclasses.is_dataclass(base_type): + return base_type + return None + + +@dataclass +class FieldMismatch: + """One field-level mismatch between hand-crafted and introspected definitions.""" + + field_name: str + issue: str # e.g. "missing", "extra", "type mismatch", "order mismatch" + expected: str = "" + actual: str = "" + + def __str__(self): + s = f" {self.field_name}: {self.issue}" + if self.expected or self.actual: + s += f" (expected={self.expected}, actual={self.actual})" + return s + + +@dataclass +class ValidationResult: + """Result of comparing a hand-crafted dataclass against introspection.""" + + name: str + passed: bool = False + mismatches: List[FieldMismatch] = field(default_factory=list) + children: List["ValidationResult"] = field(default_factory=list) + + def __str__(self): + icon = "✅" if self.passed else "❌" + lines = [f"{icon} {self.name}"] + for m in self.mismatches: + lines.append(str(m)) + for child in self.children: + for line in str(child).split("\n"): + lines.append(f" {line}") + return "\n".join(lines) + + +def validate_struct( + dataclass_cls, + introspected: StructInfo, + pool: Optional[GlobalTypePool] = None, + registry: Optional["TypeRegistry"] = None, +) -> ValidationResult: + """Compare a hand-crafted dataclass against an introspected StructInfo. + + Checks field count, field names (snake_case → PascalCase), field types + (extracts type_id from Annotated metadata), and field order. For nested + structs (Annotated[X, Struct()]), recursively validates the child struct + when a GlobalTypePool and/or TypeRegistry can resolve the nested ref + (global, same-interface, or local source_id=2 with registry + interface id). + + Args: + dataclass_cls: The hand-crafted dataclass class (not an instance). + introspected: The introspected StructInfo from the device. + pool: Optional GlobalTypePool for nested global (1) and same-interface (0) refs. + registry: Optional TypeRegistry for nested local (source_id=2) refs. + + Returns: + ValidationResult with pass/fail and detailed mismatches. + """ + import dataclasses as dc + import typing + + result = ValidationResult(name=dataclass_cls.__name__) + mismatches = result.mismatches + + # Get hand-crafted fields + hints = typing.get_type_hints(dataclass_cls, include_extras=True) + hand_fields = list(dc.fields(dataclass_cls)) + hand_names = [f.name for f in hand_fields] + hand_norm = [_normalize_name(n) for n in hand_names] + + # Get introspected fields + intro_names = list(introspected.fields.keys()) + intro_norm = [_normalize_name(n) for n in intro_names] + intro_types = list(introspected.fields.values()) + + # 1. Field count + if len(hand_names) != len(intro_names): + mismatches.append( + FieldMismatch( + field_name="(count)", + issue="field count mismatch", + expected=str(len(intro_names)), + actual=str(len(hand_names)), + ) + ) + + # 2. Field names (order-aware) + for i, (hn_norm, in_norm) in enumerate(zip(hand_norm, intro_norm)): + if hn_norm != in_norm: + mismatches.append( + FieldMismatch( + field_name=hand_names[i], + issue=f"name mismatch at position {i}", + expected=intro_names[i], + actual=hand_names[i], + ) + ) + + # 3. Extra / missing fields + hand_set = set(hand_norm) + intro_set = set(intro_norm) + + # For error reporting, we want the original casing, so we build reverse maps + hand_map = {hn_norm: h for hn_norm, h in zip(hand_norm, hand_names)} + intro_map = {in_norm: i for in_norm, i in zip(intro_norm, intro_names)} + + for missing_norm in intro_set - hand_set: + original_intro = intro_map[missing_norm] + mismatches.append(FieldMismatch(field_name=original_intro, issue="missing in hand-crafted")) + for extra_norm in hand_set - intro_set: + original_hand = hand_map[extra_norm] + mismatches.append( + FieldMismatch(field_name=original_hand, issue="extra in hand-crafted (not in introspection)") + ) + + # 4. Field types (where names match) + for i, (hand_name, intro_name) in enumerate(zip(hand_names, intro_names)): + if _normalize_name(hand_name) != _normalize_name(intro_name): + continue # Already reported as name mismatch + annotation = hints.get(hand_name) + if annotation is None: + continue + hand_type_id = _get_wire_type_id(annotation) + intro_pt = intro_types[i] + if hand_type_id is not None and hand_type_id != intro_pt.type_id: + try: + from pylabrobot.hamilton.tcp.wire_types import HamiltonDataType + + expected_name = HamiltonDataType(intro_pt.type_id).name + actual_name = HamiltonDataType(hand_type_id).name + except ValueError: + expected_name = str(intro_pt.type_id) + actual_name = str(hand_type_id) + mismatches.append( + FieldMismatch( + field_name=hand_name, + issue="type mismatch", + expected=expected_name, + actual=actual_name, + ) + ) + + # 5. Recursive validation for nested structs + if ( + intro_pt.is_complex + and intro_pt.source_id is not None + and intro_pt.ref_id is not None + and intro_pt.type_id == 30 + ): # STRUCTURE + nested_cls = _get_nested_dataclass(annotation) + if nested_cls: + nested_struct: Optional[StructInfo] = None + if intro_pt.source_id == 1 and pool is not None: + nested_struct = pool.resolve_struct(intro_pt.ref_id) + elif intro_pt.source_id == 0 and pool is not None and introspected.interface_id is not None: + nested_struct = pool.resolve_struct_local(introspected.interface_id, intro_pt.ref_id) + elif ( + intro_pt.source_id == 2 and registry is not None and introspected.interface_id is not None + ): + nested_struct = registry.resolve_struct( + 2, intro_pt.ref_id, ho_interface_id=introspected.interface_id + ) + if nested_struct: + child_result = validate_struct(nested_cls, nested_struct, pool, registry=registry) + result.children.append(child_result) + + result.passed = len(mismatches) == 0 and all(c.passed for c in result.children) + return result + + +def validate_command( + command_cls, + registry: TypeRegistry, + pool: GlobalTypePool, + interface_id: int = 1, +) -> ValidationResult: + """Compare a PrepCommand against its introspected method signature. + + Matches the command's command_id to the introspected method_id on the given + interface. Validates that the command's struct parameters match the method's + expected struct types. + + Args: + command_cls: The PrepCommand subclass. + registry: TypeRegistry with the object's methods. + pool: GlobalTypePool for resolving struct refs. + interface_id: Interface ID to look up the method on (default 1 = Pipettor). + + Returns: + ValidationResult with pass/fail and details. + """ + import dataclasses as dc + import typing + + cmd_id = getattr(command_cls, "command_id", None) + result = ValidationResult(name=f"{command_cls.__name__} (cmd={cmd_id})") + + if cmd_id is None: + result.mismatches.append(FieldMismatch(field_name="(class)", issue="no command_id attribute")) + result.passed = False + return result + + # Find matching introspected method + method = registry.get_method(interface_id, cmd_id) + if method is None: + result.mismatches.append( + FieldMismatch( + field_name="(method)", issue=f"no introspected method for [{interface_id}:{cmd_id}]" + ) + ) + result.passed = False + return result + + result.name = f"{command_cls.__name__} ↔ {method.name} [{interface_id}:{cmd_id}]" + + # Get command's payload fields (exclude 'dest' and class-level attrs) + hints = typing.get_type_hints(command_cls, include_extras=True) + payload_fields = [f for f in dc.fields(command_cls) if f.name != "dest"] + + # Match struct payload fields to introspected parameter types positionally + struct_fields = [ + (pf, hints.get(pf.name)) + for pf in payload_fields + if _get_nested_dataclass(hints.get(pf.name)) is not None + ] + struct_params = [ + pt + for pt in method.parameter_types + if pt.is_complex and pt.source_id is not None and pt.ref_id is not None + ] + + for (pf, annotation), pt in zip(struct_fields, struct_params): + ref_id = pt.ref_id + assert ref_id is not None, "struct_params filtered for ref_id is not None" + if pt.source_id == 0: + # Same-interface ref: needs correct HOI interface id for GlobalTypePool.resolve_struct_local + # vs. registry — validate on hardware before wiring method-level validation here. + intro_struct = None + elif pt.source_id == 2: + intro_struct = registry.resolve_struct( + pt.source_id, ref_id, ho_interface_id=method.interface_id + ) + else: + src_id = pt.source_id + assert src_id is not None, "struct_params filtered for source_id is not None" + intro_struct = registry.resolve_struct(src_id, ref_id) + nested_cls = _get_nested_dataclass(annotation) + if intro_struct and nested_cls: + child_result = validate_struct(nested_cls, intro_struct, pool, registry=registry) + child_result.name = f"{pf.name} → {intro_struct.name} (ref={pt.ref_id})" + result.children.append(child_result) + + result.passed = all(c.passed for c in result.children) and len(result.mismatches) == 0 + return result diff --git a/pylabrobot/hamilton/tcp/messages.py b/pylabrobot/hamilton/tcp/messages.py new file mode 100644 index 00000000000..45dccb4d29f --- /dev/null +++ b/pylabrobot/hamilton/tcp/messages.py @@ -0,0 +1,1001 @@ +"""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). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from dataclasses import fields as dc_fields +from typing import Any, List, Optional, cast, get_args, get_origin, get_type_hints + +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.hamilton.tcp.packets import ( + Address, + HarpPacket, + HoiPacket, + IpPacket, + RegistrationPacket, +) +from pylabrobot.hamilton.tcp.protocol import ( + HarpTransportableProtocol, + Hoi2Action, + RegistrationOptionType, +) +from pylabrobot.hamilton.tcp.wire_types import ( + HamiltonDataType, + HcResultEntry, + decode_fragment, +) + +PADDED_FLAG = 0x01 + +logger = logging.getLogger(__name__) + +# ============================================================================ +# HOI PARAMETER ENCODING - DataFragment wrapping for HOI protocol +# ============================================================================ +# +# Note: This is conceptually a separate layer in the Hamilton protocol +# architecture, but implemented here for efficiency since it's exclusively +# used by HOI messages (CommandMessage). +# ============================================================================ + + +class HoiParams: + """Builder for HOI parameters with automatic DataFragment wrapping. + + Each parameter is wrapped with DataFragment header before being added: + [type_id:1][flags:1][length:2][data:n] + + This ensures HOI parameters are always correctly formatted and eliminates + the possibility of forgetting to add DataFragment headers. + + Example: + Creates concatenated DataFragments: + [0x03|0x00|0x04|0x00|100][0x0F|0x00|0x05|0x00|"test\0"][0x1C|0x00|...array...] + + params = (HoiParams() + .add(100, I32) + .add("test", Str) + .add([1, 2, 3], U32Array) + .build()) + """ + + def __init__(self): + self._fragments: list[bytes] = [] + + def _add_fragment(self, type_id: int, data: bytes, flags: int = 0) -> "HoiParams": + """Add a DataFragment with the given type_id and data. + + Creates: [type_id:1][flags:1][length:2][data:n] + + 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 (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 + + def add(self, value: Any, wire_type: Any) -> "HoiParams": + """Encode a value using its WireType and append the DataFragment. + + 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)) + + # ------------------------------------------------------------------ + # Generic dataclass serialiser (wire_types.py Annotated metadata) + # ------------------------------------------------------------------ + + @classmethod + def from_struct(cls, obj) -> "HoiParams": + """Serialize any dataclass whose fields use ``Annotated`` wire-type metadata. + + 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. + """ + 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.""" + return b"".join(self._fragments) + + def count(self) -> int: + """Return number of fragments (parameters).""" + return len(self._fragments) + + +class HoiParamsParser: + """Cursor over sequential DataFragments in an HOI payload. + + 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]: + 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 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)}" + ) + payload = self._data[self._offset + 4 : payload_end] + self._offset = payload_end + return type_id, flags, length, payload + + def has_remaining(self) -> bool: + return self._offset < len(self._data) + + def remaining(self) -> bytes: + """Unconsumed payload bytes (from current cursor to end).""" + return self._data[self._offset :] + + 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 + + +_ERROR_ENTRY_RE = None # lazy-compiled below + + +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. + """ + import re + + 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]+))?" + ) + + 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 _ERROR_ENTRY_RE.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_get_hc_results_string(text: str) -> list[HcResultEntry]: + """Parse the semicolon-separated warning string from ``HoiDecoder2.GetHcResults`` (fragment 1). + + Each segment is ``0xMMMM.0xMMMM.0xMMMM:0xII,0xAAAA,0xRRRR`` (HarpAddress.ToString + 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 + + +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_get_hc_results_string(v1) + elif isinstance(v1, (bytes, bytearray)): + prefix_entries = _parse_get_hc_results_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 +# ============================================================================ + + +class CommandMessage: + """Build HOI command messages for method calls. + + Creates complete IP[HARP[HOI]] packets with proper protocols and actions. + Parameters are automatically wrapped with DataFragment headers via HoiParams. + + Example: + msg = CommandMessage(dest, interface_id=0, method_id=42) + msg.add_i32(100).add_string("test") + packet_bytes = msg.build(src, seq=1) + """ + + def __init__( + self, + dest: Address, + interface_id: int, + method_id: int, + params: HoiParams, + action_code: int = 3, # Default: COMMAND_REQUEST + harp_protocol: int = 2, # Default: HOI2 + ip_protocol: int = 6, # Default: OBJECT_DISCOVERY + ): + """Initialize command message. + + Args: + dest: Destination object address + interface_id: Interface ID (typically 0 for main interface, 1 for extended) + method_id: Method/action ID to invoke + action_code: HOI action code (default 3=COMMAND_REQUEST) + harp_protocol: HARP protocol identifier (default 2=HOI2) + ip_protocol: IP protocol identifier (default 6=OBJECT_DISCOVERY) + """ + self.dest = dest + self.interface_id = interface_id + self.method_id = method_id + self.params = params + self.action_code = action_code + self.harp_protocol = harp_protocol + self.ip_protocol = ip_protocol + + def build( + self, + src: Address, + seq: int, + harp_response_required: bool = True, + hoi_response_required: bool = False, + ) -> bytes: + """Build complete IP[HARP[HOI]] packet. + + Args: + src: Source address (client address) + seq: Sequence number for this request + harp_response_required: Set bit 4 in HARP action byte (default True) + hoi_response_required: Set bit 4 in HOI action byte (default False) + + Returns: + Complete packet bytes ready to send over TCP + """ + # Build HOI - it handles its own action byte construction + hoi = HoiPacket( + interface_id=self.interface_id, + action_code=self.action_code, + action_id=self.method_id, + params=self.params.build(), + response_required=hoi_response_required, + ) + + # Build HARP - it handles its own action byte construction + harp = HarpPacket( + src=src, + dst=self.dest, + seq=seq, + protocol=self.harp_protocol, + action_code=self.action_code, + payload=hoi.pack(), + response_required=harp_response_required, + ) + + # Wrap in IP packet + ip = IpPacket(protocol=self.ip_protocol, payload=harp.pack()) + + return ip.pack() + + +class RegistrationMessage: + """Build Registration messages for object discovery. + + Creates complete IP[HARP[Registration]] packets for discovering modules, + objects, and capabilities on the Hamilton instrument. + + Example: + msg = RegistrationMessage(dest, action_code=12) + msg.add_registration_option(RegistrationOptionType.HARP_PROTOCOL_REQUEST, protocol=2, request_id=1) + packet_bytes = msg.build(src, req_addr, res_addr, seq=1) + """ + + def __init__( + self, + dest: Address, + action_code: int, + response_code: int = 0, # Default: no error + harp_protocol: int = 3, # Default: Registration + ip_protocol: int = 6, # Default: OBJECT_DISCOVERY + ): + """Initialize registration message. + + Args: + dest: Destination address (typically 0:0:65534 for registration service) + action_code: Registration action code (e.g., 12=HARP_PROTOCOL_REQUEST) + response_code: Response code (default 0=no error) + harp_protocol: HARP protocol identifier (default 3=Registration) + ip_protocol: IP protocol identifier (default 6=OBJECT_DISCOVERY) + """ + self.dest = dest + self.action_code = action_code + self.response_code = response_code + self.harp_protocol = harp_protocol + self.ip_protocol = ip_protocol + self.options = bytearray() + + def add_registration_option( + self, option_type: RegistrationOptionType, protocol: int = 2, request_id: int = 1 + ) -> "RegistrationMessage": + """Add a registration packet option. + + Args: + option_type: Type of registration option (from RegistrationOptionType enum) + protocol: For HARP_PROTOCOL_REQUEST: protocol type (2=HOI, default) + request_id: For HARP_PROTOCOL_REQUEST: what to discover (1=root, 2=global) + + Returns: + Self for method chaining + """ + # Registration option format: [option_id:1][length:1][data...] + # For HARP_PROTOCOL_REQUEST (option 5): data is [protocol:1][request_id:1] + data = Writer().u8(protocol).u8(request_id).finish() + option = Writer().u8(option_type).u8(len(data)).raw_bytes(data).finish() + self.options.extend(option) + return self + + def build( + self, + src: Address, + req_addr: Address, + res_addr: Address, + seq: int, + harp_action_code: int = 3, # Default: COMMAND_REQUEST + harp_response_required: bool = True, # Default: request with response + ) -> bytes: + """Build complete IP[HARP[Registration]] packet. + + Args: + src: Source address (client address) + req_addr: Request address (for registration context) + res_addr: Response address (for registration context) + seq: Sequence number for this request + harp_action_code: HARP action code (default 3=COMMAND_REQUEST) + harp_response_required: Whether response required (default True) + + Returns: + Complete packet bytes ready to send over TCP + """ + # Build Registration packet + reg = RegistrationPacket( + action_code=self.action_code, + response_code=self.response_code, + req_address=req_addr, + res_address=res_addr, + options=bytes(self.options), + ) + + # Wrap in HARP packet + harp = HarpPacket( + src=src, + dst=self.dest, + seq=seq, + protocol=self.harp_protocol, + action_code=harp_action_code, + payload=reg.pack(), + response_required=harp_response_required, + ) + + # Wrap in IP packet + ip = IpPacket(protocol=self.ip_protocol, payload=harp.pack()) + + return ip.pack() + + +class InitMessage: + """Build Connection initialization messages. + + Creates complete IP[Connection] packets for establishing a connection + with the Hamilton instrument. Uses Protocol 7 (INITIALIZATION) which + has a different structure than HARP-based messages. + + Example: + msg = InitMessage(timeout=30) + packet_bytes = msg.build() + """ + + def __init__( + self, + timeout: int = 30, + connection_type: int = 1, # Default: standard connection + protocol_version: int = 0x30, # Default: 3.0 + ip_protocol: int = 7, # Default: INITIALIZATION + ): + """Initialize connection message. + + Args: + timeout: Connection timeout in seconds (default 30) + connection_type: Connection type (default 1=standard) + protocol_version: Protocol version byte (default 0x30=3.0) + ip_protocol: IP protocol identifier (default 7=INITIALIZATION) + """ + self.timeout = timeout + self.connection_type = connection_type + self.protocol_version = protocol_version + self.ip_protocol = ip_protocol + + def build(self) -> bytes: + """Build complete IP[Connection] packet. + + Returns: + Complete packet bytes ready to send over TCP + """ + # Build raw connection parameters (NOT DataFragments) + # Frame: [version:1][message_id:1][count:1][unknown:1] + # Parameters: [id:1][type:1][reserved:2][value:2] repeated + params = ( + Writer() + # Frame + .u8(0) # version + .u8(0) # message_id + .u8(3) # count (3 parameters) + .u8(0) # unknown + # Parameter 1: connection_id (request allocation) + .u8(1) # param id + .u8(16) # param type + .u16(0) # reserved + .u16(0) # value (0 = request allocation) + # Parameter 2: connection_type + .u8(2) # param id + .u8(16) # param type + .u16(0) # reserved + .u16(self.connection_type) # value + # Parameter 3: timeout + .u8(4) # param id + .u8(16) # param type + .u16(0) # reserved + .u16(self.timeout) # value + .finish() + ) + + # Build IP packet + packet_size = 1 + 1 + 2 + len(params) # protocol + version + opts_len + params + + return ( + Writer() + .u16(packet_size) + .u8(self.ip_protocol) + .u8(self.protocol_version) + .u16(0) # options_length + .raw_bytes(params) + .finish() + ) + + +# ============================================================================ +# RESPONSE PARSERS - Paired with message builders above +# ============================================================================ + + +@dataclass +class InitResponse: + """Parsed initialization response. + + Pairs with InitMessage - parses Protocol 7 (INITIALIZATION) responses. + """ + + raw_bytes: bytes + client_id: int + connection_type: int + timeout: int + + @classmethod + def from_bytes(cls, data: bytes) -> "InitResponse": + """Parse initialization response. + + Args: + data: Raw bytes from TCP socket + + Returns: + Parsed InitResponse with connection parameters + """ + # Skip IP header (size + protocol + version + opts_len = 6 bytes) + parser = Reader(data[6:]) + + # Parse frame + _version = parser.u8() # Read but unused + _message_id = parser.u8() # Read but unused + _count = parser.u8() # Read but unused + _unknown = parser.u8() # Read but unused + + # Parse parameter 1 (client_id) + _param1_id = parser.u8() # Read but unused + _param1_type = parser.u8() # Read but unused + _param1_reserved = parser.u16() # Read but unused + client_id = parser.u16() + + # Parse parameter 2 (connection_type) + _param2_id = parser.u8() # Read but unused + _param2_type = parser.u8() # Read but unused + _param2_reserved = parser.u16() # Read but unused + connection_type = parser.u16() + + # Parse parameter 4 (timeout) + _param4_id = parser.u8() # Read but unused + _param4_type = parser.u8() # Read but unused + _param4_reserved = parser.u16() # Read but unused + timeout = parser.u16() + + return cls( + raw_bytes=data, client_id=client_id, connection_type=connection_type, timeout=timeout + ) + + +@dataclass +class RegistrationResponse: + """Parsed registration response. + + Pairs with RegistrationMessage - parses IP[HARP[Registration]] responses. + """ + + raw_bytes: bytes + ip: IpPacket + harp: HarpPacket + registration: RegistrationPacket + + @classmethod + def from_bytes(cls, data: bytes) -> "RegistrationResponse": + """Parse registration response. + + Args: + data: Raw bytes from TCP socket + + Returns: + Parsed RegistrationResponse with all layers + """ + ip = IpPacket.unpack(data) + harp = HarpPacket.unpack(ip.payload) + registration = RegistrationPacket.unpack(harp.payload) + + return cls(raw_bytes=data, ip=ip, harp=harp, registration=registration) + + @property + def sequence_number(self) -> int: + """Get sequence number from HARP layer.""" + return self.harp.seq + + +@dataclass +class CommandResponse: + """Parsed command response. + + Pairs with CommandMessage - parses IP[HARP[HOI]] responses. + """ + + raw_bytes: bytes + ip: IpPacket + harp: HarpPacket + hoi: HoiPacket + + @classmethod + def from_bytes(cls, data: bytes) -> "CommandResponse": + """Parse command response. + + Args: + data: Raw bytes from TCP socket + + Returns: + Parsed CommandResponse with all layers + + Raises: + ValueError: If response is not HOI protocol + """ + ip = IpPacket.unpack(data) + harp = HarpPacket.unpack(ip.payload) + + if harp.protocol != HarpTransportableProtocol.HOI2: + raise ValueError(f"Expected HOI2 protocol, got {harp.protocol}") + + hoi = HoiPacket.unpack(harp.payload) + + return cls(raw_bytes=data, ip=ip, harp=harp, hoi=hoi) + + @property + def sequence_number(self) -> int: + """Get sequence number from HARP layer.""" + return self.harp.seq diff --git a/pylabrobot/hamilton/tcp/packets.py b/pylabrobot/hamilton/tcp/packets.py new file mode 100644 index 00000000000..fb301cfbef6 --- /dev/null +++ b/pylabrobot/hamilton/tcp/packets.py @@ -0,0 +1,419 @@ +"""Hamilton TCP packet structures. + +This module defines the packet layer of the Hamilton protocol stack: +- IpPacket: Transport layer (size, protocol, version, payload) +- HarpPacket: Protocol layer (addressing, sequence, action, payload) +- HoiPacket: HOI application layer (interface_id, action_id, DataFragment params) +- RegistrationPacket: Registration protocol payload +- ConnectionPacket: Connection initialization payload + +Each packet knows how to pack/unpack itself using the Wire serialization layer. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +from pylabrobot.io.binary import Reader, Writer + +# Hamilton protocol version +HAMILTON_PROTOCOL_VERSION_MAJOR = 3 +HAMILTON_PROTOCOL_VERSION_MINOR = 0 + + +def encode_version_byte(major: int, minor: int) -> int: + """Pack Hamilton version byte (two 4-bit fields packed into one byte). + + Args: + major: Major version (0-15, stored in upper 4 bits) + minor: Minor version (0-15, stored in lower 4 bits) + """ + if not 0 <= major <= 15: + raise ValueError(f"major version must be 0-15, got {major}") + if not 0 <= minor <= 15: + raise ValueError(f"minor version must be 0-15, got {minor}") + version_byte = (minor & 0xF) | ((major & 0xF) << 4) + return version_byte + + +def decode_version_byte(version_bite: int) -> tuple[int, int]: + """Decode Hamilton version byte and return (major, minor). + + Returns: + Tuple of (major_version, minor_version), each 0-15 + """ + minor = version_bite & 0xF + major = (version_bite >> 4) & 0xF + return (major, minor) + + +@dataclass(frozen=True) +class Address: + """Hamilton network address (module_id, node_id, object_id).""" + + module: int # u16 + node: int # u16 + object: int # u16 + + def pack(self) -> bytes: + """Serialize address to 6 bytes.""" + return Writer().u16(self.module).u16(self.node).u16(self.object).finish() + + @classmethod + def unpack(cls, data: bytes) -> "Address": + """Deserialize address from bytes.""" + r = Reader(data) + return cls(module=r.u16(), node=r.u16(), object=r.u16()) + + def __str__(self) -> str: + return f"{self.module}:{self.node}:{self.object}" + + +@dataclass +class IpPacket: + """Hamilton IpPacket2 - Transport layer. + + Structure: + Bytes 00-01: size (2) + Bytes 02: protocol (1) + Bytes 03: version byte (major.minor) + Bytes 04-05: options_length (2) + Bytes 06+: options (x bytes) + Bytes: payload + """ + + protocol: int # Protocol identifier (6=OBJECT_DISCOVERY, 7=INITIALIZATION) + payload: bytes + options: bytes = b"" + + def pack(self) -> bytes: + """Serialize IP packet.""" + # Calculate size: protocol(1) + version(1) + opts_len(2) + options + payload + packet_size = 1 + 1 + 2 + len(self.options) + len(self.payload) + + return ( + Writer() + .u16(packet_size) + .u8(self.protocol) + .u8(encode_version_byte(HAMILTON_PROTOCOL_VERSION_MAJOR, HAMILTON_PROTOCOL_VERSION_MINOR)) + .u16(len(self.options)) + .raw_bytes(self.options) + .raw_bytes(self.payload) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "IpPacket": + """Deserialize IP packet.""" + r = Reader(data) + _size = r.u16() # Read but unused + protocol = r.u8() + major, minor = decode_version_byte(r.u8()) + + # Validate version + if major != HAMILTON_PROTOCOL_VERSION_MAJOR or minor != HAMILTON_PROTOCOL_VERSION_MINOR: + # Warning but not fatal + pass + + opts_len = r.u16() + options = r.raw_bytes(opts_len) if opts_len > 0 else b"" + payload = r.remaining() + + return cls(protocol=protocol, payload=payload, options=options) + + +@dataclass +class HarpPacket: + """Hamilton HarpPacket2 - Protocol layer. + + Structure: + Bytes 00-05: src address (module, node, object) + Bytes 06-11: dst address (module, node, object) + Byte 12: sequence number + Byte 13: reserved + Byte 14: protocol (2=HOI, 3=Registration) + Byte 15: action + Bytes 16-17: message length + Bytes 18-19: options length + Bytes 20+: options + Bytes: version byte (major.minor) + Byte: reserved2 + Bytes: payload + """ + + src: Address + dst: Address + seq: int + protocol: int # 2=HOI, 3=Registration + action_code: int # Base action code (0-15) + payload: bytes + options: bytes = b"" + response_required: bool = True # Controls bit 4 of action byte + + @property + def action(self) -> int: + """Compute action byte from action_code and response_required flag. + + Returns: + Action byte with bit 4 set if response required + """ + return self.action_code | (0x10 if self.response_required else 0x00) + + def pack(self) -> bytes: + """Serialize HARP packet.""" + # Message length includes: src(6) + dst(6) + seq(1) + reserved(1) + protocol(1) + + # action(1) + msg_len(2) + opts_len(2) + options + version(1) + reserved2(1) + payload + # = 20 (fixed header) + options + version + reserved2 + payload + msg_len = 20 + len(self.options) + 1 + 1 + len(self.payload) + + return ( + Writer() + .raw_bytes(self.src.pack()) + .raw_bytes(self.dst.pack()) + .u8(self.seq) + .u8(0) # reserved + .u8(self.protocol) + .u8(self.action) # Uses computed property + .u16(msg_len) + .u16(len(self.options)) + .raw_bytes(self.options) + .u8(0) # version byte - C# DLL uses 0, not 3.0 + .u8(0) # reserved2 + .raw_bytes(self.payload) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "HarpPacket": + """Deserialize HARP packet.""" + r = Reader(data) + + # Parse addresses + src = Address.unpack(r.raw_bytes(6)) + dst = Address.unpack(r.raw_bytes(6)) + + seq = r.u8() + _reserved = r.u8() # Read but unused + protocol = r.u8() + action_byte = r.u8() + _msg_len = r.u16() # Read but unused + opts_len = r.u16() + + options = r.raw_bytes(opts_len) if opts_len > 0 else b"" + _version = r.u8() # version byte (C# DLL uses 0) - Read but unused + _reserved2 = r.u8() # Read but unused + payload = r.remaining() + + # Decompose action byte into action_code and response_required flag + action_code = action_byte & 0x0F + response_required = bool(action_byte & 0x10) + + return cls( + src=src, + dst=dst, + seq=seq, + protocol=protocol, + action_code=action_code, + payload=payload, + options=options, + response_required=response_required, + ) + + +@dataclass +class HoiPacket: + """Hamilton HoiPacket2 - HOI application layer. + + Structure: + Byte 00: interface_id + Byte 01: action + Bytes 02-03: action_id + Byte 04: version byte (major.minor) + Byte 05: number of fragments + Bytes 06+: DataFragments + + Note: params must be DataFragment-wrapped (use HoiParams to build). + """ + + interface_id: int + action_code: int # Base action code (0-15) + action_id: int + params: bytes # Already DataFragment-wrapped via HoiParams + response_required: bool = False # Controls bit 4 of action byte + + @property + def action(self) -> int: + """Compute action byte from action_code and response_required flag. + + Returns: + Action byte with bit 4 set if response required + """ + return self.action_code | (0x10 if self.response_required else 0x00) + + def pack(self) -> bytes: + """Serialize HOI packet.""" + num_fragments = self._count_fragments(self.params) + + return ( + Writer() + .u8(self.interface_id) + .u8(self.action) # Uses computed property + .u16(self.action_id) + .u8(0) # version byte - always 0 for HOI packets (not 0x30!) + .u8(num_fragments) + .raw_bytes(self.params) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "HoiPacket": + """Deserialize HOI packet.""" + r = Reader(data) + + interface_id = r.u8() + action_byte = r.u8() + action_id = r.u16() + major, minor = decode_version_byte(r.u8()) + _num_fragments = r.u8() # Read but unused + params = r.remaining() + + # Decompose action byte into action_code and response_required flag + action_code = action_byte & 0x0F + response_required = bool(action_byte & 0x10) + + return cls( + interface_id=interface_id, + action_code=action_code, + action_id=action_id, + params=params, + response_required=response_required, + ) + + @staticmethod + def _count_fragments(data: bytes) -> int: + """Count DataFragments in params. + + Each DataFragment has format: [type_id:1][flags:1][length:2][data:n] + """ + if len(data) == 0: + return 0 + + count = 0 + offset = 0 + + while offset < len(data): + if offset + 4 > len(data): + break # Not enough bytes for a fragment header + + # Read fragment length + fragment_length = struct.unpack(" bytes: + """Serialize Registration packet.""" + return ( + Writer() + .u16(self.action_code) + .u16(self.response_code) + .u8(0) # version byte - DLL uses 0.0, not 3.0 + .u8(0) # reserved + .raw_bytes(self.req_address.pack()) + .raw_bytes(self.res_address.pack()) + .u16(len(self.options)) + .raw_bytes(self.options) + .finish() + ) + + @classmethod + def unpack(cls, data: bytes) -> "RegistrationPacket": + """Deserialize Registration packet.""" + r = Reader(data) + + action_code = r.u16() + response_code = r.u16() + _version = r.u8() # version byte (DLL uses 0, not packed 3.0) - Read but unused + _reserved = r.u8() # Read but unused + req_address = Address.unpack(r.raw_bytes(6)) + res_address = Address.unpack(r.raw_bytes(6)) + opts_len = r.u16() + options = r.raw_bytes(opts_len) if opts_len > 0 else b"" + + return cls( + action_code=action_code, + response_code=response_code, + req_address=req_address, + res_address=res_address, + options=options, + ) + + +@dataclass +class ConnectionPacket: + """Hamilton ConnectionPacket - Connection initialization payload. + + Used for Protocol 7 (INITIALIZATION). Has a different structure than + HARP-based packets - uses raw parameter encoding, NOT DataFragments. + + Structure: + Byte 00: version + Byte 01: message_id + Byte 02: count (number of parameters) + Byte 03: unknown + Bytes 04+: raw parameters [id|type|reserved|value] repeated + """ + + params: bytes # Raw format (NOT DataFragments) + + def pack_into_ip(self) -> bytes: + """Build complete IP packet for connection initialization. + + Returns full IP packet with protocol=7. + """ + # Connection packet size: just the params (frame is included in params) + packet_size = 1 + 1 + 2 + len(self.params) + + return ( + Writer() + .u16(packet_size) + .u8(7) # INITIALIZATION protocol + .u8(encode_version_byte(HAMILTON_PROTOCOL_VERSION_MAJOR, HAMILTON_PROTOCOL_VERSION_MINOR)) + .u16(0) # options_length + .raw_bytes(self.params) + .finish() + ) + + @classmethod + def unpack_from_ip_payload(cls, data: bytes) -> "ConnectionPacket": + """Extract ConnectionPacket from IP packet payload. + + Assumes IP header has already been parsed. + """ + return cls(params=data) diff --git a/pylabrobot/hamilton/tcp/protocol.py b/pylabrobot/hamilton/tcp/protocol.py new file mode 100644 index 00000000000..60b89b61dee --- /dev/null +++ b/pylabrobot/hamilton/tcp/protocol.py @@ -0,0 +1,136 @@ +"""Transport-level protocol constants only. + +HamiltonProtocol, Hoi2Action, HarpTransportableProtocol, RegistrationActionCode, +RegistrationOptionType, HoiRequestId. DataFragment type IDs (I8, I32, STRUCTURE, +etc.) are defined in wire_types.HamiltonDataType. +""" + +from __future__ import annotations + +from enum import IntEnum + +# Hamilton protocol version (from Piglet: version byte 0x30 = major 3, minor 0) +HAMILTON_PROTOCOL_VERSION_MAJOR = 3 +HAMILTON_PROTOCOL_VERSION_MINOR = 0 + + +class HamiltonProtocol(IntEnum): + """Hamilton protocol identifiers. + + These values are derived from the piglet Rust implementation: + - Protocol 2: PIPETTE - pipette-specific operations + - Protocol 3: REGISTRATION - object registration and discovery + - Protocol 6: OBJECT_DISCOVERY - general object discovery and method calls + - Protocol 7: INITIALIZATION - connection initialization and client ID negotiation + """ + + PIPETTE = 0x02 + REGISTRATION = 0x03 + OBJECT_DISCOVERY = 0x06 + INITIALIZATION = 0x07 + + +class Hoi2Action(IntEnum): + """HOI2/HARP2 action codes (bits 0-3 of action field). + + Values from Hamilton.Components.TransportLayer.Protocols.HoiPacket2Constants.Hoi2Action + + The action byte combines the action code (lower 4 bits) with the response_required flag (bit 4): + - action_byte = action_code | (0x10 if response_required else 0x00) + - Example: COMMAND_REQUEST with response = 3 | 0x10 = 0x13 + - Example: STATUS_REQUEST without response = 0 | 0x00 = 0x00 + + Common action codes: + - COMMAND_REQUEST (3): Send a command to an object (most common for method calls) + - STATUS_REQUEST (0): Request status information + - COMMAND_RESPONSE (4): Response to a command + - STATUS_RESPONSE (1): Response with status information + + NOTE: According to Hamilton documentation, both HARP2 and HOI2 use the same action + enumeration values. This needs verification through TCP introspection. + """ + + STATUS_REQUEST = 0 + STATUS_RESPONSE = 1 + STATUS_EXCEPTION = 2 + COMMAND_REQUEST = 3 + COMMAND_RESPONSE = 4 + COMMAND_EXCEPTION = 5 + COMMAND_ACK = 6 + UPSTREAM_SYSTEM_EVENT = 7 + DOWNSTREAM_SYSTEM_EVENT = 8 + EVENT = 9 + INVALID_ACTION_RESPONSE = 10 + STATUS_WARNING = 11 + COMMAND_WARNING = 12 + + +class HarpTransportableProtocol(IntEnum): + """HARP2 protocol field values - determines payload type. + + From Hamilton.Components.TransportLayer.Protocols.HarpTransportableProtocol. + The protocol field at byte 14 in HARP2 tells which payload parser to use. + """ + + HOI2 = 2 # Payload is Hoi2 structure (Protocol 2) + REGISTRATION2 = 3 # Payload is Registration2 structure (Protocol 3) + NOT_DEFINED = 0xFF # Invalid/unknown protocol + + +class RegistrationActionCode(IntEnum): + """Registration2 action codes (bytes 0-1 in Registration2 packet). + + From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.RegistrationActionCode2. + + Note: HARP action values for Registration packets are different from HOI action codes: + - 0x13 (19): Request with response required (typical for HARP_PROTOCOL_REQUEST) + - 0x14 (20): Response with data (typical for HARP_PROTOCOL_RESPONSE) + - 0x03 (3): Request without response + """ + + REGISTRATION_REQUEST = 0 # Initial registration handshake + REGISTRATION_RESPONSE = 1 # Response to registration + DEREGISTRATION_REQUEST = 2 # Cleanup on disconnect + DEREGISTRATION_RESPONSE = 3 # Deregistration acknowledgment + NODE_RESET_INDICATION = 4 # Node will reset + BRIDGE_REGISTRATION_REQUEST = 5 # Bridge registration + START_NODE_IDENTIFICATION = 6 # Start identification + START_NODE_IDENTIFICATION_RESPONSE = 7 + STOP_NODE_IDENTIFICATION = 8 # Stop identification + STOP_NODE_IDENTIFICATION_RESPONSE = 9 + LIST_OF_REGISTERED_MODULES_REQUEST = 10 # Request registered modules + LIST_OF_REGISTERED_MODULES_RESPONSE = 11 + HARP_PROTOCOL_REQUEST = 12 # Request objects (most important!) + HARP_PROTOCOL_RESPONSE = 13 # Response with object list + HARP_NODE_REMOVED_FROM_NETWORK = 14 + LIST_OF_REGISTERED_NODES_REQUEST = 15 + LIST_OF_REGISTERED_NODES_RESPONSE = 16 + + +class RegistrationOptionType(IntEnum): + """Registration2 option types (byte 0 of each option). + + From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.Option. + + These are semantic labels for the TYPE of information (what it means), while the + actual data inside uses Hamilton type_ids (how it's encoded). + """ + + RESERVED = 0 # Padding for 16-bit alignment when odd number of unsupported options + INCOMPATIBLE_VERSION = 1 # Version mismatch error (HARP version too high) + UNSUPPORTED_OPTIONS = 2 # Unknown options error + START_NODE_IDENTIFICATION = 3 # Identification timeout (seconds) + HARP_NETWORK_ADDRESS = 4 # Registered module/node IDs + HARP_PROTOCOL_REQUEST = 5 # Protocol request + HARP_PROTOCOL_RESPONSE = 6 # PRIMARY: Contains object ID lists (most commonly used) + + +class HoiRequestId(IntEnum): + """Request types for HarpProtocolRequest (byte 3 in command_data). + + From Hamilton.Components.TransportLayer.Protocols.RegistrationPacket2Constants.HarpProtocolRequest.HoiRequestId. + """ + + ROOT_OBJECT_OBJECT_ID = 1 # Request root objects (pipette, deck, etc.) + GLOBAL_OBJECT_ADDRESS = 2 # Request global objects + CPU_OBJECT_ADDRESS = 3 # Request CPU objects diff --git a/pylabrobot/hamilton/tcp/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py new file mode 100644 index 00000000000..7590be1c0ce --- /dev/null +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -0,0 +1,436 @@ +"""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 struct +import unittest +import asyncio +from dataclasses import dataclass +from typing import Annotated + +from pylabrobot.hamilton.tcp.client import HamiltonTCPClient +from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection, ObjectInfo, ObjectRegistry +from pylabrobot.hamilton.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParams, + HoiParamsParser, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, + parse_hamilton_error_entries, + parse_hamilton_error_entry, + 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, + Str, + StrArray, + U16, + Bool, + BoolArray, + CountedFlatArray, + HamiltonDataType, + HcResultEntry, + 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_command_return_raw_returns_hoi_payload_tuple(self): + class Cmd(HamiltonCommand): + 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): # type: ignore[override] + 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_command(Cmd(Address(1, 1, 257)), return_raw=True)) + 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): + class Backend: + def __init__(self): + self._registry = ObjectRegistry() + self._registry.set_root_addresses([Address(1, 1, 100)]) + self._discovered_objects = {"root": [Address(1, 1, 100)]} + + backend = Backend() + intro = HamiltonIntrospection(backend) + 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] + intro.get_supported_interface0_method_ids = fake_get_supported # type: ignore[method-assign] + intro.get_subobject_address = fake_get_subobject_address # type: ignore[method-assign] + + 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(len(t1.roots), 1) + self.assertEqual(t1.roots[0].path, "Root") + self.assertEqual(len(t1.roots[0].children), 1) + self.assertIn("Root.Child", str(t1)) + self.assertGreaterEqual(counts["obj"], 4) # built twice (initial + refresh) + self.assertGreaterEqual(counts["sub"], 2) + + +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 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 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(" 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) From 52c2a79d1dc8f9e6230b7be977f61f41612c9dbb Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:24:18 -0700 Subject: [PATCH 02/14] refactor tcp backend: centralize introspection ownership and simplify HOI mapping --- .../liquid_handlers/prep/prep_commands.py | 2322 +++++++++++++++++ pylabrobot/hamilton/tcp/__init__.py | 3 +- pylabrobot/hamilton/tcp/client.py | 414 ++- pylabrobot/hamilton/tcp/introspection.py | 1094 ++++---- pylabrobot/hamilton/tcp/tcp_tests.py | 295 ++- 5 files changed, 3419 insertions(+), 709 deletions(-) create mode 100644 pylabrobot/hamilton/liquid_handlers/prep/prep_commands.py 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..95794a9491f --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/prep/prep_commands.py @@ -0,0 +1,2322 @@ +"""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.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + F32, + I8, + I16, + U16, + U32, + EnumArray, + HcResultEntry, + I16Array, + PaddedBool, + PaddedU8, + Str, + Struct, + StructArray, + U8Array, + U32Array, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + Enum as WEnum, +) +from pylabrobot.liquid_handling.standard import SingleChannelAspiration + +# ============================================================================= +# 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: SingleChannelAspiration, + 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(HamiltonCommand): + """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 62589f6e83d..a54e78c36f0 100644 --- a/pylabrobot/hamilton/tcp/__init__.py +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -2,7 +2,7 @@ from pylabrobot.hamilton.tcp.client import HamiltonTCPClient from pylabrobot.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection, MethodInfo, ObjectInfo +from pylabrobot.hamilton.tcp.introspection import MethodInfo, ObjectInfo from pylabrobot.hamilton.tcp.messages import ( CommandMessage, CommandResponse, @@ -43,7 +43,6 @@ "HamiltonCommand", "HamiltonTCPClient", "HamiltonDataType", - "HamiltonIntrospection", "HamiltonProtocol", "HarpPacket", "HarpTransportableProtocol", diff --git a/pylabrobot/hamilton/tcp/client.py b/pylabrobot/hamilton/tcp/client.py index 4041e00080d..a3342f4c9d1 100644 --- a/pylabrobot/hamilton/tcp/client.py +++ b/pylabrobot/hamilton/tcp/client.py @@ -1,23 +1,36 @@ -"""Hamilton TCP client for TCP-based instruments (Nimbus, Prep, etc.).""" +"""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 (do not construct introspection classes +directly from application code). +""" from __future__ import annotations import asyncio import logging from dataclasses import dataclass -from typing import Any, Dict, Optional, Set, Union +from typing import Any, Callable, 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 HamiltonCommand +from pylabrobot.hamilton.tcp.commands import HamiltonCommand, hamilton_error_for_entry +from pylabrobot.hamilton.tcp.error_tables import HC_RESULT_PROTOCOL from pylabrobot.hamilton.tcp.messages import ( CommandResponse, InitMessage, InitResponse, RegistrationMessage, RegistrationResponse, + parse_hamilton_error_entries, + parse_hamilton_error_params, +) +from pylabrobot.hamilton.tcp.introspection import ( + HamiltonIntrospection, + MethodDescriptor, + ObjectRegistry, ) -from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection, ObjectRegistry from pylabrobot.hamilton.tcp.packets import Address from pylabrobot.hamilton.tcp.protocol import ( Hoi2Action, @@ -25,6 +38,7 @@ RegistrationActionCode, RegistrationOptionType, ) +from pylabrobot.hamilton.tcp.wire_types import HcResultEntry from pylabrobot.io.binary import Reader from pylabrobot.io.socket import Socket @@ -58,6 +72,67 @@ def parse_error(data: bytes) -> HamiltonError: ) +class _HcResultDescriptionHelper: + """Resolves ``HcResultEntry`` to display strings and optional method context. + + Thin adapter over :attr:`HamiltonTCPClient.introspection` for Interface-0 metadata lookups + used after static ``error_codes`` and :data:`HC_RESULT_PROTOCOL` tables. + """ + + def __init__(self, client: HamiltonTCPClient) -> None: + self._client = client + + def clear(self) -> None: + """No-op; introspection owns session caches.""" + return + + async def describe_entry(self, entry: HcResultEntry) -> Tuple[Optional[str], str]: + addr = Address(entry.module_id, entry.node_id, entry.object_id) + iface_name = await self._client.introspection.get_interface_name(addr, entry.interface_id) + + desc = self._client._error_codes.get( + (entry.module_id, entry.node_id, entry.object_id, entry.action_id, entry.result) + ) + if desc is None: + desc = HC_RESULT_PROTOCOL.get(entry.result) + if desc is None: + desc = await self._client.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]: + addr = Address(entry.module_id, entry.node_id, entry.object_id) + path = self._client._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._client.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 + + class HamiltonTCPClient(Driver): """Standalone transport + discovery/introspection client for Hamilton TCP devices.""" @@ -65,10 +140,12 @@ def __init__( self, host: str, port: int, - 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, + error_codes: Optional[Dict[Tuple[int, int, int, int, int], str]] = None, ): super().__init__() @@ -84,6 +161,7 @@ def __init__( 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 @@ -91,7 +169,65 @@ def __init__( self._discovered_objects: Dict[str, list[Address]] = {} self._instrument_addresses: Dict[str, Address] = {} self._registry = ObjectRegistry() - self._supported_interface0_method_ids: Dict[Address, Set[int]] = {} + self._global_object_addresses: list[Address] = [] + self._event_handlers: list[Callable[[CommandResponse], None]] = [] + self._error_codes: Dict[Tuple[int, int, int, int, int], str] = error_codes or {} + self._introspection_impl: Optional[HamiltonIntrospection] = None + self._hc_result_text = _HcResultDescriptionHelper(self) + + @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]: + """Roots from the registry, or from legacy ``_discovered_objects``.""" + roots = self._registry.get_root_addresses() + if roots: + return list(roots) + return list(self._discovered_objects.get("root", [])) + + @property + def introspection(self) -> HamiltonIntrospection: + """Lazy Interface-0 / type introspection facet (canonical entry).""" + if self._introspection_impl is None: + self._introspection_impl = HamiltonIntrospection(self) + return self._introspection_impl + + def _invalidate_introspection_session(self) -> None: + self._introspection_impl = 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._hc_result_text.clear() + self._global_object_addresses = [] + self._invalidate_introspection_session() async def _ensure_connected(self): if not self._connected: @@ -150,7 +286,7 @@ async def read(self, num_bytes: int = 128, timeout: Optional[float] = None) -> b try: data = await self.io.read(num_bytes, timeout=timeout) self._connected = True - return data + return cast(bytes, data) except (ConnectionError, OSError, TimeoutError): self._connected = False raise @@ -161,7 +297,7 @@ async def read_exact(self, num_bytes: int, timeout: Optional[float] = None) -> b try: data = await self.io.read_exact(num_bytes, timeout=timeout) self._connected = True - return data + return cast(bytes, data) except (ConnectionError, OSError, TimeoutError): self._connected = False raise @@ -170,11 +306,13 @@ async def read_exact(self, num_bytes: int, timeout: Optional[float] = None) -> b def is_connected(self) -> bool: return self._connected - async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse]: - size_data = await self.read_exact(2) + 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) + payload_data = await self.read_exact(packet_size, timeout=timeout) complete_data = size_data + payload_data ip_protocol = complete_data[2] @@ -186,7 +324,10 @@ async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse harp_protocol = complete_data[harp_protocol_offset] if harp_protocol == 2: - return CommandResponse.from_bytes(complete_data) + 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") @@ -196,19 +337,32 @@ async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse return CommandResponse.from_bytes(complete_data) async def setup(self, backend_params: Optional[BackendParams] = None): - del backend_params # reserved for capability-level startup params + 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() - logger.info(f"Hamilton TCP client setup complete. Client ID: {self._client_id}") + await self._discover_globals() + + root_addresses = self._registry.get_root_addresses() + if root_addresses: + root_info = await self.introspection.get_object(root_addresses[0]) + 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=30).build() + packet = InitMessage(timeout=self._connection_timeout).build() await self.write(packet) size_data = await self.read_exact(2) @@ -278,6 +432,36 @@ async def _discover_root(self): self._discovered_objects["root"] = root_objects self._registry.set_root_addresses(root_objects) + 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 @@ -313,40 +497,149 @@ def _allocate_sequence_number(self, dest_address: Address) -> int: async def send_command( self, command: HamiltonCommand, - timeout: float = 10.0, ensure_connection: bool = True, return_raw: bool = False, - ) -> Optional[Any]: - del ensure_connection # The client enforces connection checks internally. - 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() - await self.write(message) - - if timeout is None: - response_message = await self._read_one_message() - else: - response_message = await asyncio.wait_for(self._read_one_message(), timeout) - assert isinstance(response_message, CommandResponse) - - action = Hoi2Action(response_message.hoi.action_code) - if action in ( - Hoi2Action.STATUS_EXCEPTION, - Hoi2Action.COMMAND_EXCEPTION, - Hoi2Action.INVALID_ACTION_RESPONSE, - ): - error_message = f"Error response (action={action:#x}): {response_message.hoi.params.hex()}" - logger.error(f"Hamilton error {action}: {error_message}") - raise RuntimeError(f"Hamilton error {action}: {error_message}") - - if return_raw: - return (response_message.hoi.params,) - - return command.interpret_response(response_message) + raise_on_error: bool = True, + 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 + + per_channel: Dict[int, Exception] = {} + context_by_channel: Dict[int, Optional[str]] = {} + for idx, entry in enumerate(entries): + _iface_name, desc = await self._hc_result_text.describe_entry(entry) + err = hamilton_error_for_entry(entry, desc) + 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._hc_result_text.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) + 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._hc_result_text.describe_entry(e) + fatal_per_channel[ch] = hamilton_error_for_entry(e, desc) + fatal_context_by_channel[ch] = await self._hc_result_text.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 strict dot-path target to Address.""" @@ -363,32 +656,9 @@ async def resolve_target( resolved = aliases.get(target, target) if aliases is not None else target return await self.resolve_path(resolved) - async def get_supported_interface0_method_ids(self, address: Address) -> Set[int]: - """Return cached supported Interface-0 methods for an address.""" - if address in self._supported_interface0_method_ids: - return set(self._supported_interface0_method_ids[address]) - - introspection = HamiltonIntrospection(self) - obj = await introspection.get_object(address) - supported: Set[int] = set() - for i in range(obj.method_count): - try: - method = await introspection.get_method(address, i) - except Exception as e: - logger.debug("get_method(%s, %d) failed: %s", address, i, e) - continue - if method.interface_id == 0: - supported.add(method.method_id) - self._supported_interface0_method_ids[address] = supported - return set(supported) - async def get_firmware_tree(self, refresh: bool = False): """Return cached firmware tree, or build it through introspection.""" - return await HamiltonIntrospection(self).get_firmware_tree(refresh=refresh) - - async def print_firmware_tree(self, refresh: bool = False): - """Print firmware tree text and return the tree object.""" - return await HamiltonIntrospection(self).print_firmware_tree(refresh=refresh) + return await self.introspection.get_firmware_tree(refresh=refresh) async def stop(self): try: @@ -397,5 +667,5 @@ async def stop(self): 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/introspection.py b/pylabrobot/hamilton/tcp/introspection.py index a11a2544313..351756a3780 100644 --- a/pylabrobot/hamilton/tcp/introspection.py +++ b/pylabrobot/hamilton/tcp/introspection.py @@ -1,27 +1,45 @@ """Hamilton TCP Introspection API. -Wraps HamiltonTCPClient to provide dynamic discovery of instrument capabilities -via Interface 0 methods (GetObject, GetMethod, GetStructs, GetEnums, -GetInterfaces, GetSubobjectAddress). - -Canonical usage:: - - intro = HamiltonIntrospection(client) # standalone - intro = HamiltonIntrospection(lh.backend.client) # from LiquidHandler - - # Build a cached registry for one object (uses InterfaceDescriptors): - registry = await intro.build_type_registry("MLPrepRoot.MphRoot.MPH") - registry.print_summary() - - # Resolve a method signature: - sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9, registry) +Wraps a session backend (:class:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient`) +to provide dynamic discovery via Interface 0 methods (GetObject, GetMethod, +GetStructs, GetEnums, GetInterfaces, GetSubobjectAddress). + +**Canonical usage:** use :attr:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient.introspection` +(do not construct :class:`HamiltonIntrospection` 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, Literal, Optional, Set, TypeVar, Union, cast +from typing import Any, Dict, List, Literal, Optional, Protocol, Sequence, Set, Tuple, Union, cast from pylabrobot.hamilton.tcp.commands import HamiltonCommand from pylabrobot.hamilton.tcp.messages import ( @@ -47,6 +65,49 @@ logger = logging.getLogger(__name__) + +class HamiltonTCPIntrospectionBackend(Protocol): + """Structural type for objects passed to :class:`HamiltonIntrospection`. + + **Production:** :class:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient` implements this + Protocol (transport, registry, session caches, ``send_command``). + + **Tests:** provide a minimal object with the same methods/properties so introspection can be + exercised without a socket—see ``tcp_tests`` (e.g. fake ``Backend`` with registry roots and + patched ``HamiltonIntrospection`` methods). This is a typing contract only; there is no separate + runtime "backend" class besides the client. + """ + + @property + def registry(self) -> Any: ... + + def get_root_object_addresses(self) -> list[Address]: ... + + @property + def global_object_addresses(self) -> Sequence[Address]: ... + + async def send_command( + self, + command: HamiltonCommand, + *, + ensure_connection: bool = True, + return_raw: bool = False, + raise_on_error: bool = True, + read_timeout: Optional[float] = None, + ) -> Any: ... + + async def resolve_path(self, path: str) -> Address: ... + + +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 @@ -81,7 +142,7 @@ 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}" @@ -93,110 +154,63 @@ def resolve_type_id(type_id: int) -> str: # Rows = firmware scalar or array kinds; columns = In, Out, InOut, RetVal # (HoiParameterType.Direction). Source: vendor protocol reference mHoiParamTypes[31,4]. -_HOI_DOTNET_TYPE_ROWS: tuple[str, ...] = ( - "i8", - "i16", - "i32", - "u8", - "u16", - "u32", - "str", - "bool", - "i8[]", - "i16[]", - "i32[]", - "u8[]", - "u16[]", - "u32[]", - "bool[]", - "HcResult", - "struct", - "struct[]", - "str[]", - "enum", - "enum[]", - "i64", - "u64", - "f32", - "f64", - "i64[]", - "u64[]", - "f32[]", - "f64[]", - "HoiResult", - "padding", -) -_HOI_PARAM_DIRECTION: tuple[str, ...] = ("In", "Out", "InOut", "RetVal") +@dataclass(frozen=True) +class _HoiTypeRow: + """One row in vendor mHoiParamTypes[31,4] with readable display metadata.""" + + dotnet_name: str + display_name: str + ids: tuple[int, int, int, int] # [In, Out, InOut, RetVal] + + +_HOI_TYPE_ROWS: tuple[_HoiTypeRow, ...] = ( + _HoiTypeRow("i8", "i8", (1, 17, 9, 25)), + _HoiTypeRow("i16", "i16", (3, 19, 11, 27)), + _HoiTypeRow("i32", "i32", (5, 21, 13, 29)), + _HoiTypeRow("u8", "u8", (2, 18, 10, 26)), + _HoiTypeRow("u16", "u16", (4, 20, 12, 28)), + _HoiTypeRow("u32", "u32", (6, 22, 14, 30)), + _HoiTypeRow("str", "str", (7, 23, 15, 31)), + _HoiTypeRow("bool", "bool", (33, 35, 34, 36)), + _HoiTypeRow("i8[]", "List[i8]", (37, 39, 38, 40)), + _HoiTypeRow("i16[]", "List[i16]", (41, 43, 42, 44)), + _HoiTypeRow("i32[]", "List[i32]", (49, 51, 50, 52)), + _HoiTypeRow("u8[]", "bytes", (8, 24, 16, 32)), + _HoiTypeRow("u16[]", "List[u16]", (45, 47, 46, 48)), + _HoiTypeRow("u32[]", "List[u32]", (53, 55, 54, 56)), + _HoiTypeRow("bool[]", "List[bool]", (66, 68, 67, 69)), + _HoiTypeRow("HcResult", "HcResult", (70, 72, 71, 73)), + _HoiTypeRow("struct", "struct", (57, 59, 58, 60)), + _HoiTypeRow("struct[]", "List[struct]", (61, 63, 62, 64)), + _HoiTypeRow("str[]", "List[str]", (74, 76, 75, 77)), + _HoiTypeRow("enum", "enum", (78, 80, 79, 81)), + _HoiTypeRow("enum[]", "List[enum]", (82, 84, 83, 85)), + _HoiTypeRow("i64", "i64", (86, 88, 87, 89)), + _HoiTypeRow("u64", "u64", (90, 92, 91, 93)), + _HoiTypeRow("f32", "f32", (94, 96, 95, 97)), + _HoiTypeRow("f64", "f64", (98, 100, 99, 101)), + _HoiTypeRow("i64[]", "List[i64]", (102, 104, 103, 105)), + _HoiTypeRow("u64[]", "List[u64]", (106, 108, 107, 109)), + _HoiTypeRow("f32[]", "List[f32]", (110, 112, 111, 113)), + _HoiTypeRow("f64[]", "List[f64]", (114, 116, 115, 117)), + _HoiTypeRow("HoiResult", "HoiResult", (118, 120, 119, 121)), + _HoiTypeRow("padding", "padding", (0, 0, 0, 0)), +) -# type_ids per row [In, Out, InOut, RetVal] — ord() of each C# string cell (row 30 unused). -_HOI_PARAM_TYPE_GRID: tuple[tuple[int, int, int, int], ...] = ( - (1, 17, 9, 25), - (3, 19, 11, 27), - (5, 21, 13, 29), - (2, 18, 10, 26), - (4, 20, 12, 28), - (6, 22, 14, 30), - (7, 23, 15, 31), - (33, 35, 34, 36), - (37, 39, 38, 40), - (41, 43, 42, 44), - (49, 51, 50, 52), - (8, 24, 16, 32), - (45, 47, 46, 48), - (53, 55, 54, 56), - (66, 68, 67, 69), - (70, 72, 71, 73), - (57, 59, 58, 60), - (61, 63, 62, 64), - (74, 76, 75, 77), - (78, 80, 79, 81), - (82, 84, 83, 85), - (86, 88, 87, 89), - (90, 92, 91, 93), - (94, 96, 95, 97), - (98, 100, 99, 101), - (102, 104, 103, 105), - (106, 108, 107, 109), - (110, 112, 111, 113), - (114, 116, 115, 117), - (118, 120, 119, 121), - (0, 0, 0, 0), +_COMPLEX_METHOD_ROW_NAMES = frozenset( + { + "HcResult", + "struct", + "struct[]", + "str[]", + "enum", + "enum[]", + "HoiResult", + } ) -_ROW_DISPLAY: dict[str, str] = { - "i8": "i8", - "i16": "i16", - "i32": "i32", - "u8": "u8", - "u16": "u16", - "u32": "u32", - "str": "str", - "bool": "bool", - "i8[]": "List[i8]", - "i16[]": "List[i16]", - "i32[]": "List[i32]", - "u8[]": "bytes", - "u16[]": "List[u16]", - "u32[]": "List[u32]", - "bool[]": "List[bool]", - "HcResult": "HcResult", - "struct": "struct", - "struct[]": "List[struct]", - "str[]": "List[str]", - "enum": "enum", - "enum[]": "List[enum]", - "i64": "i64", - "u64": "u64", - "f32": "f32", - "f64": "f64", - "i64[]": "List[i64]", - "u64[]": "List[u64]", - "f32[]": "List[f32]", - "f64[]": "List[f64]", - "HoiResult": "HoiResult", - "padding": "padding", -} +_HOI_PARAM_DIRECTION: tuple[str, ...] = ("In", "Out", "InOut", "RetVal") def _build_introspection_maps() -> tuple[dict[int, str], set[int], set[int], set[int], set[int]]: @@ -205,15 +219,12 @@ def _build_introspection_maps() -> tuple[dict[int, str], set[int], set[int], set ret_el_ids: set[int] = set() ret_val_ids: set[int] = set() complex_method_ids: set[int] = set() - complex_rows = frozenset({15, 16, 17, 18, 19, 20, 29}) - - for ri, row in enumerate(_HOI_PARAM_TYPE_GRID): - base_key = _HOI_DOTNET_TYPE_ROWS[ri] - for ci, tid in enumerate(row): + for row in _HOI_TYPE_ROWS: + for ci, tid in enumerate(row.ids): if tid == 0: continue d = _HOI_PARAM_DIRECTION[ci] - disp = _ROW_DISPLAY.get(base_key, base_key) + disp = row.display_name names[tid] = f"{disp} [{d}]" if ci in (0, 2): arg_ids.add(tid) @@ -221,7 +232,7 @@ def _build_introspection_maps() -> tuple[dict[int, str], set[int], set[int], set ret_el_ids.add(tid) elif ci == 3: ret_val_ids.add(tid) - if ri in complex_rows: + if row.dotnet_name in _COMPLEX_METHOD_ROW_NAMES: complex_method_ids.add(tid) return names, arg_ids, ret_el_ids, ret_val_ids, complex_method_ids @@ -235,13 +246,15 @@ def _build_introspection_maps() -> tuple[dict[int, str], set[int], set[int], set _COMPLEX_METHOD_TYPE_IDS, ) = _build_introspection_maps() -# Empirical / device-specific id observed in the wild (not in HoiObject 31×4 grid). +# Empirical device behavior: type_id=113 appears as Argument on some firmware, +# despite the static grid column implying RetVal. _INTROSPECTION_TYPE_NAMES[113] = "List[f32] [In] (empirical)" _ARGUMENT_TYPE_IDS.add(113) _COMPLEX_STRUCT_TYPE_IDS = {30, 31, 32, 35} # STRUCTURE=30, STRUCT_ARRAY=31, ENUM=32, ENUM_ARRAY=35 -# Backward-compat alias (used by ParameterType.is_complex for method parameters) -_COMPLEX_TYPE_IDS = _COMPLEX_METHOD_TYPE_IDS +_STRUCT_REF_TYPE_IDS = frozenset({30, 31, 57, 60, 61, 63, 64}) +_ENUM_REF_TYPE_IDS = frozenset({32, 35, 78, 81, 82, 85}) +_ALL_COMPLEX_TYPE_IDS = frozenset(_COMPLEX_METHOD_TYPE_IDS | _COMPLEX_STRUCT_TYPE_IDS) def get_introspection_type_category(type_id: int) -> str: @@ -325,7 +338,10 @@ async def resolve(self, path: str, transport: Any) -> Address: parent_path = ".".join(parts[:-1]) child_name = parts[-1] - introspection = HamiltonIntrospection(transport) + introspection_obj = getattr(transport, "introspection", None) + if introspection_obj is None: + raise TypeError("ObjectRegistry.resolve requires transport.introspection") + introspection = cast("HamiltonIntrospection", introspection_obj) if not parent_path: if not self._root_addresses: raise KeyError("No root addresses; run discovery first") @@ -339,7 +355,7 @@ async def resolve(self, path: str, transport: Any) -> Address: parent_addr = await self.resolve(parent_path, transport) parent_info = self._objects[parent_path] - supported = await transport.get_supported_interface0_method_ids(parent_addr) + supported = await introspection.get_supported_interface0_method_ids(parent_addr) if GET_SUBOBJECT_ADDRESS not in supported: raise KeyError( f"Object at path '{parent_path}' does not support GetSubobjectAddress " @@ -347,8 +363,7 @@ async def resolve(self, path: str, transport: Any) -> Address: ) for i in range(parent_info.subobject_count): - sub_addr = await introspection.get_subobject_address(parent_addr, i) - sub_info = await introspection.get_object(sub_addr) + sub_addr, sub_info = await _subobject_address_and_info(introspection, parent_addr, i) sub_info.children = {} child_path = f"{parent_path}.{sub_info.name}" parent_info.children[sub_info.name] = sub_info @@ -369,7 +384,9 @@ class FirmwareTreeNode: 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]: + 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} @@ -435,17 +452,17 @@ class ParameterType: @property def is_complex(self) -> bool: """True if this is a 3-byte complex reference (method param or struct field).""" - return self.type_id in (_COMPLEX_METHOD_TYPE_IDS | _COMPLEX_STRUCT_TYPE_IDS) + return self.type_id in _ALL_COMPLEX_TYPE_IDS @property def is_struct_ref(self) -> bool: """True if this is a struct reference (type 30 in struct context, 57/61 in method context).""" - return self.type_id in {30, 31, 57, 60, 61, 63, 64} + return self.type_id in _STRUCT_REF_TYPE_IDS @property def is_enum_ref(self) -> bool: """True if this is an enum reference (type 32 in struct context, 78/81/82/85 in method).""" - return self.type_id in {32, 35, 78, 81, 82, 85} + return self.type_id in _ENUM_REF_TYPE_IDS def resolve_name( self, @@ -456,7 +473,7 @@ def resolve_name( For source_id=2 (local) refs, pass ``ho_interface_id`` (the HOI interface id of the method or struct owning this type) so resolution uses that interface's - table only. If omitted, registry falls back to multi-interface heuristics. + table only. """ base = resolve_introspection_type_name(self.type_id) if not self.is_complex or self.source_id is None or self.ref_id is None: @@ -674,35 +691,6 @@ def to_dict(self, registry: Optional["TypeRegistry"] = None) -> dict: return self.describe(registry).to_dict() -_TLocal = TypeVar("_TLocal") - - -def _lookup_local_table_entry( - tables: Dict[int, Dict[int, _TLocal]], - ref_id: int, -) -> Optional[_TLocal]: - """Resolve source_id=2 (local) refs when HOI interface id is unknown. - - ref_id is 1-based; struct/enum_id in tables is 0-based. Tables are keyed by HOI - interface id from InterfaceDescriptors (e.g. 1 for API, 0 for introspection), not - by the literal ``2`` in the wire triple. Prefers interface 1 when present, then - scans other non-zero keys — unsafe if two interfaces reuse the same local index - with different meanings. Prefer ``TypeRegistry.resolve_*(..., ho_interface_id=…)``. - """ - idx = ref_id - 1 - if idx < 0: - return None - if 1 in tables: - hit = tables[1].get(idx) - if hit is not None: - return hit - for iid in sorted(k for k in tables if k != 0 and k != 1): - hit = tables[iid].get(idx) - if hit is not None: - return hit - return None - - @dataclass class TypeRegistry: """Resolved type information for one object. @@ -727,14 +715,17 @@ class TypeRegistry: vs. this registry should be validated on hardware — do not assume the same 1-based rule as source_id=2 locals. - For source_id=2, pass ``ho_interface_id`` on ``resolve_struct`` / ``resolve_enum`` whenever - the owning method or struct's interface is known (strict table lookup). Omitting it uses - a legacy multi-interface fallback that may be ambiguous if two interfaces share a local index. + 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):: - Example: 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 @@ -755,9 +746,8 @@ def resolve_struct( 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]``). Pass ``ho_interface_id`` for strict, - interface-scoped resolution; if omitted, uses multi-interface fallback - (see _lookup_local_table_entry). + ``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) @@ -765,9 +755,9 @@ def resolve_struct( idx = ref_id - 1 if idx < 0: return None - if ho_interface_id is not None: - return self.structs.get(ho_interface_id, {}).get(idx) - return _lookup_local_table_entry(self.structs, ref_id) + 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) @@ -783,8 +773,8 @@ def resolve_enum( """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). Pass ``ho_interface_id`` - for strict resolution; if omitted, multi-interface fallback applies. + 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) @@ -792,9 +782,9 @@ def resolve_enum( idx = ref_id - 1 if idx < 0: return None - if ho_interface_id is not None: - return self.enums.get(ho_interface_id, {}).get(idx) - return _lookup_local_table_entry(self.enums, ref_id) + 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]: @@ -1119,7 +1109,7 @@ def parse_response_parameters(cls, data: bytes) -> dict: else: raise ValueError( f"Unknown introspection type_id={pt.type_id} ({resolve_introspection_type_name(pt.type_id)}); " - "not in HoiObject mHoiParamTypes grid — update _HOI_PARAM_TYPE_GRID or add an override." + "not in HoiObject mHoiParamTypes grid — update _HOI_TYPE_ROWS or add an override." ) return { @@ -1267,15 +1257,264 @@ class HamiltonIntrospection: 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. + + Prefer :attr:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient.introspection` + over constructing this class directly. """ - def __init__(self, backend): + def __init__(self, backend: HamiltonTCPIntrospectionBackend): """Initialize introspection API. Args: - backend: TCPBackend instance + backend: Session implementing :class:`HamiltonTCPIntrospectionBackend` """ self.backend = backend + # Session caches (invalidated when the client drops the introspection facet, e.g. reconnect). + self._method_table_by_address: Dict[Address, List[MethodInfo]] = {} + self._structs_by_addr_iface: Dict[Tuple[Address, int], Dict[int, StructInfo]] = {} + self._enums_by_addr_iface: Dict[Tuple[Address, int], Dict[int, EnumInfo]] = {} + self._iface_types_loaded: Set[Tuple[Address, int]] = set() + 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[FirmwareTree] = 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._structs_by_addr_iface.clear() + self._enums_by_addr_iface.clear() + self._iface_types_loaded.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*.""" + key = (addr, iface_id) + if key in self._structs_by_addr_iface: + registry.structs[iface_id] = dict(self._structs_by_addr_iface[key]) + if key in self._enums_by_addr_iface: + registry.enums[iface_id] = dict(self._enums_by_addr_iface[key]) + + 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[ParameterType], ho_iface: int) -> None: + for pt in types: + if not pt.is_complex or 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) + for iface in interfaces: + if GET_STRUCTS in supported: + structs = await self.get_structs(addr, iface.interface_id) + pool.structs.extend(structs) + pool.interface_structs[iface.interface_id] = {s.struct_id: s for s in structs} + if GET_ENUMS in supported: + enums = await self.get_enums(addr, iface.interface_id) + pool.enums.extend(enums) + 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_loaded: + 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._structs_by_addr_iface[key] = structs_map + self._enums_by_addr_iface[key] = 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] = {} + self._iface_types_loaded.add(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_loaded: + 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.backend.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 through the backend resolver consistently.""" @@ -1285,12 +1524,7 @@ async def _resolve_target_address(self, addr_or_path: Union[Address, str]) -> Ad async def _build_firmware_tree(self) -> FirmwareTree: """Build a DFS firmware tree from discovered root addresses.""" - roots = list(getattr(self.backend, "_discovered_objects", {}).get("root", [])) - if not roots: - registry = getattr(self.backend, "_registry", None) - if registry is not None and hasattr(registry, "get_root_addresses"): - roots = list(registry.get_root_addresses()) - + roots = self.backend.get_root_object_addresses() tree = FirmwareTree() if not roots: return tree @@ -1313,9 +1547,7 @@ async def walk(addr: Address, path: Optional[str] = None) -> Optional[FirmwareTr supported_interface0_methods=supported, ) - registry = getattr(self.backend, "_registry", None) - if registry is not None and hasattr(registry, "register"): - registry.register(path, obj) + self.backend.registry.register(path, obj) # Keep this guard even though Interface-0 method 3 (GetSubobjectAddress) # appears ubiquitous in current PREP captures. If this remains stable @@ -1325,8 +1557,7 @@ async def walk(addr: Address, path: Optional[str] = None) -> Optional[FirmwareTr for i in range(obj.subobject_count): try: - sub_addr = await self.get_subobject_address(addr, i) - sub_obj = await self.get_object(sub_addr) + sub_addr, sub_obj = await _subobject_address_and_info(self, addr, i) obj.children[sub_obj.name] = sub_obj child_path = f"{path}.{sub_obj.name}" child = await walk(sub_addr, child_path) @@ -1346,20 +1577,11 @@ async def walk(addr: Address, path: Optional[str] = None) -> Optional[FirmwareTr async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTree: """Return cached firmware tree, or build and cache it when missing.""" - if not refresh: - cached = getattr(self.backend, "_firmware_tree_cache", None) - if isinstance(cached, FirmwareTree): - return cached + if not refresh and self._firmware_tree_cache is not None: + return self._firmware_tree_cache - tree = await self._build_firmware_tree() - setattr(self.backend, "_firmware_tree_cache", tree) - return tree - - async def print_firmware_tree(self, refresh: bool = False) -> FirmwareTree: - """Print firmware tree text and return the tree object.""" - tree = await self.get_firmware_tree(refresh=refresh) - print(tree) - return tree + self._firmware_tree_cache = await self._build_firmware_tree() + return self._firmware_tree_cache async def get_supported_interface0_method_ids(self, address: Address) -> Set[int]: """Return the set of Interface 0 method IDs this object supports. @@ -1369,24 +1591,17 @@ async def get_supported_interface0_method_ids(self, address: Address) -> Set[int Used to guard calls so we never send an Interface 0 command the object did not advertise. """ - cached = getattr(self.backend, "get_supported_interface0_method_ids", None) - cache_store = getattr(self.backend, "_supported_interface0_method_ids", None) - has_capability_cache = isinstance(cache_store, dict) - if cached is not None and has_capability_cache: - return cast(Set[int], await cached(address)) - - obj = await self.get_object(address) - supported: Set[int] = set() - for i in range(obj.method_count): - try: - method = await self.get_method(address, i) - if method.interface_id == 0: - supported.add(method.method_id) - except _TRANSIENT_ERRORS: - raise - except Exception as e: - logger.debug("get_method(%s, %d) failed: %s", address, i, e) - return supported + 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. @@ -1486,7 +1701,7 @@ async def get_interfaces( ids = list(response.interface_ids) names = list(response.interface_names) - return [ + infos = [ InterfaceInfo( interface_id=int(ids[i]), name=names[i] if i < len(names) else f"Interface_{ids[i]}", @@ -1494,6 +1709,8 @@ async def get_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. @@ -1601,48 +1818,6 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI name_offset += cnt return result - async def get_all_methods( - self, - address: Address, - *, - _supported: Optional[Set[int]] = None, - _object_info: Optional[ObjectInfo] = None, - ) -> List[MethodInfo]: - """Get all methods for an object. - - Returns [] if the object does not support GetMethod (interface 0, method 2). - - Args: - address: Object address - _supported: Pre-computed supported Interface 0 method IDs (internal). - _object_info: Pre-fetched ObjectInfo (internal; avoids redundant GetObject). - - Returns: - List of all method signatures - """ - if _object_info is None: - _object_info = await self.get_object(address) - if _supported is None: - _supported = await self.get_supported_interface0_method_ids(address) - if GET_METHOD not in _supported: - logger.debug( - "Object at %s does not support GetMethod (interface 0, method 2); returning []", - address, - ) - return [] - - methods = [] - for i in range(_object_info.method_count): - try: - method = await self.get_method(address, i) - methods.append(method) - except _TRANSIENT_ERRORS: - raise - except Exception as e: - logger.warning(f"Failed to get method {i} for {address}: {e}") - - return methods - async def build_type_registry( self, address: Union[Address, str], @@ -1679,19 +1854,14 @@ async def build_type_registry( interfaces = [] if GET_METHOD in _supported: - registry.methods = await self.get_all_methods(address, _supported=_supported) + registry.methods = await self.ensure_method_table(address) else: registry.methods = [] for iface in interfaces: - if GET_STRUCTS in _supported: - structs = await self.get_structs(address, iface.interface_id) - if structs: - registry.structs[iface.interface_id] = {s.struct_id: s for s in structs} - if GET_ENUMS in _supported: - enums = await self.get_enums(address, iface.interface_id) - if enums: - registry.enums[iface.interface_id] = {e.enum_id: e for e in enums} + 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) return registry @@ -1756,48 +1926,20 @@ async def build_global_type_pool( self, global_addresses: List[Address], ) -> GlobalTypePool: - """Build the global type pool from global objects. + """Build a fresh global type pool from *global_addresses* (full walk; not the session singleton). - This mirrors piglet's approach: walk each global object, iterate its - interfaces, and collect all structs/enums in sequential encounter order. - The resulting flat pool is used for source_id=1 lookups (1-based indexing). + 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: global_addresses: List of global object addresses - (from HamiltonTCPClient._global_object_addresses). + (from :attr:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient.global_object_addresses`). Returns: GlobalTypePool with all global structs and enums. """ - 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) - for iface in interfaces: - if GET_STRUCTS in supported: - structs = await self.get_structs(addr, iface.interface_id) - pool.structs.extend(structs) - pool.interface_structs[iface.interface_id] = {s.struct_id: s for s in structs} - if GET_ENUMS in supported: - enums = await self.get_enums(addr, iface.interface_id) - pool.enums.extend(enums) - 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 + return await self._build_global_type_pool_impl(global_addresses) async def get_method_by_id( self, @@ -1826,7 +1968,7 @@ async def get_method_by_id( if cached is not None: return cached address = await self._resolve_target_address(address) - methods = await self.get_all_methods(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 @@ -1839,14 +1981,15 @@ async def resolve_signature( method_id: int, registry: Optional[TypeRegistry] = None, ) -> str: - """One-liner: return a fully resolved method signature string. + """Return a fully resolved method signature string. - Looks up the method and resolves struct/enum references using the - provided TypeRegistry (or falls back to unresolved names). + 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, mph_registry) + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9) print(sig) # PickupTips(tipParameters: PickupTipParameters, finalZ: f32, ...) -> ... @@ -1854,312 +1997,17 @@ async def resolve_signature( Human-readable signature string, or a descriptive error string. """ address = await self._resolve_target_address(address) - method = await self.get_method_by_id(address, interface_id, method_id, registry=registry) + 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"" - return method.get_signature_string(registry) - - -# ============================================================================ -# STRUCT / COMMAND VALIDATION -# ============================================================================ - - -def _normalize_name(name: str) -> str: - """Normalize a name for comparison (remove underscores, make lowercase). - - Allows Pythonic `snake_case` (e.g. `z_liquid_exit_speed`) to match - Hamilton's arbitrary PascalCase (`ZLiquidExitSpeed` or `Zliquidexitspeed`). - """ - return name.replace("_", "").lower() - - -def _get_wire_type_id(annotation) -> Optional[int]: - """Extract HamiltonDataType type_id from an Annotated type alias. - - Works for all our wire types: F32, PaddedBool, U32, WEnum, Str, - Annotated[X, Struct()], Annotated[list[X], StructArray()], etc. - - Returns None if the annotation doesn't carry a WireType. - """ - # Handle typing.Annotated - metadata = getattr(annotation, "__metadata__", None) - if metadata: - for m in metadata: - if hasattr(m, "type_id"): - return cast(int, m.type_id) - return None - - -def _get_nested_dataclass(annotation): - """For Annotated[SomeDataclass, Struct()], return SomeDataclass. Else None.""" - args = getattr(annotation, "__args__", None) - if not args: - return None - base_type = args[0] - # For Annotated[list[X], StructArray()], dig into the list's inner type - inner_args = getattr(base_type, "__args__", None) - if inner_args: - base_type = inner_args[0] - import dataclasses - - if dataclasses.is_dataclass(base_type): - return base_type - return None - - -@dataclass -class FieldMismatch: - """One field-level mismatch between hand-crafted and introspected definitions.""" - - field_name: str - issue: str # e.g. "missing", "extra", "type mismatch", "order mismatch" - expected: str = "" - actual: str = "" - - def __str__(self): - s = f" {self.field_name}: {self.issue}" - if self.expected or self.actual: - s += f" (expected={self.expected}, actual={self.actual})" - return s - - -@dataclass -class ValidationResult: - """Result of comparing a hand-crafted dataclass against introspection.""" - - name: str - passed: bool = False - mismatches: List[FieldMismatch] = field(default_factory=list) - children: List["ValidationResult"] = field(default_factory=list) - - def __str__(self): - icon = "✅" if self.passed else "❌" - lines = [f"{icon} {self.name}"] - for m in self.mismatches: - lines.append(str(m)) - for child in self.children: - for line in str(child).split("\n"): - lines.append(f" {line}") - return "\n".join(lines) - - -def validate_struct( - dataclass_cls, - introspected: StructInfo, - pool: Optional[GlobalTypePool] = None, - registry: Optional["TypeRegistry"] = None, -) -> ValidationResult: - """Compare a hand-crafted dataclass against an introspected StructInfo. - - Checks field count, field names (snake_case → PascalCase), field types - (extracts type_id from Annotated metadata), and field order. For nested - structs (Annotated[X, Struct()]), recursively validates the child struct - when a GlobalTypePool and/or TypeRegistry can resolve the nested ref - (global, same-interface, or local source_id=2 with registry + interface id). - - Args: - dataclass_cls: The hand-crafted dataclass class (not an instance). - introspected: The introspected StructInfo from the device. - pool: Optional GlobalTypePool for nested global (1) and same-interface (0) refs. - registry: Optional TypeRegistry for nested local (source_id=2) refs. - - Returns: - ValidationResult with pass/fail and detailed mismatches. - """ - import dataclasses as dc - import typing - - result = ValidationResult(name=dataclass_cls.__name__) - mismatches = result.mismatches - - # Get hand-crafted fields - hints = typing.get_type_hints(dataclass_cls, include_extras=True) - hand_fields = list(dc.fields(dataclass_cls)) - hand_names = [f.name for f in hand_fields] - hand_norm = [_normalize_name(n) for n in hand_names] - - # Get introspected fields - intro_names = list(introspected.fields.keys()) - intro_norm = [_normalize_name(n) for n in intro_names] - intro_types = list(introspected.fields.values()) - - # 1. Field count - if len(hand_names) != len(intro_names): - mismatches.append( - FieldMismatch( - field_name="(count)", - issue="field count mismatch", - expected=str(len(intro_names)), - actual=str(len(hand_names)), - ) - ) - - # 2. Field names (order-aware) - for i, (hn_norm, in_norm) in enumerate(zip(hand_norm, intro_norm)): - if hn_norm != in_norm: - mismatches.append( - FieldMismatch( - field_name=hand_names[i], - issue=f"name mismatch at position {i}", - expected=intro_names[i], - actual=hand_names[i], - ) - ) - - # 3. Extra / missing fields - hand_set = set(hand_norm) - intro_set = set(intro_norm) - - # For error reporting, we want the original casing, so we build reverse maps - hand_map = {hn_norm: h for hn_norm, h in zip(hand_norm, hand_names)} - intro_map = {in_norm: i for in_norm, i in zip(intro_norm, intro_names)} - - for missing_norm in intro_set - hand_set: - original_intro = intro_map[missing_norm] - mismatches.append(FieldMismatch(field_name=original_intro, issue="missing in hand-crafted")) - for extra_norm in hand_set - intro_set: - original_hand = hand_map[extra_norm] - mismatches.append( - FieldMismatch(field_name=original_hand, issue="extra in hand-crafted (not in introspection)") - ) - - # 4. Field types (where names match) - for i, (hand_name, intro_name) in enumerate(zip(hand_names, intro_names)): - if _normalize_name(hand_name) != _normalize_name(intro_name): - continue # Already reported as name mismatch - annotation = hints.get(hand_name) - if annotation is None: - continue - hand_type_id = _get_wire_type_id(annotation) - intro_pt = intro_types[i] - if hand_type_id is not None and hand_type_id != intro_pt.type_id: - try: - from pylabrobot.hamilton.tcp.wire_types import HamiltonDataType - - expected_name = HamiltonDataType(intro_pt.type_id).name - actual_name = HamiltonDataType(hand_type_id).name - except ValueError: - expected_name = str(intro_pt.type_id) - actual_name = str(hand_type_id) - mismatches.append( - FieldMismatch( - field_name=hand_name, - issue="type mismatch", - expected=expected_name, - actual=actual_name, - ) - ) - - # 5. Recursive validation for nested structs - if ( - intro_pt.is_complex - and intro_pt.source_id is not None - and intro_pt.ref_id is not None - and intro_pt.type_id == 30 - ): # STRUCTURE - nested_cls = _get_nested_dataclass(annotation) - if nested_cls: - nested_struct: Optional[StructInfo] = None - if intro_pt.source_id == 1 and pool is not None: - nested_struct = pool.resolve_struct(intro_pt.ref_id) - elif intro_pt.source_id == 0 and pool is not None and introspected.interface_id is not None: - nested_struct = pool.resolve_struct_local(introspected.interface_id, intro_pt.ref_id) - elif ( - intro_pt.source_id == 2 and registry is not None and introspected.interface_id is not None - ): - nested_struct = registry.resolve_struct( - 2, intro_pt.ref_id, ho_interface_id=introspected.interface_id - ) - if nested_struct: - child_result = validate_struct(nested_cls, nested_struct, pool, registry=registry) - result.children.append(child_result) - - result.passed = len(mismatches) == 0 and all(c.passed for c in result.children) - return result - - -def validate_command( - command_cls, - registry: TypeRegistry, - pool: GlobalTypePool, - interface_id: int = 1, -) -> ValidationResult: - """Compare a PrepCommand against its introspected method signature. - - Matches the command's command_id to the introspected method_id on the given - interface. Validates that the command's struct parameters match the method's - expected struct types. - - Args: - command_cls: The PrepCommand subclass. - registry: TypeRegistry with the object's methods. - pool: GlobalTypePool for resolving struct refs. - interface_id: Interface ID to look up the method on (default 1 = Pipettor). - - Returns: - ValidationResult with pass/fail and details. - """ - import dataclasses as dc - import typing - - cmd_id = getattr(command_cls, "command_id", None) - result = ValidationResult(name=f"{command_cls.__name__} (cmd={cmd_id})") - - if cmd_id is None: - result.mismatches.append(FieldMismatch(field_name="(class)", issue="no command_id attribute")) - result.passed = False - return result - - # Find matching introspected method - method = registry.get_method(interface_id, cmd_id) - if method is None: - result.mismatches.append( - FieldMismatch( - field_name="(method)", issue=f"no introspected method for [{interface_id}:{cmd_id}]" - ) - ) - result.passed = False - return result - - result.name = f"{command_cls.__name__} ↔ {method.name} [{interface_id}:{cmd_id}]" - - # Get command's payload fields (exclude 'dest' and class-level attrs) - hints = typing.get_type_hints(command_cls, include_extras=True) - payload_fields = [f for f in dc.fields(command_cls) if f.name != "dest"] - - # Match struct payload fields to introspected parameter types positionally - struct_fields = [ - (pf, hints.get(pf.name)) - for pf in payload_fields - if _get_nested_dataclass(hints.get(pf.name)) is not None - ] - struct_params = [ - pt - for pt in method.parameter_types - if pt.is_complex and pt.source_id is not None and pt.ref_id is not None - ] - - for (pf, annotation), pt in zip(struct_fields, struct_params): - ref_id = pt.ref_id - assert ref_id is not None, "struct_params filtered for ref_id is not None" - if pt.source_id == 0: - # Same-interface ref: needs correct HOI interface id for GlobalTypePool.resolve_struct_local - # vs. registry — validate on hardware before wiring method-level validation here. - intro_struct = None - elif pt.source_id == 2: - intro_struct = registry.resolve_struct( - pt.source_id, ref_id, ho_interface_id=method.interface_id - ) - else: - src_id = pt.source_id - assert src_id is not None, "struct_params filtered for source_id is not None" - intro_struct = registry.resolve_struct(src_id, ref_id) - nested_cls = _get_nested_dataclass(annotation) - if intro_struct and nested_cls: - child_result = validate_struct(nested_cls, intro_struct, pool, registry=registry) - child_result.name = f"{pf.name} → {intro_struct.name} (ref={pt.ref_id})" - result.children.append(child_result) - - result.passed = all(c.passed for c in result.children) and len(result.mismatches) == 0 - return result + reg = await self._build_minimal_registry_for_signature(address, method) + return method.get_signature_string(reg) diff --git a/pylabrobot/hamilton/tcp/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py index 7590be1c0ce..aac0bf03f09 100644 --- a/pylabrobot/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -13,11 +13,24 @@ import unittest import asyncio from dataclasses import dataclass -from typing import Annotated +from typing import Annotated, cast +from unittest.mock import AsyncMock +import pylabrobot.hamilton.tcp.introspection as introspection_mod from pylabrobot.hamilton.tcp.client import HamiltonTCPClient from pylabrobot.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.hamilton.tcp.introspection import HamiltonIntrospection, ObjectInfo, ObjectRegistry +from pylabrobot.hamilton.tcp.introspection import ( + EnumInfo, + GlobalTypePool, + HamiltonIntrospection, + InterfaceInfo, + MethodInfo, + ObjectInfo, + ObjectRegistry, + ParameterType, + StructInfo, + TypeRegistry, +) from pylabrobot.hamilton.tcp.messages import ( CommandMessage, CommandResponse, @@ -97,12 +110,12 @@ def test_encode_version_byte_invalid(self): class TestPacketWireShape(unittest.TestCase): def test_ip_packet_roundtrip(self): - original = IpPacket(protocol=6, payload=b"\xAA\xBB", options=b"\x10\x20") + 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") + self.assertEqual(unpacked.payload, b"\xaa\xbb") def test_harp_action_bit_and_roundtrip(self): original = HarpPacket( @@ -254,7 +267,9 @@ class Response: cmd = Cmd(Address(0, 0, 0)) params = HoiParams().add(42, I64).build() - hoi = HoiPacket(interface_id=1, action_code=Hoi2Action.COMMAND_RESPONSE, action_id=0, params=params) + hoi = HoiPacket( + interface_id=1, action_code=Hoi2Action.COMMAND_RESPONSE, action_id=0, params=params + ) harp = HarpPacket( src=Address(0, 0, 0), dst=Address(0, 0, 0), @@ -284,7 +299,9 @@ async def _fake_resolve_path(path: str) -> Address: 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"})) + got = asyncio.run( + client.resolve_target("pipettor_service", aliases={"pipettor_service": "Root.Child"}) + ) self.assertEqual(got, Address(1, 1, 999)) def test_send_command_return_raw_returns_hoi_payload_tuple(self): @@ -297,9 +314,12 @@ class FakeClient(HamiltonTCPClient): async def write(self, data: bytes, timeout=None): # type: ignore[override] del data, timeout - async def _read_one_message(self): # type: ignore[override] + 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) + 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), @@ -323,6 +343,34 @@ def __init__(self): self._registry = ObjectRegistry() self._registry.set_root_addresses([Address(1, 1, 100)]) self._discovered_objects = {"root": [Address(1, 1, 100)]} + self._fw_cache = None + self._global_object_addresses = () + + @property + def registry(self): + return self._registry + + @property + def global_object_addresses(self): + return self._global_object_addresses + + def get_root_object_addresses(self): + return self._registry.get_root_addresses() or list(self._discovered_objects.get("root", [])) + + def get_firmware_tree_cache(self): + return self._fw_cache + + def set_firmware_tree_cache(self, tree): + self._fw_cache = tree + + async def send_command(self, *a, **k): + raise RuntimeError("unused in this test") + + async def resolve_path(self, path: str): + raise RuntimeError("unused") + + async def get_supported_interface0_method_ids(self, address: Address): + raise RuntimeError("unused") backend = Backend() intro = HamiltonIntrospection(backend) @@ -344,9 +392,9 @@ async def fake_get_subobject_address(_addr: Address, idx: int) -> Address: self.assertEqual(idx, 0) return child - intro.get_object = fake_get_object # type: ignore[method-assign] - intro.get_supported_interface0_method_ids = fake_get_supported # type: ignore[method-assign] - intro.get_subobject_address = fake_get_subobject_address # type: ignore[method-assign] + 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()) @@ -362,6 +410,36 @@ async def fake_get_subobject_address(_addr: Address, idx: int) -> Address: self.assertGreaterEqual(counts["sub"], 2) +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._hc_result_text.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._hc_result_text.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: @@ -374,7 +452,7 @@ def _format_entry(entry: HcResultEntry) -> str: 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 summary + entries_frag + tail + return cast(bytes, summary + entries_frag + tail) def test_non_warning_action_does_not_strip(self): payload = HoiParams().add(True, Bool).build() @@ -432,5 +510,198 @@ def test_i16_array_roundtrip_decode_fragment(self): self.assertEqual(decode_fragment(HamiltonDataType.I16_ARRAY, payload), [1, 2, 3]) +class TestIntrospectionTypeGridInvariants(unittest.TestCase): + """Canonical integrity guard for HOI type table edits.""" + + def test_grid_dimensions_match_protocol(self): + rows = introspection_mod._HOI_TYPE_ROWS + self.assertEqual(len(rows), 31) + self.assertTrue(all(len(row.ids) == 4 for row in rows)) + + def test_only_padding_row_has_zero_ids(self): + rows = introspection_mod._HOI_TYPE_ROWS + for idx, row in enumerate(rows): + if idx == len(rows) - 1: + self.assertEqual(row.ids, (0, 0, 0, 0)) + else: + self.assertTrue(all(tid != 0 for tid in row.ids), msg=f"unexpected zero at row {idx}") + + def test_nonzero_ids_are_unique(self): + all_nonzero = [tid for row in introspection_mod._HOI_TYPE_ROWS for tid in row.ids if tid != 0] + self.assertEqual(len(all_nonzero), len(set(all_nonzero))) + + def test_special_name_and_category_overrides(self): + self.assertEqual(introspection_mod.resolve_introspection_type_name(0), "void") + self.assertEqual( + introspection_mod.resolve_introspection_type_name(113), + "List[f32] [In] (empirical)", + ) + self.assertEqual(introspection_mod.get_introspection_type_category(113), "Argument") + + def test_grid_categories_match_directions_with_empirical_exception(self): + empirical_argument_ids = {113} + for row in introspection_mod._HOI_TYPE_ROWS: + in_id, out_id, inout_id, retval_id = row.ids + if row.ids == (0, 0, 0, 0): + continue + for tid in (in_id, out_id, inout_id, retval_id): + if tid == 0: + continue + self.assertNotEqual(introspection_mod.get_introspection_type_category(tid), "Unknown") + + self.assertEqual(introspection_mod.get_introspection_type_category(in_id), "Argument") + self.assertEqual(introspection_mod.get_introspection_type_category(inout_id), "Argument") + self.assertEqual(introspection_mod.get_introspection_type_category(out_id), "ReturnElement") + if retval_id in empirical_argument_ids: + self.assertEqual(introspection_mod.get_introspection_type_category(retval_id), "Argument") + else: + self.assertEqual( + introspection_mod.get_introspection_type_category(retval_id), "ReturnValue" + ) + + +class _MinimalIntroBackend: + """Protocol-shaped stub for :class:`HamiltonIntrospection` unit tests.""" + + def __init__(self) -> None: + self._registry = ObjectRegistry() + + @property + def registry(self): + return self._registry + + @property + def global_object_addresses(self): + return () + + def get_root_object_addresses(self): + return [] + + def get_firmware_tree_cache(self): + return None + + def set_firmware_tree_cache(self, tree): + del tree + + async def send_command(self, *a, **k): + raise AssertionError("send_command should be patched out in introspection cache tests") + + async def resolve_path(self, path: str): + del path + raise AssertionError("unused") + + async def get_supported_interface0_method_ids(self, address): + del address + return set() + + +class TestHamiltonIntrospectionLazyCaches(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.addr = Address(1, 1, 99) + self.backend = _MinimalIntroBackend() + self.intro = HamiltonIntrospection(self.backend) + + 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 = ParameterType(57, 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._structs_by_addr_iface[key] = {0: st} + self.intro._enums_by_addr_iface[key] = {} + self.intro._iface_types_loaded.add(key) + + 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 = ParameterType(57, 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() From e1dcc6092b5ca0a8236d8f11735540c738b6bf02 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:09:56 -0700 Subject: [PATCH 03/14] Merge Nimbus v1b1 branch: - NimbusDriver on HamiltonTCPClient with NIMBUS_ERROR_CODES; chatterbox, door, pip backend - TCPCommand as the shared wire command base; client/introspection aligned - Nimbus unit tests for error-code merge and interface resolution --- .../liquid_handlers/nimbus/__init__.py | 5 + .../liquid_handlers/nimbus/chatterbox.py | 99 ++ .../liquid_handlers/nimbus/commands.py | 599 +++++++++ .../hamilton/liquid_handlers/nimbus/door.py | 56 + .../hamilton/liquid_handlers/nimbus/driver.py | 251 ++++ .../hamilton/liquid_handlers/nimbus/nimbus.py | 104 ++ .../liquid_handlers/nimbus/pip_backend.py | 1069 +++++++++++++++++ .../liquid_handlers/nimbus/tests/__init__.py | 0 .../nimbus/tests/driver_tests.py | 136 +++ .../liquid_handlers/prep/prep_commands.py | 20 +- pylabrobot/hamilton/tcp/__init__.py | 4 +- pylabrobot/hamilton/tcp/client.py | 4 +- pylabrobot/hamilton/tcp/commands.py | 12 +- pylabrobot/hamilton/tcp/introspection.py | 16 +- pylabrobot/hamilton/tcp/tcp_tests.py | 10 +- 15 files changed, 2351 insertions(+), 34 deletions(-) create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/commands.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/door.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/driver.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/tests/__init__.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py new file mode 100644 index 00000000000..9bb82dffb2c --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py @@ -0,0 +1,5 @@ +from .chatterbox import NimbusChatterboxDriver +from .door import NimbusDoor +from .driver import NimbusDriver, NimbusSetupParams +from .nimbus import Nimbus +from .pip_backend import NimbusPIPBackend diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py new file mode 100644 index 00000000000..5c5b63c86cf --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py @@ -0,0 +1,99 @@ +"""NimbusChatterboxDriver: prints commands instead of sending them over TCP.""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.hamilton.tcp.commands import TCPCommand +from pylabrobot.hamilton.tcp.packets import Address + +from .door import NimbusDoor +from .driver import NimbusDriver, NimbusSetupParams + +logger = logging.getLogger(__name__) + + +class NimbusChatterboxDriver(NimbusDriver): + """Chatterbox driver for Nimbus. Simulates commands for testing without hardware. + + Inherits NimbusDriver but overrides setup/stop/send_command to skip TCP + and use canned addresses and responses instead. + """ + + def __init__(self, num_channels: int = 8): + # Pass dummy host — Socket is created but never opened + super().__init__(host="chatterbox", port=2000) + self._num_channels = num_channels + + async def setup(self, backend_params: Optional[BackendParams] = None): + from .pip_backend import NimbusPIPBackend + + 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__}" + ) + + # Use canned addresses (skip TCP connection entirely) + pipette_address = Address(1, 1, 257) + nimbus_core_address = Address(1, 1, 48896) + self._nimbus_core_address = nimbus_core_address + door_address = Address(1, 1, 268) + self._resolved_interfaces = { + "nimbus_core": nimbus_core_address, + "pipette": pipette_address, + "door_lock": door_address, + } + + self.pip = NimbusPIPBackend( + driver=self, deck=params.deck, address=pipette_address, num_channels=self._num_channels + ) + self.door = NimbusDoor(driver=self, address=door_address) + if params.require_door_lock and self.door is None: + raise RuntimeError("DoorLock is required but not available on this instrument.") + + async def stop(self): + if self.door is not None: + await self.door._on_stop() + self.door = None + self._resolved_interfaces = {} + + 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 + from .commands import ( + GetChannelConfiguration, + GetChannelConfiguration_1, + IsDoorLocked, + IsInitialized, + IsTipPresent, + ) + + if isinstance(command, GetChannelConfiguration_1): + return {"channels": self._num_channels} + if isinstance(command, IsInitialized): + return {"initialized": True} + if isinstance(command, IsTipPresent): + return {"tip_present": [False] * self._num_channels} + if isinstance(command, IsDoorLocked): + return {"locked": True} + if isinstance(command, GetChannelConfiguration): + return {"enabled": [False]} + 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 new file mode 100644 index 00000000000..31a4863a374 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py @@ -0,0 +1,599 @@ +"""Hamilton Nimbus command classes and supporting types. + +This module contains all Nimbus-specific Hamilton protocol commands, including +tip management, initialization, door control, ADC, aspirate, and dispense. +""" + +from __future__ import annotations + +import enum +import logging +from dataclasses import dataclass + +from pylabrobot.hamilton.tcp.commands import TCPCommand +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 ( + Bool, + BoolArray, + I16Array, + I32, + I32Array, + U16, + U16Array, + U32Array, +) +from pylabrobot.resources import Tip +from pylabrobot.resources.hamilton import HamiltonTip, TipSize + +logger = logging.getLogger(__name__) + + +class NimbusCommand(TCPCommand): + """Thin Nimbus command base for namespace clarity.""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + + def _build_structured_parameters(self) -> HoiParams: + """Serialize wire-annotated dataclass payload fields in declaration order.""" + return HoiParams.from_struct(self) + + +# ============================================================================ +# TIP TYPE ENUM +# ============================================================================ + + +class NimbusTipType(enum.IntEnum): + """Hamilton Nimbus tip type enumeration. + + Maps tip type names to their integer values used in Hamilton protocol commands. + """ + + STANDARD_300UL = 0 # "300ul Standard Volume Tip" + STANDARD_300UL_FILTER = 1 # "300ul Standard Volume Tip with filter" + LOW_VOLUME_10UL = 2 # "10ul Low Volume Tip" + LOW_VOLUME_10UL_FILTER = 3 # "10ul Low Volume Tip with filter" + HIGH_VOLUME_1000UL = 4 # "1000ul High Volume Tip" + HIGH_VOLUME_1000UL_FILTER = 5 # "1000ul High Volume Tip with filter" + TIP_50UL = 22 # "50ul Tip" + TIP_50UL_FILTER = 23 # "50ul Tip with filter" + SLIM_CORE_300UL = 36 # "SLIM CO-RE Tip 300ul" + + +def _get_tip_type_from_tip(tip: Tip) -> int: + """Map Tip object characteristics to Hamilton tip type integer. + + Args: + tip: Tip object with volume and filter information. Must be a HamiltonTip. + + Returns: + Hamilton tip type integer value. + + Raises: + ValueError: If tip characteristics don't match any known tip type. + """ + + if not isinstance(tip, HamiltonTip): + raise ValueError("Tip must be a HamiltonTip to determine tip type.") + + if tip.tip_size == TipSize.LOW_VOLUME: # 10ul tip + return NimbusTipType.LOW_VOLUME_10UL_FILTER if tip.has_filter else NimbusTipType.LOW_VOLUME_10UL + + if tip.tip_size == TipSize.STANDARD_VOLUME and tip.maximal_volume < 60: # 50ul tip + return NimbusTipType.TIP_50UL_FILTER if tip.has_filter else NimbusTipType.TIP_50UL + + if tip.tip_size == TipSize.STANDARD_VOLUME: # 300ul tip + return NimbusTipType.STANDARD_300UL_FILTER if tip.has_filter else NimbusTipType.STANDARD_300UL + + if tip.tip_size == TipSize.HIGH_VOLUME: # 1000ul tip + return ( + NimbusTipType.HIGH_VOLUME_1000UL_FILTER + if tip.has_filter + else NimbusTipType.HIGH_VOLUME_1000UL + ) + + raise ValueError( + f"Cannot determine tip type for tip with volume {tip.maximal_volume}uL " + f"and filter={tip.has_filter}. No matching Hamilton tip type found." + ) + + +def _get_default_flow_rate(tip: Tip, is_aspirate: bool) -> float: + """Get default flow rate based on tip type. + + Defaults from Hamilton Nimbus: + - 1000 ul tip: 250 asp / 400 disp + - 300 and 50 ul tip: 100 asp / 180 disp + - 10 ul tip: 100 asp / 75 disp + + Args: + tip: Tip object to determine default flow rate for. + is_aspirate: True for aspirate, False for dispense. + + Returns: + Default flow rate in uL/s. + """ + tip_type = _get_tip_type_from_tip(tip) + + if tip_type in (NimbusTipType.HIGH_VOLUME_1000UL, NimbusTipType.HIGH_VOLUME_1000UL_FILTER): + return 250.0 if is_aspirate else 400.0 + + if tip_type in (NimbusTipType.LOW_VOLUME_10UL, NimbusTipType.LOW_VOLUME_10UL_FILTER): + return 100.0 if is_aspirate else 75.0 + + # 50 and 300 ul tips + return 100.0 if is_aspirate else 180.0 + + +# ============================================================================ +# COMMAND CLASSES +# ============================================================================ + + +class LockDoor(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 + + +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 + + +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) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse IsDoorLocked response.""" + parser = HoiParamsParser(data) + _, locked = parser.parse_next() + return {"locked": bool(locked)} + + +class PreInitializeSmart(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 + + +@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 + + dest: Address + 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 + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() + + +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) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse IsInitialized response.""" + parser = HoiParamsParser(data) + _, initialized = parser.parse_next() + return {"initialized": bool(initialized)} + + +class IsTipPresent(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 + action_code = 0 + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse IsTipPresent response - returns List[i16].""" + parser = HoiParamsParser(data) + # Parse array of i16 values representing tip presence per channel + _, tip_presence = parser.parse_next() + return {"tip_present": tip_presence} + + +class GetChannelConfiguration_1(NimbusCommand): + """Get channel configuration (NimbusCore root, interface_id=1, command_id=15).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 15 + action_code = 0 + + @classmethod + def parse_response_parameters(cls, data: bytes) -> dict: + """Parse GetChannelConfiguration_1 response. + + Returns: (channels: u16, channel_types: List[i16]) + """ + parser = HoiParamsParser(data) + _, channels = parser.parse_next() + _, channel_types = parser.parse_next() + return {"channels": channels, "channel_types": channel_types} + + +@dataclass +class SetChannelConfiguration(NimbusCommand): + """Set channel configuration (Pipette, cmd=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 + + dest: Address + channel: U16 + indexes: I16Array + enables: BoolArray + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() + + +@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) + + dest: Address + channel: U16 + indexes: I16Array + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() + + @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(NimbusCommand): + """Park command (NimbusCore at 1:1:48896, interface_id=1, command_id=3).""" + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 3 + + +@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 + + dest: Address + 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 __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() + + +@dataclass +class DropTips(NimbusCommand): + """Drop tips command (Pipette, cmd=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 + + dest: Address + 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 __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() + + +@dataclass +class DropTipsRoll(NimbusCommand): + """Drop tips with roll command (Pipette, cmd=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 + + dest: Address + 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 __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() + + +@dataclass +class EnableADC(NimbusCommand): + """Enable ADC command (Pipette, cmd=43). + + Field meanings: + - `channels_involved`: 1=active, 0=inactive. + """ + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 43 + + dest: Address + channels_involved: U16Array + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() + + +@dataclass +class DisableADC(NimbusCommand): + """Disable ADC command (Pipette, cmd=44). + + Field meanings: + - `channels_involved`: 1=active, 0=inactive. + """ + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 44 + + dest: Address + channels_involved: U16Array + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() + + +@dataclass +class Aspirate(NimbusCommand): + """Aspirate command (Pipette, cmd=6). + + Units: + - linear positions/heights: 0.01 mm + - volumes: 0.1 uL + - flow rates: 0.1 uL/s + - settling time: 0.1 s + + 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 + + dest: Address + # 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 + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() + + +@dataclass +class Dispense(NimbusCommand): + """Dispense command (Pipette, cmd=7). + + Units: + - linear positions/heights: 0.01 mm + - volumes: 0.1 uL + - flow rates: 0.1 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. + """ + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 7 + + dest: Address + # 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 + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/door.py b/pylabrobot/hamilton/liquid_handlers/nimbus/door.py new file mode 100644 index 00000000000..363bb93b6ca --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/door.py @@ -0,0 +1,56 @@ +"""NimbusDoor: door control subsystem for Hamilton Nimbus.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from pylabrobot.hamilton.tcp.packets import Address + +from .commands import IsDoorLocked, LockDoor, UnlockDoor + +if TYPE_CHECKING: + from .driver import NimbusDriver + +logger = logging.getLogger(__name__) + + +class NimbusDoor: + """Controls the door on a Hamilton Nimbus. + + Plain helper class (not a CapabilityBackend), following the STARCover pattern. + Owned by NimbusDriver, exposed via convenience methods on the Nimbus device. + """ + + def __init__(self, driver: "NimbusDriver", address: Address): + self.driver = driver + self.address = address + + async def _on_setup(self): + """Lock door on setup if available.""" + try: + if not await self.is_locked(): + await self.lock() + else: + logger.info("Door already locked") + except Exception as e: + logger.warning(f"Door operations skipped during setup: {e}") + + async def _on_stop(self): + pass + + async def is_locked(self) -> bool: + """Check if the door is locked.""" + status = await self.driver.send_command(IsDoorLocked(self.address)) + assert status is not None, "IsDoorLocked command returned None" + return bool(status["locked"]) + + async def lock(self) -> None: + """Lock the door.""" + await self.driver.send_command(LockDoor(self.address)) + logger.info("Door locked successfully") + + async def unlock(self) -> None: + """Unlock the door.""" + await self.driver.send_command(UnlockDoor(self.address)) + logger.info("Door unlocked successfully") diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py new file mode 100644 index 00000000000..b015f06e91f --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -0,0 +1,251 @@ +"""NimbusDriver: TCP-based driver for Hamilton Nimbus liquid handlers.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Dict, Optional, Set, Tuple + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.hamilton.tcp.client import HamiltonTCPClient +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 + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class InterfaceSpec: + object_name: str + required: bool + raise_when_missing: bool = True + + +@dataclass +class NimbusSetupParams(BackendParams): + deck: Optional[NimbusDeck] = None + require_door_lock: bool = False + + +class NimbusDriver(HamiltonTCPClient): + """Driver for Hamilton Nimbus liquid handlers. + + Handles TCP communication, hardware discovery via introspection, and + manages the PIP backend and door subsystem. + """ + + _INTERFACES = { + "nimbus_core": InterfaceSpec("NimbusCore", required=True), + "pipette": InterfaceSpec("Pipette", required=True), + "door_lock": InterfaceSpec("DoorLock", required=False, raise_when_missing=False), + } + + _REQUIRED_METHODS_CORE: Set[int] = { + 3, + 14, + 15, + 29, + } # Park, IsInitialized, GetChannelConfig_1, InitializeSmartRoll + _REQUIRED_METHODS_PIPETTE: Set[int] = { + 4, # PickupTips + 5, # DropTips + 6, # Aspirate + 7, # Dispense + 16, # IsTipPresent + 43, # EnableADC + 44, # DisableADC + 66, # GetChannelConfiguration + 67, # SetChannelConfiguration + 82, # DropTipsRoll + } + + def __init__( + self, + host: str, + port: int = 2000, + read_timeout: float = 300.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + connection_timeout: int = 600, + error_codes: Optional[Dict[Tuple[int, int, int, int, int], str]] = None, + ): + merged_error_codes = {**NIMBUS_ERROR_CODES, **(error_codes or {})} + super().__init__( + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + auto_reconnect=auto_reconnect, + max_reconnect_attempts=max_reconnect_attempts, + connection_timeout=connection_timeout, + error_codes=merged_error_codes, + ) + + self._nimbus_core_address: Optional[Address] = None + self._resolved_interfaces: Dict[str, Optional[Address]] = {} + + self.pip: NimbusPIPBackend # set in setup() + self.door: Optional[NimbusDoor] = None # set in setup() if available + + @property + def nimbus_core_address(self) -> Address: + if self._nimbus_core_address is None: + raise RuntimeError("NimbusCore address not discovered. Call setup() first.") + return self._nimbus_core_address + + async def setup(self, backend_params: Optional[BackendParams] = None): + """Initialize connection, discover hardware, and create backends. + + Args: + backend_params: Optional :class:`NimbusSetupParams`. + """ + 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__}" + ) + + # TCP connection + Protocol 7 + Protocol 3 + root discovery + await super().setup() + + addresses = await self._discover_instrument_objects() + await self._resolve_interfaces(addresses) + + nimbus_core_address = await self._require_interface("nimbus_core") + pipette_address = await self._require_interface("pipette") + door_address = self._resolved_interfaces.get("door_lock") + + await self._assert_required_methods( + nimbus_core_address, + object_name="NimbusCore", + required_method_ids=self._REQUIRED_METHODS_CORE, + ) + await self._assert_required_methods( + pipette_address, + object_name="Pipette", + required_method_ids=self._REQUIRED_METHODS_PIPETTE, + ) + + # Query channel configuration + config = await self.send_command(GetChannelConfiguration_1(nimbus_core_address)) + assert config is not None, "GetChannelConfiguration_1 command returned None" + num_channels = config["channels"] + logger.info(f"Channel configuration: {num_channels} channels") + + # Create backends — each object stores its own address and state + self.pip = NimbusPIPBackend( + driver=self, deck=params.deck, address=pipette_address, num_channels=num_channels + ) + + if door_address is not None: + self.door = NimbusDoor(driver=self, address=door_address) + elif params.require_door_lock: + raise RuntimeError("DoorLock is required but not available on this instrument.") + + # Initialize subsystems + if self.door is not None: + await self.door._on_setup() + + async def stop(self): + """Stop driver and close connection.""" + if self.door is not None: + await self.door._on_stop() + await super().stop() + self.door = None + self._resolved_interfaces = {} + + 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. + """ + addresses: Dict[str, Address] = {} + + root_objects = self.get_root_object_addresses() + if not root_objects: + raise RuntimeError("No root objects discovered during setup.") + + nimbus_core_addr = root_objects[0] + root_info = await self.introspection.get_object(nimbus_core_addr) + if "nimbus" not in root_info.name.lower(): + raise RuntimeError( + f"Expected a Nimbus root object, but discovered '{root_info.name}'. Wrong instrument?" + ) + addresses[root_info.name] = nimbus_core_addr + self._nimbus_core_address = nimbus_core_addr + + for i in range(root_info.subobject_count): + try: + sub_addr = await self.introspection.get_subobject_address(nimbus_core_addr, i) + sub_info = await self.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}") + + if "DoorLock" not in addresses: + logger.info("DoorLock not available on this instrument") + + return addresses + + async def _resolve_interfaces(self, discovered: Dict[str, Address]) -> None: + self._resolved_interfaces = {} + for key, spec in self._INTERFACES.items(): + addr = discovered.get(spec.object_name) + if addr is None: + if spec.required: + raise RuntimeError( + f"Could not find required interface '{key}' ({spec.object_name}) on Nimbus." + ) + self._resolved_interfaces[key] = None + else: + self._resolved_interfaces[key] = addr + + async def _require_interface(self, name: str) -> Address: + if name not in self._INTERFACES: + raise KeyError(f"Unknown interface: {name}") + + spec = self._INTERFACES[name] + addr = self._resolved_interfaces.get(name) + if addr is None: + msg = f"Could not find interface '{name}' ({spec.object_name}) on Nimbus." + if spec.raise_when_missing: + logger.warning(msg) + raise RuntimeError(msg) + return addr + + async def _assert_required_methods( + self, + address: Address, + *, + object_name: str, + required_method_ids: Set[int], + ) -> None: + methods = await self.introspection.methods_for_interface(address, interface_id=1) + available = {m.method_id for m in methods} + missing = sorted(required_method_ids - available) + if missing: + raise RuntimeError( + f"{object_name} is missing required interface-1 methods: {missing}. " + "Firmware is incompatible with Nimbus v1 backend requirements." + ) + + async def park(self): + """Park the instrument.""" + await self.send_command(Park(self.nimbus_core_address)) + logger.info("Instrument parked successfully") diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py new file mode 100644 index 00000000000..c03ee1901f8 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py @@ -0,0 +1,104 @@ +"""Nimbus device: wires NimbusDriver backends to PIP capability frontend.""" + +from typing import 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 .chatterbox import NimbusChatterboxDriver +from .driver import NimbusDriver, NimbusSetupParams + + +class Nimbus(Device): + """Hamilton Nimbus liquid handler. + + User-facing device that wires the PIP capability frontend to the + NimbusDriver's PIP backend after hardware discovery during setup(). + """ + + def __init__( + self, + deck: NimbusDeck, + chatterbox: bool = False, + host: Optional[str] = None, + port: int = 2000, + ): + if chatterbox: + driver: NimbusDriver = NimbusChatterboxDriver() + else: + if not host: + raise ValueError("host must be provided when chatterbox is False.") + driver = NimbusDriver(host=host, port=port) + super().__init__(driver=driver) + self.driver: NimbusDriver = driver + self.deck = deck + self.pip: PIP # set in setup() + + async def setup(self, backend_params: Optional[BackendParams] = None): + """Initialize the Nimbus instrument. + + Establishes the TCP connection, discovers hardware objects, queries channel + configuration and tip presence, locks the door (if available), conditionally + runs InitializeSmartRoll, and wires the PIP capability frontend to the driver's + PIP backend. + """ + if backend_params is None: + params = NimbusSetupParams(deck=self.deck) + elif isinstance(backend_params, NimbusSetupParams): + params = backend_params + if params.deck is None: + params = NimbusSetupParams(deck=self.deck, require_door_lock=params.require_door_lock) + else: + raise TypeError( + "Nimbus.setup expected NimbusSetupParams | None for backend_params, " + f"got {type(backend_params).__name__}" + ) + + try: + await self.driver.setup(backend_params=params) + + self.pip = PIP(backend=self.driver.pip) + self._capabilities = [self.pip] + await self.pip._on_setup() + self._setup_finished = True + except Exception: + await self.driver.stop() + raise + + async def stop(self): + """Tear down the Nimbus instrument. + + Stops all capabilities in reverse order and closes the driver connection. + """ + if not self._setup_finished: + return + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + + # -- Convenience methods delegating to driver/subsystems -------------------- + + async def park(self): + """Park the instrument.""" + await self.driver.park() + + async def lock_door(self): + """Lock the door.""" + if self.driver.door is None: + raise RuntimeError("Door lock is not available on this instrument.") + await self.driver.door.lock() + + async def unlock_door(self): + """Unlock the door.""" + if self.driver.door is None: + raise RuntimeError("Door lock is not available on this instrument.") + await self.driver.door.unlock() + + async def is_door_locked(self) -> bool: + """Check if the door is locked.""" + if self.driver.door is None: + raise RuntimeError("Door lock is not available on this instrument.") + return await self.driver.door.is_locked() diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py new file mode 100644 index 00000000000..132f48589f3 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -0,0 +1,1069 @@ +"""NimbusPIPBackend: translates PIP operations into Nimbus firmware commands.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, TypeVar, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend +from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop +from pylabrobot.hamilton.tcp.packets import Address +from pylabrobot.resources import Tip +from pylabrobot.resources.container import Container +from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.trash import Trash + +from .commands import ( + Aspirate, + Dispense as DispenseCommand, + DisableADC, + DropTips, + DropTipsRoll, + EnableADC, + GetChannelConfiguration, + InitializeSmartRoll, + IsInitialized, + IsTipPresent, + PickupTips, + SetChannelConfiguration, + _get_default_flow_rate, + _get_tip_type_from_tip, +) + +if TYPE_CHECKING: + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + from .driver import NimbusDriver + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fill_in_defaults(val: Optional[List[T]], default: List[T]) -> List[T]: + """If val is None, return default. Otherwise validate length and fill None entries.""" + if val is None: + return default + if len(val) != len(default): + raise ValueError(f"Value length must equal num operations ({len(default)}), but is {len(val)}") + return [v if v is not None else d for v, d in zip(val, default)] + + +# --------------------------------------------------------------------------- +# BackendParams dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class NimbusPIPPickUpTipsParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + + +@dataclass +class NimbusPIPDropTipsParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + default_waste: bool = False + z_position_at_end_of_a_command: Optional[float] = None + roll_distance: Optional[float] = None + + +@dataclass +class NimbusPIPAspirateParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + adc_enabled: bool = False + lld_mode: Optional[List[int]] = None + lld_search_height: Optional[List[float]] = None + immersion_depth: Optional[List[float]] = None + surface_following_distance: Optional[List[float]] = None + gamma_lld_sensitivity: Optional[List[int]] = None + dp_lld_sensitivity: Optional[List[int]] = None + settling_time: Optional[List[float]] = None + transport_air_volume: Optional[List[float]] = None + pre_wetting_volume: Optional[List[float]] = None + swap_speed: Optional[List[float]] = None + mix_position_from_liquid_surface: Optional[List[float]] = None + limit_curve_index: Optional[List[int]] = None + tadm_enabled: bool = False + + +@dataclass +class NimbusPIPDispenseParams(BackendParams): + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + adc_enabled: bool = False + lld_mode: Optional[List[int]] = None + lld_search_height: Optional[List[float]] = None + immersion_depth: Optional[List[float]] = None + surface_following_distance: Optional[List[float]] = None + gamma_lld_sensitivity: Optional[List[int]] = None + settling_time: Optional[List[float]] = None + transport_air_volume: Optional[List[float]] = None + swap_speed: Optional[List[float]] = None + mix_position_from_liquid_surface: Optional[List[float]] = None + limit_curve_index: Optional[List[int]] = None + tadm_enabled: bool = False + cut_off_speed: Optional[List[float]] = None + stop_back_volume: Optional[List[float]] = None + side_touch_off_distance: float = 0.0 + dispense_offset: Optional[List[float]] = None + + +# --------------------------------------------------------------------------- +# NimbusPIPBackend +# --------------------------------------------------------------------------- + + +class NimbusPIPBackend(PIPBackend): + """PIP backend for Hamilton Nimbus liquid handlers. + + Translates abstract PIP operations (pick_up_tips, drop_tips, aspirate, dispense) + into Nimbus-specific Hamilton TCP commands. + """ + + def __init__( + self, + driver: "NimbusDriver", + deck: Optional["NimbusDeck"] = None, + address: Optional["Address"] = None, + num_channels: int = 8, + traversal_height: float = 146.0, + ): + self.driver = driver + self.deck = deck + self.address = address + self._num_channels = num_channels + self.traversal_height = traversal_height + self._channel_configurations: Optional[dict] = None + + @property + def num_channels(self) -> int: + return self._num_channels + + @property + def pipette_address(self) -> Address: + if self.address is None: + raise RuntimeError("Pipette address not set. Call setup() first.") + return self.address + + def _ensure_deck(self) -> "NimbusDeck": + """Return the deck, raising if not set.""" + if self.deck is None: + raise RuntimeError("Deck must be set for pipetting operations.") + return self.deck + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + """Initialize SmartRoll if not already initialized.""" + del backend_params + # Query initialization status + init_status = await self.driver.send_command(IsInitialized(self.driver.nimbus_core_address)) + assert init_status is not None + is_initialized = init_status.get("initialized", False) + + if not is_initialized: + await self._initialize_smart_roll() + else: + logger.info("Instrument already initialized, skipping SmartRoll init") + + async def _on_stop(self): + pass + + async def _initialize_smart_roll(self): + """Configure channels and initialize SmartRoll with waste positions.""" + self._ensure_deck() + # Set channel configuration for each channel + for channel in range(1, self.num_channels + 1): + await self.driver.send_command( + SetChannelConfiguration( + dest=self.pipette_address, + channel=channel, + indexes=[1, 3, 4], + enables=[True, False, False, False], + ) + ) + logger.info(f"Channel configuration set for {self.num_channels} channels") + + # Initialize SmartRoll using waste positions + all_channels = list(range(self.num_channels)) + ( + x_positions_full, + y_positions_full, + begin_tip_deposit_process_full, + end_tip_deposit_process_full, + z_position_at_end_of_a_command_full, + roll_distances_full, + ) = self._build_waste_position_params(use_channels=all_channels) + + await self.driver.send_command( + InitializeSmartRoll( + dest=self.driver.nimbus_core_address, + x_positions=x_positions_full, + y_positions=y_positions_full, + begin_tip_deposit_process=begin_tip_deposit_process_full, + end_tip_deposit_process=end_tip_deposit_process_full, + z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, + roll_distances=roll_distances_full, + ) + ) + logger.info("NimbusCore initialized with InitializeSmartRoll successfully") + + # --------------------------------------------------------------------------- + # Channel fill helper + # --------------------------------------------------------------------------- + + def _fill_by_channels(self, values: List[T], use_channels: List[int], default: T) -> List[T]: + """Returns a full-length list of size `num_channels` where positions in `use_channels` + are filled from `values` in order; all others are `default`.""" + if len(values) != len(use_channels): + raise ValueError( + f"values and channels must have same length (got {len(values)} vs {len(use_channels)})" + ) + for ch in use_channels: + if ch < 0 or ch >= self.num_channels: + raise ValueError( + f"Channel index {ch} out of range for {self.num_channels}-channel instrument" + ) + out = [default] * self.num_channels + for ch, v in zip(use_channels, values): + out[ch] = v + return out + + # --------------------------------------------------------------------------- + # Coordinate helpers + # --------------------------------------------------------------------------- + + def _compute_ops_xy_locations( + self, ops: Sequence, use_channels: List[int] + ) -> Tuple[List[int], List[int]]: + """Compute X and Y positions in Hamilton coordinates for the given operations.""" + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + if not isinstance(self.deck, NimbusDeck): + raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") + + x_positions_mm: List[float] = [] + y_positions_mm: List[float] = [] + + for op in ops: + abs_location = op.resource.get_location_wrt(self.deck) + final_location = abs_location + op.offset + hamilton_coord = self.deck.to_hamilton_coordinate(final_location) + x_positions_mm.append(hamilton_coord.x) + y_positions_mm.append(hamilton_coord.y) + + x_positions = [round(x * 100) for x in x_positions_mm] + y_positions = [round(y * 100) for y in y_positions_mm] + + x_positions_full = self._fill_by_channels(x_positions, use_channels, default=0) + y_positions_full = self._fill_by_channels(y_positions, use_channels, default=0) + + return x_positions_full, y_positions_full + + def _compute_tip_handling_parameters( + self, + ops: Sequence, + use_channels: List[int], + use_fixed_offset: bool = False, + fixed_offset_mm: float = 10.0, + ) -> Tuple[List[int], List[int]]: + """Calculate Z positions for tip pickup/drop operations. + + Pickup (use_fixed_offset=False): Z based on tip length. + Drop (use_fixed_offset=True): Z based on fixed offset. + + Returns: (begin_position, end_position) in 0.01mm units, full num_channels arrays. + """ + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + if not isinstance(self.deck, NimbusDeck): + raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") + + z_positions_mm: List[float] = [] + for op in ops: + abs_location = op.resource.get_location_wrt(self.deck) + op.offset + hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) + z_positions_mm.append(hamilton_coord.z) + + max_z_hamilton = max(z_positions_mm) + + if use_fixed_offset: + begin_position_mm = max_z_hamilton + fixed_offset_mm + end_position_mm = max_z_hamilton + else: + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + begin_position_mm = max_z_hamilton + max_total_tip_length + end_position_mm = max_z_hamilton + max_tip_length + + begin_position = [round(begin_position_mm * 100)] * len(ops) + end_position = [round(end_position_mm * 100)] * len(ops) + + begin_position_full = self._fill_by_channels(begin_position, use_channels, default=0) + end_position_full = self._fill_by_channels(end_position, use_channels, default=0) + + return begin_position_full, end_position_full + + def _build_waste_position_params( + self, + use_channels: List[int], + z_position_at_end_of_a_command: Optional[float] = None, + roll_distance: Optional[float] = None, + ) -> Tuple[List[int], List[int], List[int], List[int], List[int], List[int]]: + """Build waste position parameters for InitializeSmartRoll or DropTipsRoll.""" + from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck + + if not isinstance(self.deck, NimbusDeck): + raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") + + x_positions_mm: List[float] = [] + y_positions_mm: List[float] = [] + z_positions_mm: List[float] = [] + + for channel_idx in use_channels: + if not hasattr(self.deck, "waste_type") or self.deck.waste_type is None: + raise RuntimeError( + f"Deck does not have waste_type attribute. " + f"Cannot determine waste position for channel {channel_idx}." + ) + waste_pos_name = f"{self.deck.waste_type}_{channel_idx + 1}" + waste_pos = self.deck.get_resource(waste_pos_name) + abs_location = waste_pos.get_location_wrt(self.deck) + hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) + + x_positions_mm.append(hamilton_coord.x) + y_positions_mm.append(hamilton_coord.y) + z_positions_mm.append(hamilton_coord.z) + + x_positions = [round(x * 100) for x in x_positions_mm] + y_positions = [round(y * 100) for y in y_positions_mm] + + max_z_hamilton = max(z_positions_mm) + z_start_absolute_mm = max_z_hamilton + 4.0 + z_stop_absolute_mm = max_z_hamilton + + if z_position_at_end_of_a_command is None: + z_position_at_end_of_a_command = self.traversal_height + if roll_distance is None: + roll_distance = 9.0 + + begin_tip_deposit_process = [round(z_start_absolute_mm * 100)] * len(use_channels) + end_tip_deposit_process = [round(z_stop_absolute_mm * 100)] * len(use_channels) + z_position_at_end_list = [round(z_position_at_end_of_a_command * 100)] * len(use_channels) + roll_distances = [round(roll_distance * 100)] * len(use_channels) + + x_positions_full = self._fill_by_channels(x_positions, use_channels, default=0) + y_positions_full = self._fill_by_channels(y_positions, use_channels, default=0) + begin_full = self._fill_by_channels(begin_tip_deposit_process, use_channels, default=0) + end_full = self._fill_by_channels(end_tip_deposit_process, use_channels, default=0) + z_end_full = self._fill_by_channels(z_position_at_end_list, use_channels, default=0) + roll_full = self._fill_by_channels(roll_distances, use_channels, default=0) + + return x_positions_full, y_positions_full, begin_full, end_full, z_end_full, roll_full + + # --------------------------------------------------------------------------- + # PIPBackend interface + # --------------------------------------------------------------------------- + + async def request_tip_presence(self) -> List[Optional[bool]]: + tip_status = await self.driver.send_command(IsTipPresent(self.pipette_address)) + assert tip_status is not None, "IsTipPresent command returned None" + tip_present = tip_status.get("tip_present", []) + return [bool(v) for v in tip_present] + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + if not isinstance(tip, HamiltonTip): + return False + if tip.tip_size in {TipSize.XL}: + return False + if channel_idx >= self._num_channels: + return False + return True + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Pick up tips from the specified resource. + + Z positions are calculated from resource locations and tip properties: + - begin_tip_pick_up_process: max(resource Z) + max(tip total_tip_length) + - end_tip_pick_up_process: max(resource Z) + max(tip total_tip_length - fitting_depth) + + Checks tip presence before pickup and raises if channels already have tips. + + Args: + ops: List of Pickup operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPPickUpTipsParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``, typically 146.0 mm). + + Raises: + RuntimeError: If channels already have tips mounted. + """ + if not ops: + return + self._ensure_deck() + params = ( + backend_params + if isinstance(backend_params, NimbusPIPPickUpTipsParams) + else NimbusPIPPickUpTipsParams() + ) + + # Check tip presence before picking up + try: + tip_present = await self.request_tip_presence() + channels_with_tips = [ + i for i, present in enumerate(tip_present) if i in use_channels and present + ] + if channels_with_tips: + raise RuntimeError( + f"Cannot pick up tips: channels {channels_with_tips} already have tips mounted." + ) + except RuntimeError: + raise + except Exception as e: + logger.warning(f"Could not check tip presence before pickup: {e}") + + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + begin_tip_pick_up_process, end_tip_pick_up_process = self._compute_tip_handling_parameters( + ops, use_channels + ) + + channels_involved = [int(ch in use_channels) for ch in range(self.num_channels)] + + tip_types = [_get_tip_type_from_tip(op.tip) for op in ops] + tip_types_full = self._fill_by_channels(tip_types, use_channels, default=0) + + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + command = PickupTips( + dest=self.pipette_address, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + begin_tip_pick_up_process=begin_tip_pick_up_process, + end_tip_pick_up_process=end_tip_pick_up_process, + tip_types=tip_types_full, + ) + + await self.driver.send_command(command) + logger.info(f"Picked up tips on channels {use_channels}") + + async def drop_tips( + self, + ops: List[TipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Drop tips to the specified resource. + + Auto-detects waste positions and uses the appropriate firmware command: + - If resource is a Trash, uses **DropTipsRoll** (roll-off into waste chute). + - Otherwise, uses **DropTips** (return tips to a tip rack). + + Z positions are calculated from resource locations: + - Waste positions: Z start/stop from deck waste coordinates via ``_build_waste_position_params``. + - Regular resources: Fixed offset (max_z + 10 mm start, max_z stop) -- independent of tip + length because the tip is already mounted on the pipette. + + Cannot mix waste and regular resources in a single call. + + Args: + ops: List of TipDrop operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPDropTipsParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``). + - default_waste: For DropTips command, if True the instrument drops to the + default waste position (default: False). + - z_position_at_end_of_a_command: Z final position in mm, absolute + (default: traversal height). + - roll_distance: Roll distance in mm for DropTipsRoll (default: 9.0 mm). + + Raises: + ValueError: If operations mix waste and regular resources. + """ + if not ops: + return + self._ensure_deck() + params = ( + backend_params + if isinstance(backend_params, NimbusPIPDropTipsParams) + else NimbusPIPDropTipsParams() + ) + + # Check if resources are waste positions + is_waste_positions = [isinstance(op.resource, Trash) for op in ops] + all_waste = all(is_waste_positions) + all_regular = not any(is_waste_positions) + + if not (all_waste or all_regular): + raise ValueError( + "Cannot mix waste positions and regular resources in a single drop_tips call." + ) + + channels_involved = [int(ch in use_channels) for ch in range(self.num_channels)] + + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + command: Union[DropTips, DropTipsRoll] + + if all_waste: + ( + x_positions_full, + y_positions_full, + begin_tip_deposit_process_full, + end_tip_deposit_process_full, + z_position_at_end_of_a_command_full, + roll_distances_full, + ) = self._build_waste_position_params( + use_channels=use_channels, + z_position_at_end_of_a_command=params.z_position_at_end_of_a_command, + roll_distance=params.roll_distance, + ) + + command = DropTipsRoll( + dest=self.pipette_address, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + begin_tip_deposit_process=begin_tip_deposit_process_full, + end_tip_deposit_process=end_tip_deposit_process_full, + z_position_at_end_of_a_command=z_position_at_end_of_a_command_full, + roll_distances=roll_distances_full, + ) + else: + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + begin_tip_deposit_process, end_tip_deposit_process = self._compute_tip_handling_parameters( + ops, use_channels, use_fixed_offset=True + ) + + z_end = params.z_position_at_end_of_a_command + if z_end is None: + z_end = traverse_height + z_position_at_end_list = [round(z_end * 100)] * len(ops) + z_position_at_end_full = self._fill_by_channels( + z_position_at_end_list, use_channels, default=0 + ) + + command = DropTips( + dest=self.pipette_address, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + begin_tip_deposit_process=begin_tip_deposit_process, + end_tip_deposit_process=end_tip_deposit_process, + z_position_at_end_of_a_command=z_position_at_end_full, + default_waste=params.default_waste, + ) + + await self.driver.send_command(command) + logger.info(f"Dropped tips on channels {use_channels}") + + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Aspirate liquid from the specified resource. + + Volumes, flow rates, blow-out air volumes, and mix parameters are taken from the + ``Aspiration`` operations. Hardware-level parameters are set via ``backend_params``. + + Args: + ops: List of Aspiration operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPAspirateParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``). + - adc_enabled: Enable Automatic Drip Control (default: False). + - lld_mode: LLD mode per channel -- 0=OFF, 1=cLLD, 2=pLLD, 3=DUAL (default: [0]*n). + - lld_search_height: **Relative offset** from well bottom (mm) where LLD search + starts. The instrument adds this to minimum_height internally. + If None, defaults to the well's size_z (i.e. search from the top of the well). + - immersion_depth: Depth to submerge below liquid surface (mm, default: [0.0]*n). + - surface_following_distance: Distance to follow liquid surface during aspiration + (mm, default: [0.0]*n). + - gamma_lld_sensitivity: Gamma LLD sensitivity, 1-4 (default: [0]*n). + - dp_lld_sensitivity: Differential-pressure LLD sensitivity, 1-4 (default: [0]*n). + - settling_time: Settling time after aspiration (s, default: [1.0]*n). + - transport_air_volume: Transport air volume (uL, default: [5.0]*n). + - pre_wetting_volume: Pre-wetting volume (uL, default: [0.0]*n). + - swap_speed: Swap speed on leaving liquid (uL/s, default: [20.0]*n). + - mix_position_from_liquid_surface: Mix position offset from liquid surface + (mm, default: [0.0]*n). + - limit_curve_index: Limit curve index (default: [0]*n). + - tadm_enabled: Enable TADM (Total Aspiration and Dispense Monitoring) + (default: False). + """ + if not ops: + return + params = ( + backend_params + if isinstance(backend_params, NimbusPIPAspirateParams) + else NimbusPIPAspirateParams() + ) + + n = len(ops) + + channels_involved = [0] * self.num_channels + for channel_idx in use_channels: + channels_involved[channel_idx] = 1 + + # ADC control + if params.adc_enabled: + await self.driver.send_command(EnableADC(self.pipette_address, channels_involved)) + else: + await self.driver.send_command(DisableADC(self.pipette_address, channels_involved)) + + # Query channel configurations + if self._channel_configurations is None: + self._channel_configurations = {} + for channel_idx in use_channels: + channel_num = channel_idx + 1 + try: + config = await self.driver.send_command( + GetChannelConfiguration(self.pipette_address, channel=channel_num, indexes=[2]) + ) + assert config is not None + enabled = config["enabled"][0] if config["enabled"] else False + if channel_num not in self._channel_configurations: + self._channel_configurations[channel_num] = {} + self._channel_configurations[channel_num][2] = enabled + except Exception as e: + logger.warning(f"Failed to get channel config for channel {channel_num}: {e}") + + # Compute XY positions + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + + # Traverse height + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + deck = self._ensure_deck() + + # Well bottoms + well_bottoms = [] + for op in ops: + abs_location = op.resource.get_location_wrt(deck) + op.offset + if isinstance(op.resource, Container): + abs_location.z += op.resource.material_z_thickness + hamilton_coord = deck.to_hamilton_coordinate(abs_location) + well_bottoms.append(hamilton_coord.z) + + liquid_heights_mm = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] + + lld_search_height = params.lld_search_height + if lld_search_height is None: + lld_search_height = [op.resource.get_absolute_size_z() for op in ops] + + minimum_heights_mm = well_bottoms.copy() + + volumes = [op.volume for op in ops] + flow_rates: List[float] = [ + op.flow_rate if op.flow_rate is not None else _get_default_flow_rate(op.tip, is_aspirate=True) + for op in ops + ] + blow_out_air_volumes = [ + op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops + ] + + mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_speed: List[float] = [ + op.mix.flow_rate + if op.mix is not None + else ( + op.flow_rate + if op.flow_rate is not None + else _get_default_flow_rate(op.tip, is_aspirate=True) + ) + for op in ops + ] + + # Advanced parameters + lld_mode = _fill_in_defaults(params.lld_mode, [0] * n) + immersion_depth = _fill_in_defaults(params.immersion_depth, [0.0] * n) + surface_following_distance = _fill_in_defaults(params.surface_following_distance, [0.0] * n) + gamma_lld_sensitivity = _fill_in_defaults(params.gamma_lld_sensitivity, [0] * n) + dp_lld_sensitivity = _fill_in_defaults(params.dp_lld_sensitivity, [0] * n) + settling_time = _fill_in_defaults(params.settling_time, [1.0] * n) + transport_air_volume = _fill_in_defaults(params.transport_air_volume, [5.0] * n) + pre_wetting_volume = _fill_in_defaults(params.pre_wetting_volume, [0.0] * n) + swap_speed = _fill_in_defaults(params.swap_speed, [20.0] * n) + mix_position_from_liquid_surface = _fill_in_defaults( + params.mix_position_from_liquid_surface, [0.0] * n + ) + limit_curve_index = _fill_in_defaults(params.limit_curve_index, [0] * n) + + # Unit conversions + aspirate_volumes = [round(vol * 10) for vol in volumes] + blow_out_air_volumes_units = [round(vol * 10) for vol in blow_out_air_volumes] + aspiration_speeds = [round(fr * 10) for fr in flow_rates] + lld_search_height_units = [round(h * 100) for h in lld_search_height] + liquid_height_units = [round(h * 100) for h in liquid_heights_mm] + immersion_depth_units = [round(d * 100) for d in immersion_depth] + surface_following_distance_units = [round(d * 100) for d in surface_following_distance] + minimum_height_units = [round(z * 100) for z in minimum_heights_mm] + settling_time_units = [round(t * 10) for t in settling_time] + transport_air_volume_units = [round(v * 10) for v in transport_air_volume] + pre_wetting_volume_units = [round(v * 10) for v in pre_wetting_volume] + swap_speed_units = [round(s * 10) for s in swap_speed] + mix_volume_units = [round(v * 10) for v in mix_volume] + mix_speed_units = [round(s * 10) for s in mix_speed] + mix_position_from_liquid_surface_units = [ + round(p * 100) for p in mix_position_from_liquid_surface + ] + + # Build full-channel arrays + aspirate_volumes_full = self._fill_by_channels(aspirate_volumes, use_channels, default=0) + blow_out_air_volumes_full = self._fill_by_channels( + blow_out_air_volumes_units, use_channels, default=0 + ) + aspiration_speeds_full = self._fill_by_channels(aspiration_speeds, use_channels, default=0) + lld_search_height_full = self._fill_by_channels( + lld_search_height_units, use_channels, default=0 + ) + liquid_height_full = self._fill_by_channels(liquid_height_units, use_channels, default=0) + immersion_depth_full = self._fill_by_channels(immersion_depth_units, use_channels, default=0) + surface_following_distance_full = self._fill_by_channels( + surface_following_distance_units, use_channels, default=0 + ) + minimum_height_full = self._fill_by_channels(minimum_height_units, use_channels, default=0) + settling_time_full = self._fill_by_channels(settling_time_units, use_channels, default=0) + transport_air_volume_full = self._fill_by_channels( + transport_air_volume_units, use_channels, default=0 + ) + pre_wetting_volume_full = self._fill_by_channels( + pre_wetting_volume_units, use_channels, default=0 + ) + swap_speed_full = self._fill_by_channels(swap_speed_units, use_channels, default=0) + mix_volume_full = self._fill_by_channels(mix_volume_units, use_channels, default=0) + mix_cycles_full = self._fill_by_channels(mix_cycles, use_channels, default=0) + mix_speed_full = self._fill_by_channels(mix_speed_units, use_channels, default=0) + mix_position_from_liquid_surface_full = self._fill_by_channels( + mix_position_from_liquid_surface_units, use_channels, default=0 + ) + gamma_lld_sensitivity_full = self._fill_by_channels( + gamma_lld_sensitivity, use_channels, default=0 + ) + dp_lld_sensitivity_full = self._fill_by_channels(dp_lld_sensitivity, use_channels, default=0) + limit_curve_index_full = self._fill_by_channels(limit_curve_index, use_channels, default=0) + lld_mode_full = self._fill_by_channels(lld_mode, use_channels, default=0) + + # Default values for remaining parameters + aspirate_type = [0] * self.num_channels + clot_detection_height = [0] * self.num_channels + min_z_endpos = traverse_height_units + mix_surface_following_distance = [0] * self.num_channels + tube_section_height = [0] * self.num_channels + tube_section_ratio = [0] * self.num_channels + lld_height_difference = [0] * self.num_channels + recording_mode = 0 + + command = Aspirate( + dest=self.pipette_address, + aspirate_type=aspirate_type, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + lld_search_height=lld_search_height_full, + liquid_height=liquid_height_full, + immersion_depth=immersion_depth_full, + surface_following_distance=surface_following_distance_full, + minimum_height=minimum_height_full, + clot_detection_height=clot_detection_height, + min_z_endpos=min_z_endpos, + swap_speed=swap_speed_full, + blow_out_air_volume=blow_out_air_volumes_full, + pre_wetting_volume=pre_wetting_volume_full, + aspirate_volume=aspirate_volumes_full, + transport_air_volume=transport_air_volume_full, + aspiration_speed=aspiration_speeds_full, + settling_time=settling_time_full, + mix_volume=mix_volume_full, + mix_cycles=mix_cycles_full, + mix_position_from_liquid_surface=mix_position_from_liquid_surface_full, + mix_surface_following_distance=mix_surface_following_distance, + mix_speed=mix_speed_full, + tube_section_height=tube_section_height, + tube_section_ratio=tube_section_ratio, + lld_mode=lld_mode_full, + gamma_lld_sensitivity=gamma_lld_sensitivity_full, + dp_lld_sensitivity=dp_lld_sensitivity_full, + lld_height_difference=lld_height_difference, + tadm_enabled=params.tadm_enabled, + limit_curve_index=limit_curve_index_full, + recording_mode=recording_mode, + ) + + await self.driver.send_command(command) + logger.info(f"Aspirated on channels {use_channels}") + + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Dispense liquid to the specified resource. + + Volumes, flow rates, blow-out air volumes, and mix parameters are taken from the + ``Dispense`` operations. Hardware-level parameters are set via ``backend_params``. + + Args: + ops: List of Dispense operations, one per channel. + use_channels: List of 0-based channel indices to use. + backend_params: Optional :class:`NimbusPIPDispenseParams`: + - minimum_traverse_height_at_beginning_of_a_command: Traverse height in mm + (default: ``self.traversal_height``). + - adc_enabled: Enable Automatic Drip Control (default: False). + - lld_mode: LLD mode per channel -- 0=OFF, 1=cLLD, 2=pLLD, 3=DUAL (default: [0]*n). + - lld_search_height: **Relative offset** from well bottom (mm) where LLD search + starts. If None, defaults to the well's size_z. + - immersion_depth: Depth to submerge below liquid surface (mm, default: [0.0]*n). + - surface_following_distance: Distance to follow liquid surface during dispense + (mm, default: [0.0]*n). + - gamma_lld_sensitivity: Gamma LLD sensitivity, 1-4 (default: [0]*n). + - settling_time: Settling time after dispense (s, default: [1.0]*n). + - transport_air_volume: Transport air volume (uL, default: [5.0]*n). + - swap_speed: Swap speed on leaving liquid (uL/s, default: [20.0]*n). + - mix_position_from_liquid_surface: Mix position offset from liquid surface + (mm, default: [0.0]*n). + - limit_curve_index: Limit curve index (default: [0]*n). + - tadm_enabled: Enable TADM (default: False). + - cut_off_speed: Cut-off speed at end of dispense (uL/s, default: [25.0]*n). + - stop_back_volume: Stop-back volume to prevent dripping (uL, default: [0.0]*n). + - side_touch_off_distance: Side touch-off distance (mm, default: 0.0). + - dispense_offset: Dispense Z offset (mm, default: [0.0]*n). + """ + if not ops: + return + params = ( + backend_params + if isinstance(backend_params, NimbusPIPDispenseParams) + else NimbusPIPDispenseParams() + ) + + n = len(ops) + + channels_involved = [0] * self.num_channels + for channel_idx in use_channels: + channels_involved[channel_idx] = 1 + + # ADC control + if params.adc_enabled: + await self.driver.send_command(EnableADC(self.pipette_address, channels_involved)) + else: + await self.driver.send_command(DisableADC(self.pipette_address, channels_involved)) + + # Query channel configurations + if self._channel_configurations is None: + self._channel_configurations = {} + for channel_idx in use_channels: + channel_num = channel_idx + 1 + try: + config = await self.driver.send_command( + GetChannelConfiguration(self.pipette_address, channel=channel_num, indexes=[2]) + ) + assert config is not None + enabled = config["enabled"][0] if config["enabled"] else False + if channel_num not in self._channel_configurations: + self._channel_configurations[channel_num] = {} + self._channel_configurations[channel_num][2] = enabled + except Exception as e: + logger.warning(f"Failed to get channel config for channel {channel_num}: {e}") + + # Compute XY positions + x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) + + # Traverse height + traverse_height = params.minimum_traverse_height_at_beginning_of_a_command + if traverse_height is None: + traverse_height = self.traversal_height + traverse_height_units = round(traverse_height * 100) + + deck = self._ensure_deck() + + # Well bottoms + well_bottoms = [] + for op in ops: + abs_location = op.resource.get_location_wrt(deck) + op.offset + if isinstance(op.resource, Container): + abs_location.z += op.resource.material_z_thickness + hamilton_coord = deck.to_hamilton_coordinate(abs_location) + well_bottoms.append(hamilton_coord.z) + + liquid_heights_mm = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] + + lld_search_height = params.lld_search_height + if lld_search_height is None: + lld_search_height = [op.resource.get_absolute_size_z() for op in ops] + + minimum_heights_mm = well_bottoms.copy() + + volumes = [op.volume for op in ops] + flow_rates: List[float] = [ + op.flow_rate + if op.flow_rate is not None + else _get_default_flow_rate(op.tip, is_aspirate=False) + for op in ops + ] + blow_out_air_volumes = [ + op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops + ] + + mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_speed: List[float] = [ + op.mix.flow_rate + if op.mix is not None + else ( + op.flow_rate + if op.flow_rate is not None + else _get_default_flow_rate(op.tip, is_aspirate=False) + ) + for op in ops + ] + + # Advanced parameters + lld_mode = _fill_in_defaults(params.lld_mode, [0] * n) + immersion_depth = _fill_in_defaults(params.immersion_depth, [0.0] * n) + surface_following_distance = _fill_in_defaults(params.surface_following_distance, [0.0] * n) + gamma_lld_sensitivity = _fill_in_defaults(params.gamma_lld_sensitivity, [0] * n) + settling_time = _fill_in_defaults(params.settling_time, [1.0] * n) + transport_air_volume = _fill_in_defaults(params.transport_air_volume, [5.0] * n) + swap_speed = _fill_in_defaults(params.swap_speed, [20.0] * n) + mix_position_from_liquid_surface = _fill_in_defaults( + params.mix_position_from_liquid_surface, [0.0] * n + ) + limit_curve_index = _fill_in_defaults(params.limit_curve_index, [0] * n) + cut_off_speed = _fill_in_defaults(params.cut_off_speed, [25.0] * n) + stop_back_volume = _fill_in_defaults(params.stop_back_volume, [0.0] * n) + dispense_offset = _fill_in_defaults(params.dispense_offset, [0.0] * n) + + # Unit conversions + dispense_volumes = [round(vol * 10) for vol in volumes] + blow_out_air_volumes_units = [round(vol * 10) for vol in blow_out_air_volumes] + dispense_speeds = [round(fr * 10) for fr in flow_rates] + lld_search_height_units = [round(h * 100) for h in lld_search_height] + liquid_height_units = [round(h * 100) for h in liquid_heights_mm] + immersion_depth_units = [round(d * 100) for d in immersion_depth] + surface_following_distance_units = [round(d * 100) for d in surface_following_distance] + minimum_height_units = [round(z * 100) for z in minimum_heights_mm] + settling_time_units = [round(t * 10) for t in settling_time] + transport_air_volume_units = [round(v * 10) for v in transport_air_volume] + swap_speed_units = [round(s * 10) for s in swap_speed] + mix_volume_units = [round(v * 10) for v in mix_volume] + mix_speed_units = [round(s * 10) for s in mix_speed] + mix_position_from_liquid_surface_units = [ + round(p * 100) for p in mix_position_from_liquid_surface + ] + cut_off_speed_units = [round(s * 10) for s in cut_off_speed] + stop_back_volume_units = [round(v * 10) for v in stop_back_volume] + dispense_offset_units = [round(o * 100) for o in dispense_offset] + side_touch_off_distance_units = round(params.side_touch_off_distance * 100) + + # Build full-channel arrays + dispense_volumes_full = self._fill_by_channels(dispense_volumes, use_channels, default=0) + blow_out_air_volumes_full = self._fill_by_channels( + blow_out_air_volumes_units, use_channels, default=0 + ) + dispense_speeds_full = self._fill_by_channels(dispense_speeds, use_channels, default=0) + lld_search_height_full = self._fill_by_channels( + lld_search_height_units, use_channels, default=0 + ) + liquid_height_full = self._fill_by_channels(liquid_height_units, use_channels, default=0) + immersion_depth_full = self._fill_by_channels(immersion_depth_units, use_channels, default=0) + surface_following_distance_full = self._fill_by_channels( + surface_following_distance_units, use_channels, default=0 + ) + minimum_height_full = self._fill_by_channels(minimum_height_units, use_channels, default=0) + settling_time_full = self._fill_by_channels(settling_time_units, use_channels, default=0) + transport_air_volume_full = self._fill_by_channels( + transport_air_volume_units, use_channels, default=0 + ) + swap_speed_full = self._fill_by_channels(swap_speed_units, use_channels, default=0) + mix_volume_full = self._fill_by_channels(mix_volume_units, use_channels, default=0) + mix_cycles_full = self._fill_by_channels(mix_cycles, use_channels, default=0) + mix_speed_full = self._fill_by_channels(mix_speed_units, use_channels, default=0) + mix_position_from_liquid_surface_full = self._fill_by_channels( + mix_position_from_liquid_surface_units, use_channels, default=0 + ) + gamma_lld_sensitivity_full = self._fill_by_channels( + gamma_lld_sensitivity, use_channels, default=0 + ) + limit_curve_index_full = self._fill_by_channels(limit_curve_index, use_channels, default=0) + lld_mode_full = self._fill_by_channels(lld_mode, use_channels, default=0) + cut_off_speed_full = self._fill_by_channels(cut_off_speed_units, use_channels, default=0) + stop_back_volume_full = self._fill_by_channels(stop_back_volume_units, use_channels, default=0) + dispense_offset_full = self._fill_by_channels(dispense_offset_units, use_channels, default=0) + + # Default values + dispense_type = [0] * self.num_channels + min_z_endpos = traverse_height_units + mix_surface_following_distance = [0] * self.num_channels + tube_section_height = [0] * self.num_channels + tube_section_ratio = [0] * self.num_channels + recording_mode = 0 + + command = DispenseCommand( + dest=self.pipette_address, + dispense_type=dispense_type, + channels_involved=channels_involved, + x_positions=x_positions_full, + y_positions=y_positions_full, + minimum_traverse_height_at_beginning_of_a_command=traverse_height_units, + lld_search_height=lld_search_height_full, + liquid_height=liquid_height_full, + immersion_depth=immersion_depth_full, + surface_following_distance=surface_following_distance_full, + minimum_height=minimum_height_full, + min_z_endpos=min_z_endpos, + swap_speed=swap_speed_full, + transport_air_volume=transport_air_volume_full, + dispense_volume=dispense_volumes_full, + stop_back_volume=stop_back_volume_full, + blow_out_air_volume=blow_out_air_volumes_full, + dispense_speed=dispense_speeds_full, + cut_off_speed=cut_off_speed_full, + settling_time=settling_time_full, + mix_volume=mix_volume_full, + mix_cycles=mix_cycles_full, + mix_position_from_liquid_surface=mix_position_from_liquid_surface_full, + mix_surface_following_distance=mix_surface_following_distance, + mix_speed=mix_speed_full, + side_touch_off_distance=side_touch_off_distance_units, + dispense_offset=dispense_offset_full, + tube_section_height=tube_section_height, + tube_section_ratio=tube_section_ratio, + lld_mode=lld_mode_full, + gamma_lld_sensitivity=gamma_lld_sensitivity_full, + tadm_enabled=params.tadm_enabled, + limit_curve_index=limit_curve_index_full, + recording_mode=recording_mode, + ) + + await self.driver.send_command(command) + logger.info(f"Dispensed on channels {use_channels}") diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py new file mode 100644 index 00000000000..ac49d3f9622 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py @@ -0,0 +1,136 @@ +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from pylabrobot.hamilton.liquid_handlers.nimbus.chatterbox import NimbusChatterboxDriver +from pylabrobot.hamilton.liquid_handlers.nimbus.commands import GetChannelConfiguration_1 +from pylabrobot.hamilton.liquid_handlers.nimbus.driver import NimbusDriver +from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES +from pylabrobot.hamilton.tcp.packets import Address + +# Stable key from NIMBUS_ERROR_CODES for merge-override tests (must exist in table). +_NIMBUS_OVERRIDE_KEY = (0x0001, 0x0001, 0x0101, 1, 0x0F01) +_NIMBUS_OTHER_KEY = (0x0001, 0x0001, 0x0101, 1, 0x0F02) + + +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) + assert driver.door is not None + + response = await driver.send_command( + GetChannelConfiguration_1(driver.nimbus_core_address), + ensure_connection=False, + return_raw=False, + raise_on_error=False, + read_timeout=0.1, + ) + assert response == {"channels": 8} + + await driver.stop() + + asyncio.run(_run()) + + +def test_assert_required_methods_missing_raises(): + async def _run() -> None: + driver = NimbusDriver(host="127.0.0.1") + + class _Method: + def __init__(self, method_id: int): + self.method_id = method_id + + class _StubIntro: + async def methods_for_interface(self, address, interface_id): # noqa: ARG002 + return [_Method(3)] + + with patch.object( + driver.introspection, + "methods_for_interface", + AsyncMock(return_value=[_Method(3)]), + ): + with pytest.raises(RuntimeError, match="missing required interface-1 methods"): + await driver._assert_required_methods( + Address(1, 1, 48896), + object_name="NimbusCore", + required_method_ids={3, 15}, + ) + + asyncio.run(_run()) + + +def test_nimbus_driver_error_codes_user_values_override_table(): + """NimbusDriver merges NIMBUS_ERROR_CODES with caller dict; same-key entries use the caller. + + Covers the __init__ merge policy used for instrument-specific error enrichment, not + exercised elsewhere (tcp_tests do not assert Nimbus defaults). + """ + driver = NimbusDriver( + host="127.0.0.1", + error_codes={_NIMBUS_OVERRIDE_KEY: "custom text for tests"}, + ) + assert driver._error_codes[_NIMBUS_OVERRIDE_KEY] == "custom text for tests" + assert driver._error_codes[_NIMBUS_OTHER_KEY] == NIMBUS_ERROR_CODES[_NIMBUS_OTHER_KEY] + + +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="NimbusCore address not discovered"): + _ = driver.nimbus_core_address + + +def test_resolve_interfaces_maps_object_names_to_interface_keys(): + """Maps firmware object names (NimbusCore, Pipette) to internal keys; optional DoorLock may be absent.""" + core = Address(1, 1, 100) + pip = Address(1, 1, 200) + + async def _run() -> None: + driver = NimbusDriver(host="127.0.0.1") + await driver._resolve_interfaces({"NimbusCore": core, "Pipette": pip}) + assert driver._resolved_interfaces["nimbus_core"] == core + assert driver._resolved_interfaces["pipette"] == pip + assert driver._resolved_interfaces["door_lock"] is None + + asyncio.run(_run()) + + +def test_resolve_interfaces_missing_required_core_raises(): + """Required InterfaceSpec entries must appear in the discovery map by object_name.""" + pip_only = Address(1, 1, 200) + + async def _run() -> None: + driver = NimbusDriver(host="127.0.0.1") + with pytest.raises(RuntimeError, match="nimbus_core"): + await driver._resolve_interfaces({"Pipette": pip_only}) + + asyncio.run(_run()) + + +def test_assert_required_methods_succeeds_when_all_present(): + """Complements test_assert_required_methods_missing_raises: no false positive when the set is satisfied.""" + + async def _run() -> None: + driver = NimbusDriver(host="127.0.0.1") + + class _Method: + def __init__(self, method_id: int): + self.method_id = method_id + + methods = [_Method(3), _Method(15)] + with patch.object( + driver.introspection, + "methods_for_interface", + AsyncMock(return_value=methods), + ): + await driver._assert_required_methods( + Address(1, 1, 48896), + object_name="NimbusCore", + required_method_ids={3, 15}, + ) + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/liquid_handlers/prep/prep_commands.py b/pylabrobot/hamilton/liquid_handlers/prep/prep_commands.py index 95794a9491f..49417beb9d8 100644 --- a/pylabrobot/hamilton/liquid_handlers/prep/prep_commands.py +++ b/pylabrobot/hamilton/liquid_handlers/prep/prep_commands.py @@ -15,11 +15,12 @@ from enum import IntEnum from typing import Annotated, Optional, Tuple -from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol -from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( +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, @@ -36,10 +37,7 @@ U8Array, U32Array, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( - Enum as WEnum, -) -from pylabrobot.liquid_handling.standard import SingleChannelAspiration +from pylabrobot.capabilities.liquid_handling.standard import Aspiration # ============================================================================= # Enums (mirrored from Prep protocol spec) @@ -477,7 +475,7 @@ class AspirateParameters: def for_op( cls, loc, - op: SingleChannelAspiration, + op: Aspiration, prewet_volume: float = 0.0, blowout_volume: Optional[float] = None, ) -> AspirateParameters: @@ -1225,7 +1223,7 @@ class DispenseParametersLld2: @dataclass -class PrepCommand(HamiltonCommand): +class PrepCommand(TCPCommand): """Base for all Prep instrument commands. Subclasses are dataclasses with ``dest: Address`` (inherited) plus any diff --git a/pylabrobot/hamilton/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py index a54e78c36f0..75a7ab4bf6e 100644 --- a/pylabrobot/hamilton/tcp/__init__.py +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -1,7 +1,7 @@ """Canonical v1 Hamilton TCP namespace.""" from pylabrobot.hamilton.tcp.client import HamiltonTCPClient -from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.introspection import MethodInfo, ObjectInfo from pylabrobot.hamilton.tcp.messages import ( CommandMessage, @@ -40,7 +40,7 @@ "ConnectionPacket", "HAMILTON_PROTOCOL_VERSION_MAJOR", "HAMILTON_PROTOCOL_VERSION_MINOR", - "HamiltonCommand", + "TCPCommand", "HamiltonTCPClient", "HamiltonDataType", "HamiltonProtocol", diff --git a/pylabrobot/hamilton/tcp/client.py b/pylabrobot/hamilton/tcp/client.py index a3342f4c9d1..0e8fd915399 100644 --- a/pylabrobot/hamilton/tcp/client.py +++ b/pylabrobot/hamilton/tcp/client.py @@ -15,7 +15,7 @@ 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 HamiltonCommand, hamilton_error_for_entry +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.messages import ( CommandResponse, @@ -496,7 +496,7 @@ def _allocate_sequence_number(self, dest_address: Address) -> int: async def send_command( self, - command: HamiltonCommand, + command: TCPCommand, ensure_connection: bool = True, return_raw: bool = False, raise_on_error: bool = True, diff --git a/pylabrobot/hamilton/tcp/commands.py b/pylabrobot/hamilton/tcp/commands.py index ca40e42138f..8101393b92f 100644 --- a/pylabrobot/hamilton/tcp/commands.py +++ b/pylabrobot/hamilton/tcp/commands.py @@ -1,6 +1,6 @@ """Command layer for Hamilton TCP. -HamiltonCommand base: build_parameters() returns HoiParams; interpret_response() +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. """ @@ -23,17 +23,17 @@ 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: + This replaces the old command base from tcp_codec.py with a cleaner design: - Explicitly uses CommandMessage for building packets - build_parameters() returns HoiParams object (not bytes) - Uses Address instead of ObjectAddress - Cleaner separation of concerns Example: - class MyCommand(HamiltonCommand): + class MyCommand(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 command_id = 42 @@ -63,7 +63,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 diff --git a/pylabrobot/hamilton/tcp/introspection.py b/pylabrobot/hamilton/tcp/introspection.py index 351756a3780..ff6242c2112 100644 --- a/pylabrobot/hamilton/tcp/introspection.py +++ b/pylabrobot/hamilton/tcp/introspection.py @@ -41,7 +41,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Literal, Optional, Protocol, 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, @@ -88,7 +88,7 @@ def global_object_addresses(self) -> Sequence[Address]: ... async def send_command( self, - command: HamiltonCommand, + command: TCPCommand, *, ensure_connection: bool = True, return_raw: bool = False, @@ -1019,7 +1019,7 @@ def _resolve_struct_field_type( # ============================================================================ -class GetObjectCommand(HamiltonCommand): +class GetObjectCommand(TCPCommand): """Get object metadata (command_id=1).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -1038,7 +1038,7 @@ class Response: subobject_count: U16 -class GetMethodCommand(HamiltonCommand): +class GetMethodCommand(TCPCommand): """Get method signature (command_id=2).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -1124,7 +1124,7 @@ def parse_response_parameters(cls, data: bytes) -> dict: } -class GetSubobjectAddressCommand(HamiltonCommand): +class GetSubobjectAddressCommand(TCPCommand): """Get subobject address (command_id=3).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -1147,7 +1147,7 @@ class Response: object_id: U16 -class GetInterfacesCommand(HamiltonCommand): +class GetInterfacesCommand(TCPCommand): """Get available interfaces (command_id=4). Firmware signature: InterfaceDescriptors(()) -> interfaceIds: I8_ARRAY, interfaceDescriptors: STRING_ARRAY @@ -1168,7 +1168,7 @@ class Response: interface_names: StrArray -class GetEnumsCommand(HamiltonCommand): +class GetEnumsCommand(TCPCommand): """Get enum definitions (command_id=5). Firmware signature: EnumInfo(interfaceId) -> enumerationNames: STRING_ARRAY, @@ -1198,7 +1198,7 @@ class Response: value_names: StrArray -class GetStructsCommand(HamiltonCommand): +class GetStructsCommand(TCPCommand): """Get struct definitions (command_id=6).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY diff --git a/pylabrobot/hamilton/tcp/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py index aac0bf03f09..c8e0f4fa813 100644 --- a/pylabrobot/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -18,7 +18,7 @@ import pylabrobot.hamilton.tcp.introspection as introspection_mod from pylabrobot.hamilton.tcp.client import HamiltonTCPClient -from pylabrobot.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.introspection import ( EnumInfo, GlobalTypePool, @@ -245,9 +245,9 @@ def test_init_message_is_protocol_7(self): self.assertEqual(packet[2], 7) -class TestHamiltonCommandBehavior(unittest.TestCase): +class TestTCPCommandBehavior(unittest.TestCase): def test_build_requires_source_address(self): - class Cmd(HamiltonCommand): + class Cmd(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 command_id = 1 @@ -256,7 +256,7 @@ class Cmd(HamiltonCommand): Cmd(Address(1, 1, 257)).build() def test_interpret_response_auto_decodes_nested_response(self): - class Cmd(HamiltonCommand): + class Cmd(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 1 command_id = 0 @@ -305,7 +305,7 @@ async def _fake_resolve_path(path: str) -> Address: self.assertEqual(got, Address(1, 1, 999)) def test_send_command_return_raw_returns_hoi_payload_tuple(self): - class Cmd(HamiltonCommand): + class Cmd(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 command_id = 1 From f47f3015737f9609628cc83f9372ba22a830e3e3 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:03:10 -0700 Subject: [PATCH 04/14] Nimbus: Normalize interface discovery --- .../liquid_handlers/nimbus/__init__.py | 2 +- .../liquid_handlers/nimbus/chatterbox.py | 4 +- .../hamilton/liquid_handlers/nimbus/driver.py | 137 ++++++++---------- .../nimbus/tests/driver_tests.py | 52 ++++--- pylabrobot/hamilton/tcp/__init__.py | 12 +- pylabrobot/hamilton/tcp/client.py | 10 ++ pylabrobot/hamilton/tcp/interface_bundle.py | 72 +++++++++ pylabrobot/hamilton/tcp/introspection.py | 17 +++ pylabrobot/hamilton/tcp/tcp_tests.py | 35 +++++ 9 files changed, 241 insertions(+), 100 deletions(-) create mode 100644 pylabrobot/hamilton/tcp/interface_bundle.py diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py index 9bb82dffb2c..5fc622586ad 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py @@ -1,5 +1,5 @@ from .chatterbox import NimbusChatterboxDriver from .door import NimbusDoor -from .driver import NimbusDriver, NimbusSetupParams +from .driver import NimbusDriver, NimbusResolvedInterfaces, NimbusSetupParams, nimbus_interface_specs_for_root from .nimbus import Nimbus from .pip_backend import NimbusPIPBackend diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py index 5c5b63c86cf..76e994ae938 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py @@ -10,7 +10,7 @@ from pylabrobot.hamilton.tcp.packets import Address from .door import NimbusDoor -from .driver import NimbusDriver, NimbusSetupParams +from .driver import NimbusDriver, NimbusResolvedInterfaces, NimbusSetupParams logger = logging.getLogger(__name__) @@ -50,6 +50,7 @@ async def setup(self, backend_params: Optional[BackendParams] = None): "pipette": pipette_address, "door_lock": door_address, } + self._nimbus_resolved = NimbusResolvedInterfaces.from_resolution_map(self._resolved_interfaces) self.pip = NimbusPIPBackend( driver=self, deck=params.deck, address=pipette_address, num_channels=self._num_channels @@ -63,6 +64,7 @@ async def stop(self): await self.door._on_stop() self.door = None self._resolved_interfaces = {} + self._nimbus_resolved = None async def send_command( self, diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py index b015f06e91f..4f0c55f1c03 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -4,11 +4,12 @@ import logging from dataclasses import dataclass -from typing import Dict, Optional, Set, Tuple +from typing import Dict, Mapping, Optional, Set, Tuple from pylabrobot.capabilities.capability import BackendParams from pylabrobot.hamilton.tcp.client import HamiltonTCPClient from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES +from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.packets import Address from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck @@ -23,11 +24,34 @@ logger = logging.getLogger(__name__) +def nimbus_interface_specs_for_root(root_name: str) -> Dict[str, InterfacePathSpec]: + """Dot-paths under the instrument root (same mechanism as :class:`PrepDriver`).""" + return { + "nimbus_core": InterfacePathSpec(root_name, True, True), + "pipette": InterfacePathSpec(f"{root_name}.Pipette", True, True), + "door_lock": InterfacePathSpec(f"{root_name}.DoorLock", False, False), + } + + @dataclass(frozen=True) -class InterfaceSpec: - object_name: str - required: bool - raise_when_missing: bool = True +class NimbusResolvedInterfaces: + """Concrete Nimbus firmware handles after :meth:`NimbusDriver.setup`.""" + + nimbus_core: Address + pipette: Address + door_lock: Optional[Address] + + @staticmethod + def from_resolution_map(m: Mapping[str, Optional[Address]]) -> NimbusResolvedInterfaces: + nc = m.get("nimbus_core") + pip = m.get("pipette") + if nc is None or pip is None: + raise RuntimeError("internal: missing required Nimbus interfaces") + return NimbusResolvedInterfaces( + nimbus_core=nc, + pipette=pip, + door_lock=m.get("door_lock"), + ) @dataclass @@ -43,12 +67,6 @@ class NimbusDriver(HamiltonTCPClient): manages the PIP backend and door subsystem. """ - _INTERFACES = { - "nimbus_core": InterfaceSpec("NimbusCore", required=True), - "pipette": InterfaceSpec("Pipette", required=True), - "door_lock": InterfaceSpec("DoorLock", required=False, raise_when_missing=False), - } - _REQUIRED_METHODS_CORE: Set[int] = { 3, 14, @@ -93,14 +111,21 @@ def __init__( self._nimbus_core_address: Optional[Address] = None self._resolved_interfaces: Dict[str, Optional[Address]] = {} + self._nimbus_resolved: Optional[NimbusResolvedInterfaces] = None self.pip: NimbusPIPBackend # set in setup() self.door: Optional[NimbusDoor] = None # set in setup() if available + @property + def nimbus_interfaces(self) -> NimbusResolvedInterfaces: + if self._nimbus_resolved is None: + raise RuntimeError("Nimbus interfaces not resolved. Call setup() first.") + return self._nimbus_resolved + @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, backend_params: Optional[BackendParams] = None): @@ -122,16 +147,30 @@ async def setup(self, backend_params: Optional[BackendParams] = None): # TCP connection + Protocol 7 + Protocol 3 + root discovery await super().setup() - addresses = await self._discover_instrument_objects() - await self._resolve_interfaces(addresses) + root_objects = self.get_root_object_addresses() + if not root_objects: + raise RuntimeError("No root objects discovered during setup.") + + root_info = await self.introspection.get_object(root_objects[0]) + if "nimbus" not in root_info.name.lower(): + raise RuntimeError( + f"Expected a Nimbus root object, but discovered '{root_info.name}'. Wrong instrument?" + ) + + specs = nimbus_interface_specs_for_root(root_info.name) + self._resolved_interfaces = await resolve_interface_path_specs( + self, specs, instrument_label="Nimbus" + ) + self._nimbus_resolved = NimbusResolvedInterfaces.from_resolution_map(self._resolved_interfaces) + self._nimbus_core_address = self._nimbus_resolved.nimbus_core - nimbus_core_address = await self._require_interface("nimbus_core") - pipette_address = await self._require_interface("pipette") - door_address = self._resolved_interfaces.get("door_lock") + nimbus_core_address = self._nimbus_resolved.nimbus_core + pipette_address = self._nimbus_resolved.pipette + door_address = self._nimbus_resolved.door_lock await self._assert_required_methods( nimbus_core_address, - object_name="NimbusCore", + object_name=root_info.name, required_method_ids=self._REQUIRED_METHODS_CORE, ) await self._assert_required_methods( @@ -167,67 +206,7 @@ async def stop(self): await super().stop() self.door = None self._resolved_interfaces = {} - - 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. - """ - addresses: Dict[str, Address] = {} - - root_objects = self.get_root_object_addresses() - if not root_objects: - raise RuntimeError("No root objects discovered during setup.") - - nimbus_core_addr = root_objects[0] - root_info = await self.introspection.get_object(nimbus_core_addr) - if "nimbus" not in root_info.name.lower(): - raise RuntimeError( - f"Expected a Nimbus root object, but discovered '{root_info.name}'. Wrong instrument?" - ) - addresses[root_info.name] = nimbus_core_addr - self._nimbus_core_address = nimbus_core_addr - - for i in range(root_info.subobject_count): - try: - sub_addr = await self.introspection.get_subobject_address(nimbus_core_addr, i) - sub_info = await self.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}") - - if "DoorLock" not in addresses: - logger.info("DoorLock not available on this instrument") - - return addresses - - async def _resolve_interfaces(self, discovered: Dict[str, Address]) -> None: - self._resolved_interfaces = {} - for key, spec in self._INTERFACES.items(): - addr = discovered.get(spec.object_name) - if addr is None: - if spec.required: - raise RuntimeError( - f"Could not find required interface '{key}' ({spec.object_name}) on Nimbus." - ) - self._resolved_interfaces[key] = None - else: - self._resolved_interfaces[key] = addr - - async def _require_interface(self, name: str) -> Address: - if name not in self._INTERFACES: - raise KeyError(f"Unknown interface: {name}") - - spec = self._INTERFACES[name] - addr = self._resolved_interfaces.get(name) - if addr is None: - msg = f"Could not find interface '{name}' ({spec.object_name}) on Nimbus." - if spec.raise_when_missing: - logger.warning(msg) - raise RuntimeError(msg) - return addr + self._nimbus_resolved = None async def _assert_required_methods( self, diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py index ac49d3f9622..8f468613e44 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py @@ -5,7 +5,12 @@ from pylabrobot.hamilton.liquid_handlers.nimbus.chatterbox import NimbusChatterboxDriver from pylabrobot.hamilton.liquid_handlers.nimbus.commands import GetChannelConfiguration_1 -from pylabrobot.hamilton.liquid_handlers.nimbus.driver import NimbusDriver +from pylabrobot.hamilton.liquid_handlers.nimbus.driver import ( + NimbusDriver, + NimbusResolvedInterfaces, + nimbus_interface_specs_for_root, +) +from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES from pylabrobot.hamilton.tcp.packets import Address @@ -80,33 +85,44 @@ def test_nimbus_driver_error_codes_user_values_override_table(): 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="NimbusCore address not discovered"): + with pytest.raises(RuntimeError, match="Nimbus root address not discovered"): _ = driver.nimbus_core_address -def test_resolve_interfaces_maps_object_names_to_interface_keys(): - """Maps firmware object names (NimbusCore, Pipette) to internal keys; optional DoorLock may be absent.""" +def test_nimbus_interface_specs_for_root_paths(): + """Root-relative dot-paths match firmware tree naming (e.g. NimbusCORE.Pipette).""" + s = nimbus_interface_specs_for_root("NimbusCORE") + assert s["nimbus_core"].path == "NimbusCORE" + assert s["pipette"].path == "NimbusCORE.Pipette" + assert s["door_lock"].path == "NimbusCORE.DoorLock" + assert s["door_lock"].required is False + + +def test_nimbus_resolved_interfaces_from_map_optional_door(): core = Address(1, 1, 100) pip = Address(1, 1, 200) + r = NimbusResolvedInterfaces.from_resolution_map( + {"nimbus_core": core, "pipette": pip, "door_lock": None} + ) + assert r.nimbus_core == core + assert r.pipette == pip + assert r.door_lock is None - async def _run() -> None: - driver = NimbusDriver(host="127.0.0.1") - await driver._resolve_interfaces({"NimbusCore": core, "Pipette": pip}) - assert driver._resolved_interfaces["nimbus_core"] == core - assert driver._resolved_interfaces["pipette"] == pip - assert driver._resolved_interfaces["door_lock"] is None - - asyncio.run(_run()) +def test_resolve_interface_path_specs_required_missing_raises(): + async def _run() -> None: + from unittest.mock import AsyncMock -def test_resolve_interfaces_missing_required_core_raises(): - """Required InterfaceSpec entries must appear in the discovery map by object_name.""" - pip_only = Address(1, 1, 200) + from pylabrobot.hamilton.tcp.client import HamiltonTCPClient - async def _run() -> None: - driver = NimbusDriver(host="127.0.0.1") + tcp = HamiltonTCPClient(host="127.0.0.1", port=2000) + tcp.resolve_path = AsyncMock(side_effect=KeyError) # type: ignore[method-assign] with pytest.raises(RuntimeError, match="nimbus_core"): - await driver._resolve_interfaces({"Pipette": pip_only}) + await resolve_interface_path_specs( + tcp, + {"nimbus_core": InterfacePathSpec("NimbusCORE", True)}, + instrument_label="Nimbus", + ) asyncio.run(_run()) diff --git a/pylabrobot/hamilton/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py index 75a7ab4bf6e..fe3fcce4255 100644 --- a/pylabrobot/hamilton/tcp/__init__.py +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -1,8 +1,14 @@ """Canonical v1 Hamilton TCP namespace.""" from pylabrobot.hamilton.tcp.client import HamiltonTCPClient +from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.commands import TCPCommand -from pylabrobot.hamilton.tcp.introspection import MethodInfo, ObjectInfo +from pylabrobot.hamilton.tcp.introspection import ( + FirmwareTree, + MethodInfo, + ObjectInfo, + flatten_firmware_tree, +) from pylabrobot.hamilton.tcp.messages import ( CommandMessage, CommandResponse, @@ -35,9 +41,13 @@ __all__ = [ "Address", + "InterfacePathSpec", + "resolve_interface_path_specs", "CommandMessage", "CommandResponse", "ConnectionPacket", + "FirmwareTree", + "flatten_firmware_tree", "HAMILTON_PROTOCOL_VERSION_MAJOR", "HAMILTON_PROTOCOL_VERSION_MINOR", "TCPCommand", diff --git a/pylabrobot/hamilton/tcp/client.py b/pylabrobot/hamilton/tcp/client.py index 0e8fd915399..511f9e5f06e 100644 --- a/pylabrobot/hamilton/tcp/client.py +++ b/pylabrobot/hamilton/tcp/client.py @@ -30,6 +30,7 @@ HamiltonIntrospection, MethodDescriptor, ObjectRegistry, + flatten_firmware_tree, ) from pylabrobot.hamilton.tcp.packets import Address from pylabrobot.hamilton.tcp.protocol import ( @@ -660,6 +661,15 @@ async def get_firmware_tree(self, refresh: bool = False): """Return cached firmware tree, or build it through introspection.""" return await self.introspection.get_firmware_tree(refresh=refresh) + async def get_firmware_tree_flat(self, refresh: bool = False): + """Firmware object tree as a flat list of ``(path, address, object_info)`` per node. + + Same preorder as :func:`~pylabrobot.hamilton.tcp.introspection.flatten_firmware_tree`; + convenient for dot-path indexing without walking :class:`FirmwareTree` manually. + """ + tree = await self.get_firmware_tree(refresh=refresh) + return flatten_firmware_tree(tree) + async def stop(self): try: await self.io.stop() diff --git a/pylabrobot/hamilton/tcp/interface_bundle.py b/pylabrobot/hamilton/tcp/interface_bundle.py new file mode 100644 index 00000000000..bdd749fc135 --- /dev/null +++ b/pylabrobot/hamilton/tcp/interface_bundle.py @@ -0,0 +1,72 @@ +"""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 ff6242c2112..4d6eae933a8 100644 --- a/pylabrobot/hamilton/tcp/introspection.py +++ b/pylabrobot/hamilton/tcp/introspection.py @@ -430,6 +430,23 @@ def __str__(self) -> str: return self.format() +def flatten_firmware_tree(tree: FirmwareTree) -> List[Tuple[str, Address, ObjectInfo]]: + """Preorder flattening of :class:`FirmwareTree` for path-keyed lookups. + + Returns ``(dot_path, address, object_info)`` for each node (roots first, DFS). + """ + out: List[Tuple[str, Address, ObjectInfo]] = [] + + def walk(node: FirmwareTreeNode) -> None: + out.append((node.path, node.address, node.object_info)) + for child in node.children: + walk(child) + + for root in tree.roots: + walk(root) + return out + + @dataclass class ParameterType: """A resolved type reference used for both method parameters and struct fields. diff --git a/pylabrobot/hamilton/tcp/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py index c8e0f4fa813..1db907e7e53 100644 --- a/pylabrobot/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -21,6 +21,8 @@ from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.introspection import ( EnumInfo, + FirmwareTree, + FirmwareTreeNode, GlobalTypePool, HamiltonIntrospection, InterfaceInfo, @@ -30,6 +32,7 @@ ParameterType, StructInfo, TypeRegistry, + flatten_firmware_tree, ) from pylabrobot.hamilton.tcp.messages import ( CommandMessage, @@ -409,6 +412,38 @@ async def fake_get_subobject_address(_addr: Address, idx: int) -> Address: 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=1, 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) + child = FirmwareTreeNode(path="R.c", address=a1, object_info=o1, children=[]) + root = FirmwareTreeNode(path="R", address=a0, object_info=o0, children=[child]) + other_root = FirmwareTreeNode(path="S", address=a2, object_info=o2, children=[]) + tree = FirmwareTree(roots=[root, other_root]) + flat = flatten_firmware_tree(tree) + self.assertEqual([p for p, _, _ in flat], ["R", "R.c", "S"]) + + 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=[]) + tree = FirmwareTree(roots=[root]) + + async def fake_get_firmware_tree(refresh: bool = False): + del refresh + return tree + + client.get_firmware_tree = fake_get_firmware_tree # type: ignore[method-assign] + got = asyncio.run(client.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): From 8c929c6a6735dd65117179f133a6dcadc1c79dac Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:17:21 -0700 Subject: [PATCH 05/14] Hamilton: shared liquid-class resolver; Nimbus PIP HLC defaults; TCP Nimbus errors & status exceptions --- .../liquid_handlers/liquid_class_resolver.py | 104 +++++ .../liquid_handlers/nimbus/commands.py | 6 +- .../liquid_handlers/nimbus/pip_backend.py | 209 +++++++--- .../nimbus/tests/pip_backend_tests.py | 373 ++++++++++++++++++ .../liquid_handlers/star/pip_backend.py | 53 +-- .../liquid_handlers/tests/__init__.py | 0 .../tests/liquid_class_resolver_tests.py | 109 +++++ pylabrobot/hamilton/tcp/__init__.py | 2 + pylabrobot/hamilton/tcp/client.py | 95 ++++- pylabrobot/hamilton/tcp/commands.py | 21 + pylabrobot/hamilton/tcp/error_tables.py | 2 +- pylabrobot/hamilton/tcp/status_exception.py | 32 ++ pylabrobot/hamilton/tcp/tcp_tests.py | 176 ++++++++- 13 files changed, 1063 insertions(+), 119 deletions(-) create mode 100644 pylabrobot/hamilton/liquid_handlers/liquid_class_resolver.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py create mode 100644 pylabrobot/hamilton/liquid_handlers/tests/__init__.py create mode 100644 pylabrobot/hamilton/liquid_handlers/tests/liquid_class_resolver_tests.py create mode 100644 pylabrobot/hamilton/tcp/status_exception.py diff --git a/pylabrobot/hamilton/liquid_handlers/liquid_class_resolver.py b/pylabrobot/hamilton/liquid_handlers/liquid_class_resolver.py new file mode 100644 index 00000000000..2e34e5c4208 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/liquid_class_resolver.py @@ -0,0 +1,104 @@ +"""Resolve Hamilton liquid classes and corrected volumes for PIP backends. + +Lives alongside :mod:`liquid_class`. Automatic lookup defaults to +:class:`~pylabrobot.hamilton.liquid_handlers.star.liquid_classes.get_star_liquid_class` +(STAR calibration tables); pass ``lookup=`` for instrument-specific tables. +""" + +from __future__ import annotations + +from typing import Any, Callable, List, Optional, Sequence, Union + +from pylabrobot.hamilton.liquid_handlers.liquid_class import HamiltonLiquidClass +from pylabrobot.resources.hamilton import HamiltonTip +from pylabrobot.resources.liquid import Liquid + +_Lookup = Callable[..., Optional[HamiltonLiquidClass]] + + +def resolve_hamilton_liquid_classes( + explicit: Optional[List[Optional[HamiltonLiquidClass]]], + ops: list, + *, + jet: Union[bool, List[bool]] = False, + blow_out: Union[bool, List[bool]] = False, + is_aspirate: bool = True, + lookup: Optional[_Lookup] = None, +) -> List[Optional[HamiltonLiquidClass]]: + """Resolve per-op Hamilton liquid classes. + + If ``explicit`` is None, resolve from each op's tip via ``lookup`` (default + :func:`get_star_liquid_class`). Non-``HamiltonTip`` tips yield ``None``. + + If ``explicit`` is a list, it is returned as a shallow copy; ``None`` entries + are preserved (legacy STAR behavior). + + Args: + explicit: Caller-provided liquid classes, or None for automatic lookup. + ops: Aspiration or dispense operations (must have a ``tip`` attribute). + jet: Per-op or scalar flags passed to automatic liquid class lookup. + blow_out: Per-op or scalar flags passed to automatic liquid class lookup. + is_aspirate: Reserved for API compatibility with STAR; unused. + lookup: Optional callable with the same signature as ``get_star_liquid_class``. + """ + del is_aspirate + n = len(ops) + if isinstance(jet, bool): + jet = [jet] * n + if isinstance(blow_out, bool): + blow_out = [blow_out] * n + + if explicit is not None: + return list(explicit) + + if lookup is None: + # Lazy import avoids circular import: star package __init__ may pull in pip_backend, + # which imports this module. + from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import get_star_liquid_class + + fn = get_star_liquid_class + else: + fn = lookup + result: List[Optional[HamiltonLiquidClass]] = [] + for i, op in enumerate(ops): + tip = op.tip + if not isinstance(tip, HamiltonTip): + result.append(None) + continue + result.append( + fn( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=jet[i], + blow_out=blow_out[i], + ) + ) + + return result + + +def corrected_volumes_for_ops( + ops: Sequence[Any], + hlcs: Sequence[Optional[HamiltonLiquidClass]], + disable_volume_correction: Optional[Sequence[bool]] = None, +) -> List[float]: + """Apply liquid-class volume correction per op when enabled.""" + n = len(ops) + if len(hlcs) != n: + raise ValueError(f"hlcs length must match ops ({n}), got {len(hlcs)}") + dvc = ( + list(disable_volume_correction) if disable_volume_correction is not None else [False] * n + ) + if len(dvc) != n: + raise ValueError( + f"disable_volume_correction length must match ops ({n}), got {len(dvc)}" + ) + return [ + float(hlc.compute_corrected_volume(op.volume)) + if hlc is not None and not disabled + else float(op.volume) + for op, hlc, disabled in zip(ops, hlcs, dvc) + ] diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py index 31a4863a374..3cc6f56292a 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py @@ -470,7 +470,8 @@ class Aspirate(NimbusCommand): Units: - linear positions/heights: 0.01 mm - volumes: 0.1 uL - - flow rates: 0.1 uL/s + - 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 Field meanings: @@ -538,7 +539,8 @@ class Dispense(NimbusCommand): Units: - linear positions/heights: 0.01 mm - volumes: 0.1 uL - - flow rates: 0.1 uL/s + - 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: diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py index 132f48589f3..5dd500a3f2f 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -3,12 +3,17 @@ 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 @@ -76,6 +81,13 @@ 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 +107,13 @@ 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 +133,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 # --------------------------------------------------------------------------- @@ -607,7 +657,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). @@ -616,11 +668,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) @@ -679,38 +727,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 ) @@ -728,7 +809,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 = [ @@ -848,7 +930,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). @@ -860,11 +944,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) @@ -923,44 +1003,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 @@ -974,7 +1088,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 = [ 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..7207cb2a87d --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py @@ -0,0 +1,373 @@ +"""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, Dispense as DispenseCmd +from pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend import ( + NimbusPIPAspirateParams, + NimbusPIPDispenseParams, + NimbusPIPBackend, +) +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={"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={"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={"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={"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={"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={"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/star/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py index c58b42fe599..bd4b1c8de3c 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py @@ -16,18 +16,15 @@ get_tight_single_resource_liquid_op_offsets, get_wide_single_resource_liquid_op_offsets, ) -from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import ( - HamiltonLiquidClass, - get_star_liquid_class, -) +from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import HamiltonLiquidClass from pylabrobot.resources import Resource, Tip, TipSpot, Well from pylabrobot.resources.hamilton import HamiltonTip, TipDropMethod, TipPickupMethod, TipSize -from pylabrobot.resources.liquid import Liquid from .errors import ( STARFirmwareError, convert_star_firmware_error_to_plr_error, ) +from ..liquid_class_resolver import resolve_hamilton_liquid_classes from .pip_channel import PIPChannel if TYPE_CHECKING: @@ -157,48 +154,6 @@ class LLDMode(enum.Enum): # --------------------------------------------------------------------------- -def _resolve_liquid_classes( - explicit: Optional[List[Optional[HamiltonLiquidClass]]], - ops: list, - jet: Union[bool, List[bool]], - blow_out: Union[bool, List[bool]], - is_aspirate: bool, -) -> List[Optional[HamiltonLiquidClass]]: - """Resolve per-op Hamilton liquid classes. - - If ``explicit`` is None, auto-detect from tip properties for each op. - If ``explicit`` is a list, use it as-is (None entries stay None, matching legacy behavior). - """ - n = len(ops) - if isinstance(jet, bool): - jet = [jet] * n - if isinstance(blow_out, bool): - blow_out = [blow_out] * n - - if explicit is not None: - return list(explicit) - - result: List[Optional[HamiltonLiquidClass]] = [] - for i, op in enumerate(ops): - tip = op.tip - if not isinstance(tip, HamiltonTip): - result.append(None) - continue - result.append( - get_star_liquid_class( - tip_volume=tip.maximal_volume, - is_core=False, - is_tip=True, - has_filter=tip.has_filter, - liquid=Liquid.WATER, - jet=jet[i], - blow_out=blow_out[i], - ) - ) - - return result - - def _fill(val: Optional[List], default: List) -> List: """Return *val* if given, otherwise *default*. Replace per-element None with default.""" if val is None: @@ -697,7 +652,7 @@ async def aspirate( n = len(ops) # Resolve liquid classes (auto-detect from tip if not provided). - hlcs = _resolve_liquid_classes( + hlcs = resolve_hamilton_liquid_classes( backend_params.hamilton_liquid_classes, ops, jet=backend_params.jet or False, @@ -1178,7 +1133,7 @@ async def dispense( ] # Resolve liquid classes. - hlcs = _resolve_liquid_classes( + hlcs = resolve_hamilton_liquid_classes( backend_params.hamilton_liquid_classes, ops, jet=jet, blow_out=blow_out, is_aspirate=False ) diff --git a/pylabrobot/hamilton/liquid_handlers/tests/__init__.py b/pylabrobot/hamilton/liquid_handlers/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/liquid_handlers/tests/liquid_class_resolver_tests.py b/pylabrobot/hamilton/liquid_handlers/tests/liquid_class_resolver_tests.py new file mode 100644 index 00000000000..75640f17201 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/tests/liquid_class_resolver_tests.py @@ -0,0 +1,109 @@ +"""Tests for :mod:`liquid_class_resolver`.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +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.liquid_handlers.star.liquid_classes import get_star_liquid_class +from pylabrobot.resources.hamilton import HamiltonTip, TipPickupMethod, TipSize +from pylabrobot.resources.liquid import Liquid + + +def _hlc(**overrides: float) -> HamiltonLiquidClass: + base = dict( + curve={0.0: 0.0, 1000.0: 1000.0}, + aspiration_flow_rate=1.0, + aspiration_mix_flow_rate=2.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=4.0, + aspiration_swap_speed=5.0, + aspiration_settling_time=6.0, + aspiration_over_aspirate_volume=7.0, + aspiration_clot_retract_height=8.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, + ) + base.update(overrides) + return HamiltonLiquidClass(**base) + + +def test_resolve_explicit_returns_copy(): + h = _hlc() + out = resolve_hamilton_liquid_classes([h], [], jet=False, blow_out=False) + assert out == [h] + out[0] = None # type: ignore[assignment] + assert h is not None + + +def test_resolve_auto_non_hamilton_tip_is_none(): + op = SimpleNamespace(tip=object()) + assert resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False) == [None] + + +def test_resolve_auto_hamilton_tip_matches_get_star(): + 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, + ) + op = SimpleNamespace(tip=tip) + a = resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False)[0] + b = get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + assert a is not None and b is not None + assert a.aspiration_flow_rate == b.aspiration_flow_rate + + +def test_resolve_custom_lookup(): + custom = _hlc(aspiration_flow_rate=99.0) + + def lookup(**kwargs): # noqa: ARG001 + return custom + + 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, + ) + op = SimpleNamespace(tip=tip) + got = resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False, lookup=lookup)[0] + assert got is not None + assert got.aspiration_flow_rate == 99.0 + + +def test_corrected_volumes_respects_disable_and_none_hlc(): + ops = [SimpleNamespace(volume=100.0)] + hlc = _hlc(curve={0.0: 0.0, 100.0: 200.0, 200.0: 400.0}) + assert corrected_volumes_for_ops(ops, [hlc], None) == [200.0] + assert corrected_volumes_for_ops(ops, [hlc], [True]) == [100.0] + assert corrected_volumes_for_ops(ops, [None], None) == [100.0] + + +def test_corrected_volumes_length_mismatch_raises(): + with pytest.raises(ValueError, match="hlcs length"): + corrected_volumes_for_ops([SimpleNamespace(volume=1.0)], []) diff --git a/pylabrobot/hamilton/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py index fe3fcce4255..b873e209089 100644 --- a/pylabrobot/hamilton/tcp/__init__.py +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -1,6 +1,7 @@ """Canonical v1 Hamilton TCP namespace.""" from pylabrobot.hamilton.tcp.client import HamiltonTCPClient +from pylabrobot.hamilton.tcp.status_exception import HamiltonStatusException from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.introspection import ( @@ -50,6 +51,7 @@ "flatten_firmware_tree", "HAMILTON_PROTOCOL_VERSION_MAJOR", "HAMILTON_PROTOCOL_VERSION_MINOR", + "HamiltonStatusException", "TCPCommand", "HamiltonTCPClient", "HamiltonDataType", diff --git a/pylabrobot/hamilton/tcp/client.py b/pylabrobot/hamilton/tcp/client.py index 511f9e5f06e..df1096f68bf 100644 --- a/pylabrobot/hamilton/tcp/client.py +++ b/pylabrobot/hamilton/tcp/client.py @@ -14,6 +14,7 @@ from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError +from pylabrobot.hamilton.tcp.status_exception import HamiltonStatusException 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 @@ -91,9 +92,27 @@ async def describe_entry(self, entry: HcResultEntry) -> Tuple[Optional[str], str addr = Address(entry.module_id, entry.node_id, entry.object_id) iface_name = await self._client.introspection.get_interface_name(addr, entry.interface_id) - desc = self._client._error_codes.get( - (entry.module_id, entry.node_id, entry.object_id, entry.action_id, entry.result) + # Vendor tables (e.g. :data:`~pylabrobot.hamilton.tcp.error_tables.NIMBUS_ERROR_CODES`) + # key on ``(module, node, object_id, interface_id, hc_result)``. The wire ``action_id`` is the + # failing method id (e.g. PickupTips) 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._client._error_codes.get(key_iface) + if desc is None and key_action != key_iface: + desc = self._client._error_codes.get(key_action) if desc is None: desc = HC_RESULT_PROTOCOL.get(entry.result) if desc is None: @@ -566,35 +585,73 @@ async def send_command( logger.debug(enriched_msg) return None - per_channel: Dict[int, Exception] = {} - context_by_channel: Dict[int, Optional[str]] = {} + if command.error_entries_use_physical_channels(): + per_channel: Dict[int, Exception] = {} + context_by_channel: Dict[int, Optional[str]] = {} + for idx, entry in enumerate(entries): + _iface_name, desc = await self._hc_result_text.describe_entry(entry) + err = hamilton_error_for_entry(entry, desc) + 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._hc_result_text.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) + 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._hc_result_text.describe_entry(entry) err = hamilton_error_for_entry(entry, desc) - 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._hc_result_text.format_entry_context(entry) + entry_errors[idx] = err + context_by_idx[idx] = await self._hc_result_text.format_entry_context(entry) if raise_on_error: - channel_summary = ", ".join( + 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]}" + f"entry[{idx}]: {entry_errors[idx]} ({context_by_idx[idx]})" + if context_by_idx.get(idx) + else f"entry[{idx}]: {entry_errors[idx]}" ) - for ch in sorted(per_channel) + for idx in sorted(entry_errors) ) logger.error( - "Hamilton %s (action=%#x) on %d channel(s): %s", + "Hamilton %s (action=%#x), instrument-wide error (%d entries): %s", action.name, action, - len(per_channel), - channel_summary, + len(entries), + summary, + ) + raise HamiltonStatusException( + errors=entry_errors, + entries=list(entries), + raw_response=response_message.hoi.params, ) - raise ChannelizedError(errors=per_channel, raw_response=response_message.hoi.params) logger.debug( "Hamilton %s (action=%#x) suppressed; entries=%d (raise_on_error=False)", action.name, diff --git a/pylabrobot/hamilton/tcp/commands.py b/pylabrobot/hamilton/tcp/commands.py index 8101393b92f..a67e9d51fe8 100644 --- a/pylabrobot/hamilton/tcp/commands.py +++ b/pylabrobot/hamilton/tcp/commands.py @@ -8,6 +8,7 @@ from __future__ import annotations import inspect +from dataclasses import fields, is_dataclass from typing import Any, Optional from pylabrobot.hamilton.tcp.messages import ( @@ -165,6 +166,26 @@ def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Op """ return entry_index + def error_entries_use_physical_channels(self) -> bool: + """Whether ``STATUS_EXCEPTION`` entries should be mapped to PLR channel indices. + + 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.status_exception.HamiltonStatusException` + instead of attributing errors to synthetic ``ch0``. + """ + 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. diff --git a/pylabrobot/hamilton/tcp/error_tables.py b/pylabrobot/hamilton/tcp/error_tables.py index 306fd08dfc7..7a4ec26ee68 100644 --- a/pylabrobot/hamilton/tcp/error_tables.py +++ b/pylabrobot/hamilton/tcp/error_tables.py @@ -9,7 +9,7 @@ ------ - ``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, action_id, code): +- ``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. diff --git a/pylabrobot/hamilton/tcp/status_exception.py b/pylabrobot/hamilton/tcp/status_exception.py new file mode 100644 index 00000000000..29fdf5572ad --- /dev/null +++ b/pylabrobot/hamilton/tcp/status_exception.py @@ -0,0 +1,32 @@ +"""Exceptions for Hamilton HOI exception frames that are not per pipetting channel.""" + +from __future__ import annotations + +from typing import Dict, List + +from pylabrobot.hamilton.tcp.wire_types import HcResultEntry + + +class HamiltonStatusException(Exception): + """Raised for ``STATUS_EXCEPTION`` / ``COMMAND_EXCEPTION`` when the command wire shape + does not carry per-channel parameters (e.g. void MLPrep queries). + + Errors are keyed by **wire entry index**, not physical channel index — use + :attr:`entries` for raw :class:`HcResultEntry` data. + """ + + def __init__( + self, + *, + errors: Dict[int, Exception], + entries: List[HcResultEntry], + raw_response: bytes, + ) -> None: + self.errors = errors + self.entries = entries + self.raw_response = raw_response + super().__init__(self._format_message()) + + def _format_message(self) -> str: + parts = [f"entry[{i}]: {self.errors[i]}" for i in sorted(self.errors)] + return "HamiltonStatusException(" + "; ".join(parts) + ")" diff --git a/pylabrobot/hamilton/tcp/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py index 1db907e7e53..72326a51027 100644 --- a/pylabrobot/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -17,8 +17,11 @@ from unittest.mock import AsyncMock import pylabrobot.hamilton.tcp.introspection as introspection_mod -from pylabrobot.hamilton.tcp.client import HamiltonTCPClient +from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError +from pylabrobot.hamilton.tcp.client import HamiltonTCPClient, _HcResultDescriptionHelper from pylabrobot.hamilton.tcp.commands import TCPCommand +from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES +from pylabrobot.hamilton.tcp.status_exception import HamiltonStatusException from pylabrobot.hamilton.tcp.introspection import ( EnumInfo, FirmwareTree, @@ -519,6 +522,177 @@ def test_parse_hamilton_error_entry_and_entries(self): 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_hamilton_status_exception(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(HamiltonStatusException) as ctx: + await client.send_command(cmd) + self.assertIn(0, ctx.exception.errors) + 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) + + +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): + client = HamiltonTCPClient(host="127.0.0.1", port=0, error_codes=NIMBUS_ERROR_CODES) + 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] + helper = _HcResultDescriptionHelper(client) + entry = HcResultEntry(0x0001, 0x0001, 0x0110, 1, 6, 0x0F4E) + _iface, desc = await helper.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 helper.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 = ( From 12f6d339468e2654f09336242261a6eb1d27cb45 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:49:12 -0700 Subject: [PATCH 06/14] refactor(hamilton/tcp): replace status_exception with HoiError module Move HOI error parsing (parse_hamilton_error_*) from messages into hoi_error. Pass HOI entries and per-entry exceptions into ChannelizedError for callers. --- pylabrobot/hamilton/tcp/__init__.py | 12 +- pylabrobot/hamilton/tcp/client.py | 21 +- pylabrobot/hamilton/tcp/commands.py | 4 +- pylabrobot/hamilton/tcp/hoi_error.py | 208 ++++++++++++++++++++ pylabrobot/hamilton/tcp/messages.py | 167 +--------------- pylabrobot/hamilton/tcp/status_exception.py | 32 --- pylabrobot/hamilton/tcp/tcp_tests.py | 16 +- 7 files changed, 251 insertions(+), 209 deletions(-) create mode 100644 pylabrobot/hamilton/tcp/hoi_error.py delete mode 100644 pylabrobot/hamilton/tcp/status_exception.py diff --git a/pylabrobot/hamilton/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py index b873e209089..1b4c397b71a 100644 --- a/pylabrobot/hamilton/tcp/__init__.py +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -1,7 +1,12 @@ """Canonical v1 Hamilton TCP namespace.""" from pylabrobot.hamilton.tcp.client import HamiltonTCPClient -from pylabrobot.hamilton.tcp.status_exception import HamiltonStatusException +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.commands import TCPCommand from pylabrobot.hamilton.tcp.introspection import ( @@ -51,7 +56,10 @@ "flatten_firmware_tree", "HAMILTON_PROTOCOL_VERSION_MAJOR", "HAMILTON_PROTOCOL_VERSION_MINOR", - "HamiltonStatusException", + "HoiError", + "parse_hamilton_error_entries", + "parse_hamilton_error_entry", + "parse_hamilton_error_params", "TCPCommand", "HamiltonTCPClient", "HamiltonDataType", diff --git a/pylabrobot/hamilton/tcp/client.py b/pylabrobot/hamilton/tcp/client.py index df1096f68bf..452d4433ff4 100644 --- a/pylabrobot/hamilton/tcp/client.py +++ b/pylabrobot/hamilton/tcp/client.py @@ -14,7 +14,11 @@ from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError -from pylabrobot.hamilton.tcp.status_exception import HamiltonStatusException +from pylabrobot.hamilton.tcp.hoi_error import ( + HoiError, + parse_hamilton_error_entries, + parse_hamilton_error_params, +) 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 @@ -24,8 +28,6 @@ InitResponse, RegistrationMessage, RegistrationResponse, - parse_hamilton_error_entries, - parse_hamilton_error_params, ) from pylabrobot.hamilton.tcp.introspection import ( HamiltonIntrospection, @@ -588,9 +590,11 @@ async def send_command( 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._hc_result_text.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 @@ -614,7 +618,12 @@ async def send_command( len(per_channel), channel_summary, ) - raise ChannelizedError(errors=per_channel, raw_response=response_message.hoi.params) + 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, @@ -647,8 +656,8 @@ async def send_command( len(entries), summary, ) - raise HamiltonStatusException( - errors=entry_errors, + raise HoiError( + exceptions=entry_errors, entries=list(entries), raw_response=response_message.hoi.params, ) diff --git a/pylabrobot/hamilton/tcp/commands.py b/pylabrobot/hamilton/tcp/commands.py index a67e9d51fe8..b4dff84cc8d 100644 --- a/pylabrobot/hamilton/tcp/commands.py +++ b/pylabrobot/hamilton/tcp/commands.py @@ -172,7 +172,7 @@ def error_entries_use_physical_channels(self) -> bool: 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.status_exception.HamiltonStatusException` + ``False`` so the client raises :class:`~pylabrobot.hamilton.tcp.hoi_error.HoiError` instead of attributing errors to synthetic ``ch0``. """ if not is_dataclass(self): @@ -210,7 +210,7 @@ def fatal_entries_by_channel(self, response: CommandResponse) -> dict[int, HcRes 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 ``parse_hamilton_error_entry``. + 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), 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/messages.py b/pylabrobot/hamilton/tcp/messages.py index 45dccb4d29f..904e6d5a223 100644 --- a/pylabrobot/hamilton/tcp/messages.py +++ b/pylabrobot/hamilton/tcp/messages.py @@ -10,6 +10,9 @@ 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 @@ -32,6 +35,7 @@ Hoi2Action, RegistrationOptionType, ) +from pylabrobot.hamilton.tcp.hoi_error import parse_hc_results_from_semicolon_string from pylabrobot.hamilton.tcp.wire_types import ( HamiltonDataType, HcResultEntry, @@ -286,165 +290,6 @@ def inspect_hoi_params(params: bytes) -> List[dict]: return out -_ERROR_ENTRY_RE = None # lazy-compiled below - - -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. - """ - import re - - 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]+))?" - ) - - 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 _ERROR_ENTRY_RE.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_get_hc_results_string(text: str) -> list[HcResultEntry]: - """Parse the semicolon-separated warning string from ``HoiDecoder2.GetHcResults`` (fragment 1). - - Each segment is ``0xMMMM.0xMMMM.0xMMMM:0xII,0xAAAA,0xRRRR`` (HarpAddress.ToString + 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 - - 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 @@ -478,9 +323,9 @@ def split_hoi_params_after_warning_prefix( rest = parser.remaining() prefix_entries: list[HcResultEntry] = [] if isinstance(v1, str): - prefix_entries = _parse_get_hc_results_string(v1) + prefix_entries = parse_hc_results_from_semicolon_string(v1) elif isinstance(v1, (bytes, bytearray)): - prefix_entries = _parse_get_hc_results_string(bytes(v1).decode("utf-8", errors="replace")) + prefix_entries = parse_hc_results_from_semicolon_string(bytes(v1).decode("utf-8", errors="replace")) return rest, prefix_entries diff --git a/pylabrobot/hamilton/tcp/status_exception.py b/pylabrobot/hamilton/tcp/status_exception.py deleted file mode 100644 index 29fdf5572ad..00000000000 --- a/pylabrobot/hamilton/tcp/status_exception.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Exceptions for Hamilton HOI exception frames that are not per pipetting channel.""" - -from __future__ import annotations - -from typing import Dict, List - -from pylabrobot.hamilton.tcp.wire_types import HcResultEntry - - -class HamiltonStatusException(Exception): - """Raised for ``STATUS_EXCEPTION`` / ``COMMAND_EXCEPTION`` when the command wire shape - does not carry per-channel parameters (e.g. void MLPrep queries). - - Errors are keyed by **wire entry index**, not physical channel index — use - :attr:`entries` for raw :class:`HcResultEntry` data. - """ - - def __init__( - self, - *, - errors: Dict[int, Exception], - entries: List[HcResultEntry], - raw_response: bytes, - ) -> None: - self.errors = errors - self.entries = entries - self.raw_response = raw_response - super().__init__(self._format_message()) - - def _format_message(self) -> str: - parts = [f"entry[{i}]: {self.errors[i]}" for i in sorted(self.errors)] - return "HamiltonStatusException(" + "; ".join(parts) + ")" diff --git a/pylabrobot/hamilton/tcp/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py index 72326a51027..ec9dbf75c5f 100644 --- a/pylabrobot/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -21,7 +21,11 @@ from pylabrobot.hamilton.tcp.client import HamiltonTCPClient, _HcResultDescriptionHelper from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES -from pylabrobot.hamilton.tcp.status_exception import HamiltonStatusException +from pylabrobot.hamilton.tcp.hoi_error import ( + HoiError, + parse_hamilton_error_entries, + parse_hamilton_error_entry, +) from pylabrobot.hamilton.tcp.introspection import ( EnumInfo, FirmwareTree, @@ -46,8 +50,6 @@ InitResponse, RegistrationMessage, RegistrationResponse, - parse_hamilton_error_entries, - parse_hamilton_error_entry, parse_into_struct, split_hoi_params_after_warning_prefix, ) @@ -582,7 +584,7 @@ def _format_wire_entry(entry: HcResultEntry) -> str: f"0x{entry.interface_id:02X},0x{entry.action_id:04X},0x{entry.result:04X}" ) - async def test_void_command_raises_hamilton_status_exception(self): + 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() @@ -624,9 +626,9 @@ async def _read_one_message(self, timeout=None): # type: ignore[override] client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] cmd = CmdVoid(Address(1, 1, 5376)) - with self.assertRaises(HamiltonStatusException) as ctx: + with self.assertRaises(HoiError) as ctx: await client.send_command(cmd) - self.assertIn(0, ctx.exception.errors) + self.assertIn(0, ctx.exception.exceptions) self.assertEqual(ctx.exception.entries[0].result, 0x0206) async def test_channels_involved_raises_channelized_error(self): @@ -675,6 +677,8 @@ async def _read_one_message(self, timeout=None): # type: ignore[override] 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): From 59feb5b48b740193fa1e70c936e3e8f4f3e1bc02 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:51:36 -0700 Subject: [PATCH 07/14] Nimbus example notebook --- .../00_liquid-handling/_liquid-handling.rst | 1 + .../nimbus_v1_aspirate_dispense.ipynb | 394 ++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_v1_aspirate_dispense.ipynb diff --git a/docs/user_guide/00_liquid-handling/_liquid-handling.rst b/docs/user_guide/00_liquid-handling/_liquid-handling.rst index d976603d54e..8b074f35523 100644 --- a/docs/user_guide/00_liquid-handling/_liquid-handling.rst +++ b/docs/user_guide/00_liquid-handling/_liquid-handling.rst @@ -16,6 +16,7 @@ Examples: hamilton-vantage/_hamilton-vantage hamilton-prep/_hamilton-prep + hamilton-nimbus/nimbus_v1_aspirate_dispense opentrons/ot2/ot2 tecan-evo/_tecan-evo pumps/_pumps diff --git a/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_v1_aspirate_dispense.ipynb b/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_v1_aspirate_dispense.ipynb new file mode 100644 index 00000000000..88a8d9afc10 --- /dev/null +++ b/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_v1_aspirate_dispense.ipynb @@ -0,0 +1,394 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a7fe6b22", + "metadata": {}, + "source": [ + "# Nimbus v1 — aspirate / dispense\n", + "\n", + "**v1 shape:** `Nimbus` (device) → `NimbusDriver` (`HamiltonTCPClient`: TCP, introspection, resolved addresses) → `PIP` frontend → `NimbusPIPBackend` (pipette commands). After `setup()`, use **`nimbus.pip`** for moves; **`nimbus.driver`** for transport and low-level access." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "3e414ae0", + "metadata": {}, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "import logging\n", + "import sys\n", + "\n", + "from pylabrobot.hamilton.liquid_handlers.nimbus import Nimbus, NimbusSetupParams\n", + "from pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend import (\n", + " NimbusPIPAspirateParams,\n", + " NimbusPIPDispenseParams,\n", + ")\n", + "from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import get_star_liquid_class\n", + "from pylabrobot.resources import Cor_Axy_96_wellplate_500uL_Ub\n", + "from pylabrobot.resources.coordinate import Coordinate\n", + "from pylabrobot.resources.hamilton import HamiltonTip, NimbusDeck, hamilton_96_tiprack_300uL_filter\n", + "from pylabrobot.resources.liquid import Liquid\n", + "\n", + "logging.getLogger(\"pylabrobot\").setLevel(logging.DEBUG)\n", + "\n", + "\n", + "def nimbus_star_water_hlcs(pip, channels: list[int], *, blow_out: bool = False, liquid: Liquid = Liquid.WATER):\n", + " \"\"\"One STAR liquid class per channel from mounted HamiltonTips (Water, jet=False).\"\"\"\n", + " hlcs = []\n", + " for ch in channels:\n", + " tip = pip.head[ch].get_tip()\n", + " if not isinstance(tip, HamiltonTip):\n", + " raise TypeError(f\"Channel {ch} needs HamiltonTip, got {type(tip).__name__}\")\n", + " hlc = get_star_liquid_class(\n", + " tip_volume=tip.maximal_volume,\n", + " is_core=False,\n", + " is_tip=True,\n", + " has_filter=tip.has_filter,\n", + " liquid=liquid,\n", + " jet=False,\n", + " blow_out=blow_out,\n", + " )\n", + " if hlc is None:\n", + " raise RuntimeError(\"No STAR liquid class for this tip/liquid/jet/blow_out combo.\")\n", + " hlcs.append(hlc)\n", + " return hlcs" + ] + }, + { + "cell_type": "markdown", + "id": "b586973d", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()`: TCP client registration, resolve **`nimbus_core`** / **`pipette`** (and optional **`door_lock`**), channel count, `InitializeSmartRoll` when needed, then **`PIP`** ready on **`nimbus.pip`**." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "7dec2d39", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-17 22:46:55,687 - pylabrobot.hamilton.tcp.client - INFO - Initializing Hamilton connection...\n", + "2026-04-17 22:46:55,697 - pylabrobot.hamilton.tcp.client - INFO - Registering Hamilton client...\n", + "2026-04-17 22:46:55,706 - pylabrobot.hamilton.tcp.client - INFO - Discovering Hamilton root objects...\n", + "2026-04-17 22:46:55,716 - pylabrobot.hamilton.tcp.client - INFO - Discovering Hamilton global objects...\n", + "2026-04-17 22:46:55,732 - pylabrobot.hamilton.tcp.client - INFO - Hamilton TCP client setup complete. Client ID: 2, globals: 1\n", + "2026-04-17 22:46:56,645 - pylabrobot.hamilton.tcp.interface_bundle - INFO - Nimbus interfaces: door_lock, nimbus_core, pipette\n", + "2026-04-17 22:46:57,469 - pylabrobot.hamilton.liquid_handlers.nimbus.driver - INFO - Channel configuration: 4 channels\n", + "2026-04-17 22:46:58,358 - pylabrobot.hamilton.liquid_handlers.nimbus.door - INFO - Door locked successfully\n", + "2026-04-17 22:46:58,763 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - Channel configuration set for 4 channels\n", + "2026-04-17 22:47:13,333 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - NimbusCore initialized with InitializeSmartRoll successfully\n" + ] + } + ], + "source": [ + "HOST = \"192.168.100.100\"\n", + "PORT = 2000\n", + "# Create deck to attach to Nimbus\n", + "deck = NimbusDeck()\n", + "tips = hamilton_96_tiprack_300uL_filter(name=\"tips_01\", with_tips=True)\n", + "plate = Cor_Axy_96_wellplate_500uL_Ub(name=\"plate_01\", with_lid=False)\n", + "deck.assign_child_resource(tips, location=Coordinate(x=305.750, y=126.537, z=128.620))\n", + "deck.assign_child_resource(plate, location=Coordinate(x=438.070, y=124.837, z=101.490))\n", + "\n", + "nimbus = Nimbus(deck=deck, host=HOST, port=PORT)\n", + "await nimbus.setup(NimbusSetupParams(deck=deck))" + ] + }, + { + "cell_type": "markdown", + "id": "a4453923", + "metadata": {}, + "source": [ + "## Interfaces\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "7eb18e35", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "nimbus_core: 'NimbusCORE' → 1:1:48896\n", + "pipette: 'NimbusCORE.Pipette' → 1:1:257\n", + "door_lock: 'NimbusCORE.DoorLock' → 1:1:268\n" + ] + } + ], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.nimbus.driver import nimbus_interface_specs_for_root\n", + "\n", + "d = nimbus.driver\n", + "root = await d.introspection.get_object(d.get_root_object_addresses()[0])\n", + "for role, spec in nimbus_interface_specs_for_root(root.name).items():\n", + " addr = getattr(d.nimbus_interfaces, role)\n", + " if addr is None:\n", + " print(f\"{role}: {spec.path!r} → None\")\n", + " else:\n", + " print(f\"{role}: {spec.path!r} → {await d.resolve_path(spec.path)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "7b89abf3", + "metadata": {}, + "source": [ + "## Pick up tips\n", + "\n", + "Tip column slice + **`[:nc]`** (works for 4- or 8-channel heads). Adjust column to match tips on your rack." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "92e6710a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "channels: 4\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-17 22:47:51,227 - pylabrobot.hamilton.tcp.client - ERROR - Hamilton COMMAND_EXCEPTION (action=0x5) on 4 channel(s): ch0: Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 275) iface=1 action=6 (path=NimbusCORE.Channel, addr=1:1:275, method=[1:6] [1:6] PickupTip(zStartPosition: i32, zStopPosition: i32, zFinal: i32, volume: u32, colletCheck: i16) -> void), ch1: Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 274) iface=1 action=6 (path=NimbusCORE.Channel, addr=1:1:274, method=[1:6] [1:6] PickupTip(zStartPosition: i32, zStopPosition: i32, zFinal: i32, volume: u32, colletCheck: i16) -> void), ch2: Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 272) iface=1 action=6 (path=NimbusCORE.Channel, addr=1:1:272, method=[1:6] [1:6] PickupTip(zStartPosition: i32, zStopPosition: i32, zFinal: i32, volume: u32, colletCheck: i16) -> void), ch3: No Tip Picked Up. (HcResult=0x0F4B) at (1, 1, 273) iface=1 action=6 (path=NimbusCORE.Channel, addr=1:1:273, method=[1:6] [1:6] PickupTip(zStartPosition: i32, zStopPosition: i32, zFinal: i32, volume: u32, colletCheck: i16) -> void)\n" + ] + }, + { + "ename": "ChannelizedError", + "evalue": "ChannelizedError(errors={0: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 275) iface=1 action=6'), 1: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 274) iface=1 action=6'), 2: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 272) iface=1 action=6'), 3: RuntimeError('No Tip Picked Up. (HcResult=0x0F4B) at (1, 1, 273) iface=1 action=6')}, raw_response=b'!\\x00\\x02\\x00\\x15\\x00\\x0f\\x00\\xa0\\x000x0001.0x0001.0x0113:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0112:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0110:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0111:0x01,0x0006,0x0F4B\\x00', hoi_entries=[HcResultEntry(module_id=1, node_id=1, object_id=275, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=274, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=272, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=273, interface_id=1, action_id=6, result=3915)], hoi_exceptions={0: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 275) iface=1 action=6'), 1: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 274) iface=1 action=6'), 2: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 272) iface=1 action=6'), 3: RuntimeError('No Tip Picked Up. (HcResult=0x0F4B) at (1, 1, 273) iface=1 action=6')})", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mChannelizedError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[4]\u001b[39m\u001b[32m, line 5\u001b[39m\n\u001b[32m 3\u001b[39m \u001b[38;5;28mprint\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mchannels: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mnc\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m)\n\u001b[32m 4\u001b[39m tip_pick = tips[\u001b[33m\"\u001b[39m\u001b[33mA9:H9\u001b[39m\u001b[33m\"\u001b[39m][:nc]\n\u001b[32m----> \u001b[39m\u001b[32m5\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m nimbus.pip.pick_up_tips(tip_pick)\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/repos/pull_Requests/prep/pylabrobot/capabilities/capability.py:46\u001b[39m, in \u001b[36mneed_capability_ready..wrapper\u001b[39m\u001b[34m(*args, **kwargs)\u001b[39m\n\u001b[32m 44\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28mself\u001b[39m.setup_finished:\n\u001b[32m 45\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[33m\"\u001b[39m\u001b[33mThe capability has not been set up. Call setup() on the parent device.\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m---> \u001b[39m\u001b[32m46\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m func(*args, **kwargs)\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/repos/pull_Requests/prep/pylabrobot/capabilities/liquid_handling/pip.py:191\u001b[39m, in \u001b[36mPIP.pick_up_tips\u001b[39m\u001b[34m(self, tip_spots, use_channels, offsets, backend_params)\u001b[39m\n\u001b[32m 188\u001b[39m (\u001b[38;5;28mself\u001b[39m.head[channel].commit \u001b[38;5;28;01mif\u001b[39;00m success \u001b[38;5;28;01melse\u001b[39;00m \u001b[38;5;28mself\u001b[39m.head[channel].rollback)()\n\u001b[32m 190\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m error \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m191\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m error\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/repos/pull_Requests/prep/pylabrobot/capabilities/liquid_handling/pip.py:166\u001b[39m, in \u001b[36mPIP.pick_up_tips\u001b[39m\u001b[34m(self, tip_spots, use_channels, offsets, backend_params)\u001b[39m\n\u001b[32m 164\u001b[39m error: Optional[\u001b[38;5;167;01mBaseException\u001b[39;00m] = \u001b[38;5;28;01mNone\u001b[39;00m\n\u001b[32m 165\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m166\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.backend.pick_up_tips(\n\u001b[32m 167\u001b[39m ops=pickups, use_channels=use_channels, backend_params=backend_params\n\u001b[32m 168\u001b[39m )\n\u001b[32m 169\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mBaseException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n\u001b[32m 170\u001b[39m error = e\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/repos/pull_Requests/prep/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py:511\u001b[39m, in \u001b[36mNimbusPIPBackend.pick_up_tips\u001b[39m\u001b[34m(self, ops, use_channels, backend_params)\u001b[39m\n\u001b[32m 498\u001b[39m traverse_height_units = \u001b[38;5;28mround\u001b[39m(traverse_height * \u001b[32m100\u001b[39m)\n\u001b[32m 500\u001b[39m command = PickupTips(\n\u001b[32m 501\u001b[39m dest=\u001b[38;5;28mself\u001b[39m.pipette_address,\n\u001b[32m 502\u001b[39m channels_involved=channels_involved,\n\u001b[32m (...)\u001b[39m\u001b[32m 508\u001b[39m tip_types=tip_types_full,\n\u001b[32m 509\u001b[39m )\n\u001b[32m--> \u001b[39m\u001b[32m511\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.driver.send_command(command)\n\u001b[32m 512\u001b[39m logger.info(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mPicked up tips on channels \u001b[39m\u001b[38;5;132;01m{\u001b[39;00muse_channels\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m)\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/repos/pull_Requests/prep/pylabrobot/hamilton/tcp/client.py:621\u001b[39m, in \u001b[36mHamiltonTCPClient.send_command\u001b[39m\u001b[34m(self, command, ensure_connection, return_raw, raise_on_error, read_timeout)\u001b[39m\n\u001b[32m 606\u001b[39m channel_summary = \u001b[33m\"\u001b[39m\u001b[33m, \u001b[39m\u001b[33m\"\u001b[39m.join(\n\u001b[32m 607\u001b[39m (\n\u001b[32m 608\u001b[39m \u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mch\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mch\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mper_channel[ch]\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m (\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mcontext_by_channel[ch]\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m)\u001b[39m\u001b[33m\"\u001b[39m\n\u001b[32m (...)\u001b[39m\u001b[32m 612\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m ch \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28msorted\u001b[39m(per_channel)\n\u001b[32m 613\u001b[39m )\n\u001b[32m 614\u001b[39m logger.error(\n\u001b[32m 615\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mHamilton \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m (action=\u001b[39m\u001b[38;5;132;01m%#x\u001b[39;00m\u001b[33m) on \u001b[39m\u001b[38;5;132;01m%d\u001b[39;00m\u001b[33m channel(s): \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m\"\u001b[39m,\n\u001b[32m 616\u001b[39m action.name,\n\u001b[32m (...)\u001b[39m\u001b[32m 619\u001b[39m channel_summary,\n\u001b[32m 620\u001b[39m )\n\u001b[32m--> \u001b[39m\u001b[32m621\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m ChannelizedError(\n\u001b[32m 622\u001b[39m errors=per_channel,\n\u001b[32m 623\u001b[39m raw_response=response_message.hoi.params,\n\u001b[32m 624\u001b[39m hoi_entries=\u001b[38;5;28mlist\u001b[39m(entries),\n\u001b[32m 625\u001b[39m hoi_exceptions=hoi_exceptions,\n\u001b[32m 626\u001b[39m )\n\u001b[32m 627\u001b[39m logger.debug(\n\u001b[32m 628\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mHamilton \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m (action=\u001b[39m\u001b[38;5;132;01m%#x\u001b[39;00m\u001b[33m) suppressed; entries=\u001b[39m\u001b[38;5;132;01m%d\u001b[39;00m\u001b[33m (raise_on_error=False)\u001b[39m\u001b[33m\"\u001b[39m,\n\u001b[32m 629\u001b[39m action.name,\n\u001b[32m 630\u001b[39m action,\n\u001b[32m 631\u001b[39m \u001b[38;5;28mlen\u001b[39m(entries),\n\u001b[32m 632\u001b[39m )\n\u001b[32m 633\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m\n", + "\u001b[31mChannelizedError\u001b[39m: ChannelizedError(errors={0: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 275) iface=1 action=6'), 1: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 274) iface=1 action=6'), 2: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 272) iface=1 action=6'), 3: RuntimeError('No Tip Picked Up. (HcResult=0x0F4B) at (1, 1, 273) iface=1 action=6')}, raw_response=b'!\\x00\\x02\\x00\\x15\\x00\\x0f\\x00\\xa0\\x000x0001.0x0001.0x0113:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0112:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0110:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0111:0x01,0x0006,0x0F4B\\x00', hoi_entries=[HcResultEntry(module_id=1, node_id=1, object_id=275, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=274, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=272, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=273, interface_id=1, action_id=6, result=3915)], hoi_exceptions={0: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 275) iface=1 action=6'), 1: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 274) iface=1 action=6'), 2: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 272) iface=1 action=6'), 3: RuntimeError('No Tip Picked Up. (HcResult=0x0F4B) at (1, 1, 273) iface=1 action=6')})" + ] + } + ], + "source": [ + "# Example of a Channelized error from failed tip pickup\n", + "nc = nimbus.driver.pip.num_channels\n", + "print(f\"channels: {nc}\")\n", + "tip_pick = tips[\"A9:H9\"][:nc]\n", + "await nimbus.pip.pick_up_tips(tip_pick)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "3192a2da", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-17 22:48:19,006 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - Picked up tips on channels [0, 1, 2, 3]\n" + ] + } + ], + "source": [ + "# Successful tip pickup\n", + "tip_pick = tips[\"E9:H9\"][:nc]\n", + "await nimbus.pip.pick_up_tips(tip_pick)" + ] + }, + { + "cell_type": "markdown", + "id": "37337994", + "metadata": {}, + "source": [ + "## Aspirate\n", + "\n", + "`vols`, `liquid_height` (mm from well bottom), `flow_rates` — length **`nc`** (see `PIP` docs).\n", + "\n", + "**Hamilton liquid classes:** `nimbus_star_water_hlcs` (defined with imports) builds per-channel classes; set **`blow_out_liquid_class`** to match partial vs blow-out STAR rows. **`swap_speed_mm_s`** sets leave-liquid speed (**mm/s**). Shared **`hlc_kw`** is reused for dispense below.\n", + "\n", + "**swap_speed vs logs:** The `\"Aspirated on channels …\"` line does **not** print `swap_speed`. Values are still set on the `Aspirate` command: firmware uses **0.01 mm/s** per element, so **`15.0` mm/s → `1500`** on each active channel index. Use a TCP/trace capture or a temporary `send_command` wrapper if you need to see the raw command object." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a11988ea", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-17 22:48:28,053 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - Aspirated on channels [0, 1, 2, 3]\n" + ] + } + ], + "source": [ + "ch = list(range(nc))\n", + "blow_out_liquid_class = False # True → STAR blow-out / empty-tip style row\n", + "swap_speed_mm_s = 30.0\n", + "\n", + "hlc_kw = dict(\n", + " hamilton_liquid_classes=nimbus_star_water_hlcs(nimbus.pip, ch, blow_out=blow_out_liquid_class),\n", + " swap_speed=[swap_speed_mm_s] * nc,\n", + ")\n", + "\n", + "await nimbus.pip.aspirate(\n", + " plate[\"A1:H1\"][:nc],\n", + " vols=[50.0] * nc,\n", + " liquid_height=[2.0] * nc,\n", + " backend_params=NimbusPIPAspirateParams(**hlc_kw),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "2091b5d0", + "metadata": {}, + "source": [ + "## Dispense\n", + "\n", + "Example: second column (`A2:H2` vs `A1:H1`). Same **`hlc_kw`** as aspirate so liquid class + swap speed stay aligned." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "52eddbc5", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-17 22:48:31,971 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - Dispensed on channels [0, 1, 2, 3]\n" + ] + } + ], + "source": [ + "await nimbus.pip.dispense(\n", + " plate[\"A2:H2\"][:nc],\n", + " vols=[50.0] * nc,\n", + " liquid_height=[2.0] * nc,\n", + " backend_params=NimbusPIPDispenseParams(**hlc_kw),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "34fe6a83", + "metadata": {}, + "source": [ + "## Drop tips\n", + "\n", + "To waste **`Trash`** sites (`waste_sites`), not back to the rack." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "95b64f3c", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-17 22:48:38,246 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - Dropped tips on channels [0, 1, 2, 3]\n" + ] + } + ], + "source": [ + "waste_sites = [deck.get_resource(f\"default_long_{i}\") for i in range(1, nc + 1)]\n", + "\n", + "await nimbus.pip.drop_tips(tip_pick)" + ] + }, + { + "cell_type": "markdown", + "id": "e861af9f", + "metadata": {}, + "source": [ + "## Cleanup\n", + "\n", + "Park if you want the head stowed; unlock door when **`door_lock`** exists; **`stop()`** closes TCP." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "d2f54b37", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-04-17 22:48:41,688 - pylabrobot.hamilton.liquid_handlers.nimbus.driver - INFO - Instrument parked successfully\n", + "2026-04-17 22:48:41,782 - pylabrobot.hamilton.liquid_handlers.nimbus.door - INFO - Door unlocked successfully\n", + "2026-04-17 22:48:41,783 - pylabrobot.io.socket - INFO - Closing connection to socket 192.168.100.100:2000\n", + "2026-04-17 22:48:41,784 - pylabrobot.hamilton.tcp.client - INFO - Hamilton TCP client stopped\n" + ] + } + ], + "source": [ + "await nimbus.park()\n", + "if nimbus.driver.door is not None:\n", + " await nimbus.unlock_door()\n", + "\n", + "await nimbus.stop()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "682744ae", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 8c6ea09b4224f1c19b277949b21080329c54d381 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:26:27 -0700 Subject: [PATCH 08/14] Formatting and PEP --- .../liquid_handlers/nimbus/__init__.py | 7 +++++- .../liquid_handlers/nimbus/commands.py | 4 +-- .../hamilton/liquid_handlers/nimbus/driver.py | 1 - .../liquid_handlers/nimbus/pip_backend.py | 12 ++++----- .../nimbus/tests/driver_tests.py | 2 +- .../nimbus/tests/pip_backend_tests.py | 25 +++++++++++++------ pylabrobot/hamilton/tcp/__init__.py | 2 +- pylabrobot/hamilton/tcp/client.py | 18 ++++++------- pylabrobot/hamilton/tcp/interface_bundle.py | 4 +-- pylabrobot/hamilton/tcp/messages.py | 11 ++++---- pylabrobot/hamilton/tcp/tcp_tests.py | 6 ++--- 11 files changed, 52 insertions(+), 40 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py index 5fc622586ad..2a91a3d4223 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py @@ -1,5 +1,10 @@ from .chatterbox import NimbusChatterboxDriver from .door import NimbusDoor -from .driver import NimbusDriver, NimbusResolvedInterfaces, NimbusSetupParams, nimbus_interface_specs_for_root +from .driver import ( + NimbusDriver, + NimbusResolvedInterfaces, + NimbusSetupParams, + nimbus_interface_specs_for_root, +) from .nimbus import Nimbus from .pip_backend import NimbusPIPBackend diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py index 3cc6f56292a..2b087dc2053 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py @@ -15,12 +15,12 @@ from pylabrobot.hamilton.tcp.packets import Address from pylabrobot.hamilton.tcp.protocol import HamiltonProtocol from pylabrobot.hamilton.tcp.wire_types import ( + I32, + U16, Bool, BoolArray, I16Array, - I32, I32Array, - U16, U16Array, U32Array, ) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py index 4f0c55f1c03..84f6a220841 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -11,7 +11,6 @@ from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.packets import Address - from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck from .commands import ( diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py index 5dd500a3f2f..7c24c29e5b3 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -22,7 +22,6 @@ from .commands import ( Aspirate, - Dispense as DispenseCommand, DisableADC, DropTips, DropTipsRoll, @@ -36,6 +35,9 @@ _get_default_flow_rate, _get_tip_type_from_tip, ) +from .commands import ( + Dispense as DispenseCommand, +) if TYPE_CHECKING: from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck @@ -85,9 +87,7 @@ class NimbusPIPAspirateParams(BackendParams): 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 + 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 @@ -111,9 +111,7 @@ class NimbusPIPDispenseParams(BackendParams): 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 + 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 diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py index 8f468613e44..5ab7eb1d0b8 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py @@ -10,8 +10,8 @@ NimbusResolvedInterfaces, nimbus_interface_specs_for_root, ) -from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES +from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.packets import Address # Stable key from NIMBUS_ERROR_CODES for merge-override tests (must exist in table). diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py index 7207cb2a87d..b25666b9ae7 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py @@ -7,11 +7,12 @@ 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, Dispense as DispenseCmd +from pylabrobot.hamilton.liquid_handlers.nimbus.commands import Aspirate +from pylabrobot.hamilton.liquid_handlers.nimbus.commands import Dispense as DispenseCmd from pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend import ( NimbusPIPAspirateParams, - NimbusPIPDispenseParams, NimbusPIPBackend, + NimbusPIPDispenseParams, ) from pylabrobot.hamilton.liquid_handlers.star.pip_backend import STARPIPBackend from pylabrobot.hamilton.tcp.packets import Address @@ -94,7 +95,9 @@ async def _run() -> None: ), ) - aspirate_cmds = [c for c in driver.send_command.call_args_list if isinstance(c.args[0], Aspirate)] + 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) @@ -155,7 +158,9 @@ async def _run() -> None: ), ) - aspirate_cmds = [c for c in driver.send_command.call_args_list if isinstance(c.args[0], Aspirate)] + 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 @@ -207,7 +212,9 @@ async def _run() -> None: 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)] + 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 @@ -260,7 +267,9 @@ async def _run() -> None: 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)] + 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 @@ -365,7 +374,9 @@ async def _run() -> None: 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)] + 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 diff --git a/pylabrobot/hamilton/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py index 1b4c397b71a..746045c949c 100644 --- a/pylabrobot/hamilton/tcp/__init__.py +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -1,6 +1,7 @@ """Canonical v1 Hamilton TCP namespace.""" 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, @@ -8,7 +9,6 @@ parse_hamilton_error_params, ) from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs -from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.introspection import ( FirmwareTree, MethodInfo, diff --git a/pylabrobot/hamilton/tcp/client.py b/pylabrobot/hamilton/tcp/client.py index 452d4433ff4..e53b7e2953f 100644 --- a/pylabrobot/hamilton/tcp/client.py +++ b/pylabrobot/hamilton/tcp/client.py @@ -14,14 +14,20 @@ 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.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.introspection import ( + HamiltonIntrospection, + MethodDescriptor, + ObjectRegistry, + flatten_firmware_tree, +) from pylabrobot.hamilton.tcp.messages import ( CommandResponse, InitMessage, @@ -29,12 +35,6 @@ RegistrationMessage, RegistrationResponse, ) -from pylabrobot.hamilton.tcp.introspection import ( - HamiltonIntrospection, - MethodDescriptor, - ObjectRegistry, - flatten_firmware_tree, -) from pylabrobot.hamilton.tcp.packets import Address from pylabrobot.hamilton.tcp.protocol import ( Hoi2Action, diff --git a/pylabrobot/hamilton/tcp/interface_bundle.py b/pylabrobot/hamilton/tcp/interface_bundle.py index bdd749fc135..7f304867ac7 100644 --- a/pylabrobot/hamilton/tcp/interface_bundle.py +++ b/pylabrobot/hamilton/tcp/interface_bundle.py @@ -62,9 +62,7 @@ async def resolve_interface_path_specs( ) 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 - ) + 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)) diff --git a/pylabrobot/hamilton/tcp/messages.py b/pylabrobot/hamilton/tcp/messages.py index 904e6d5a223..1de6eb5b93c 100644 --- a/pylabrobot/hamilton/tcp/messages.py +++ b/pylabrobot/hamilton/tcp/messages.py @@ -20,9 +20,9 @@ import logging from dataclasses import dataclass from dataclasses import fields as dc_fields -from typing import Any, List, Optional, cast, get_args, get_origin, get_type_hints +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, @@ -35,12 +35,11 @@ Hoi2Action, RegistrationOptionType, ) -from pylabrobot.hamilton.tcp.hoi_error import parse_hc_results_from_semicolon_string from pylabrobot.hamilton.tcp.wire_types import ( - HamiltonDataType, HcResultEntry, decode_fragment, ) +from pylabrobot.io.binary import Reader, Writer PADDED_FLAG = 0x01 @@ -325,7 +324,9 @@ def split_hoi_params_after_warning_prefix( 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")) + prefix_entries = parse_hc_results_from_semicolon_string( + bytes(v1).decode("utf-8", errors="replace") + ) return rest, prefix_entries diff --git a/pylabrobot/hamilton/tcp/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py index ec9dbf75c5f..1c84b2076ee 100644 --- a/pylabrobot/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -9,9 +9,9 @@ from __future__ import annotations +import asyncio import struct import unittest -import asyncio from dataclasses import dataclass from typing import Annotated, cast from unittest.mock import AsyncMock @@ -71,14 +71,14 @@ from pylabrobot.hamilton.tcp.wire_types import ( I32, I64, - Str, - StrArray, U16, Bool, BoolArray, CountedFlatArray, HamiltonDataType, HcResultEntry, + Str, + StrArray, decode_fragment, ) From 3316ab02ea689419243cd9272f0afc192afa81f4 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 18 Apr 2026 12:54:56 -0700 Subject: [PATCH 09/14] Simplify Nimbus setup params handling to isinstance-only check Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hamilton/liquid_handlers/nimbus/chatterbox.py | 15 ++++----------- .../hamilton/liquid_handlers/nimbus/driver.py | 15 ++++----------- .../hamilton/liquid_handlers/nimbus/nimbus.py | 15 +++------------ 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py index 76e994ae938..6ab83cf705d 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py @@ -30,15 +30,8 @@ def __init__(self, num_channels: int = 8): async def setup(self, backend_params: Optional[BackendParams] = None): from .pip_backend import NimbusPIPBackend - 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__}" - ) + if not isinstance(backend_params, NimbusSetupParams): + backend_params = NimbusSetupParams() # Use canned addresses (skip TCP connection entirely) pipette_address = Address(1, 1, 257) @@ -53,10 +46,10 @@ async def setup(self, backend_params: Optional[BackendParams] = None): self._nimbus_resolved = NimbusResolvedInterfaces.from_resolution_map(self._resolved_interfaces) self.pip = NimbusPIPBackend( - driver=self, deck=params.deck, address=pipette_address, num_channels=self._num_channels + driver=self, deck=backend_params.deck, address=pipette_address, num_channels=self._num_channels ) self.door = NimbusDoor(driver=self, address=door_address) - if params.require_door_lock and self.door is None: + if backend_params.require_door_lock and self.door is None: raise RuntimeError("DoorLock is required but not available on this instrument.") async def stop(self): diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py index 84f6a220841..0a34aee2398 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -133,15 +133,8 @@ async def setup(self, backend_params: Optional[BackendParams] = None): Args: backend_params: Optional :class:`NimbusSetupParams`. """ - 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__}" - ) + if not isinstance(backend_params, NimbusSetupParams): + backend_params = NimbusSetupParams() # TCP connection + Protocol 7 + Protocol 3 + root discovery await super().setup() @@ -186,12 +179,12 @@ async def setup(self, backend_params: Optional[BackendParams] = None): # Create backends — each object stores its own address and state self.pip = NimbusPIPBackend( - driver=self, deck=params.deck, address=pipette_address, num_channels=num_channels + driver=self, deck=backend_params.deck, address=pipette_address, num_channels=num_channels ) if door_address is not None: self.door = NimbusDoor(driver=self, address=door_address) - elif params.require_door_lock: + elif backend_params.require_door_lock: raise RuntimeError("DoorLock is required but not available on this instrument.") # Initialize subsystems diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py index c03ee1901f8..dcd6617ab10 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py @@ -44,20 +44,11 @@ async def setup(self, backend_params: Optional[BackendParams] = None): runs InitializeSmartRoll, and wires the PIP capability frontend to the driver's PIP backend. """ - if backend_params is None: - params = NimbusSetupParams(deck=self.deck) - elif isinstance(backend_params, NimbusSetupParams): - params = backend_params - if params.deck is None: - params = NimbusSetupParams(deck=self.deck, require_door_lock=params.require_door_lock) - else: - raise TypeError( - "Nimbus.setup expected NimbusSetupParams | None for backend_params, " - f"got {type(backend_params).__name__}" - ) + if not isinstance(backend_params, NimbusSetupParams): + backend_params = NimbusSetupParams(deck=self.deck) try: - await self.driver.setup(backend_params=params) + await self.driver.setup(backend_params=backend_params) self.pip = PIP(backend=self.driver.pip) self._capabilities = [self.pip] From b57983b6331b94cb4f62d85c3f28d224cbfb5f61 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 18 Apr 2026 16:13:43 -0700 Subject: [PATCH 10/14] Remove Hamilton liquid-class resolver and Nimbus HLC integration Reverts the HLC pieces of 8c929c6a6: - Delete pylabrobot/hamilton/liquid_handlers/liquid_class_resolver.py and its tests - STAR PIP backend: restore the local _resolve_liquid_classes helper - Nimbus PIP backend: drop all HLC fields, imports, and resolution/volume-correction logic - Delete the HLC Nimbus pip_backend_tests.py (was added entirely by that commit) The underlying HamiltonLiquidClass and STAR calibration tables predated the PR and are kept. Nimbus is restored to its pre-HLC state (no liquid-class support) with the _on_setup signature updated to accept backend_params for the capability protocol. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../liquid_handlers/liquid_class_resolver.py | 104 ----- .../liquid_handlers/nimbus/pip_backend.py | 209 +++------- .../nimbus/tests/pip_backend_tests.py | 384 ------------------ .../liquid_handlers/star/pip_backend.py | 53 ++- .../liquid_handlers/tests/__init__.py | 0 .../tests/liquid_class_resolver_tests.py | 109 ----- 6 files changed, 97 insertions(+), 762 deletions(-) delete mode 100644 pylabrobot/hamilton/liquid_handlers/liquid_class_resolver.py delete mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py delete mode 100644 pylabrobot/hamilton/liquid_handlers/tests/__init__.py delete mode 100644 pylabrobot/hamilton/liquid_handlers/tests/liquid_class_resolver_tests.py diff --git a/pylabrobot/hamilton/liquid_handlers/liquid_class_resolver.py b/pylabrobot/hamilton/liquid_handlers/liquid_class_resolver.py deleted file mode 100644 index 2e34e5c4208..00000000000 --- a/pylabrobot/hamilton/liquid_handlers/liquid_class_resolver.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Resolve Hamilton liquid classes and corrected volumes for PIP backends. - -Lives alongside :mod:`liquid_class`. Automatic lookup defaults to -:class:`~pylabrobot.hamilton.liquid_handlers.star.liquid_classes.get_star_liquid_class` -(STAR calibration tables); pass ``lookup=`` for instrument-specific tables. -""" - -from __future__ import annotations - -from typing import Any, Callable, List, Optional, Sequence, Union - -from pylabrobot.hamilton.liquid_handlers.liquid_class import HamiltonLiquidClass -from pylabrobot.resources.hamilton import HamiltonTip -from pylabrobot.resources.liquid import Liquid - -_Lookup = Callable[..., Optional[HamiltonLiquidClass]] - - -def resolve_hamilton_liquid_classes( - explicit: Optional[List[Optional[HamiltonLiquidClass]]], - ops: list, - *, - jet: Union[bool, List[bool]] = False, - blow_out: Union[bool, List[bool]] = False, - is_aspirate: bool = True, - lookup: Optional[_Lookup] = None, -) -> List[Optional[HamiltonLiquidClass]]: - """Resolve per-op Hamilton liquid classes. - - If ``explicit`` is None, resolve from each op's tip via ``lookup`` (default - :func:`get_star_liquid_class`). Non-``HamiltonTip`` tips yield ``None``. - - If ``explicit`` is a list, it is returned as a shallow copy; ``None`` entries - are preserved (legacy STAR behavior). - - Args: - explicit: Caller-provided liquid classes, or None for automatic lookup. - ops: Aspiration or dispense operations (must have a ``tip`` attribute). - jet: Per-op or scalar flags passed to automatic liquid class lookup. - blow_out: Per-op or scalar flags passed to automatic liquid class lookup. - is_aspirate: Reserved for API compatibility with STAR; unused. - lookup: Optional callable with the same signature as ``get_star_liquid_class``. - """ - del is_aspirate - n = len(ops) - if isinstance(jet, bool): - jet = [jet] * n - if isinstance(blow_out, bool): - blow_out = [blow_out] * n - - if explicit is not None: - return list(explicit) - - if lookup is None: - # Lazy import avoids circular import: star package __init__ may pull in pip_backend, - # which imports this module. - from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import get_star_liquid_class - - fn = get_star_liquid_class - else: - fn = lookup - result: List[Optional[HamiltonLiquidClass]] = [] - for i, op in enumerate(ops): - tip = op.tip - if not isinstance(tip, HamiltonTip): - result.append(None) - continue - result.append( - fn( - tip_volume=tip.maximal_volume, - is_core=False, - is_tip=True, - has_filter=tip.has_filter, - liquid=Liquid.WATER, - jet=jet[i], - blow_out=blow_out[i], - ) - ) - - return result - - -def corrected_volumes_for_ops( - ops: Sequence[Any], - hlcs: Sequence[Optional[HamiltonLiquidClass]], - disable_volume_correction: Optional[Sequence[bool]] = None, -) -> List[float]: - """Apply liquid-class volume correction per op when enabled.""" - n = len(ops) - if len(hlcs) != n: - raise ValueError(f"hlcs length must match ops ({n}), got {len(hlcs)}") - dvc = ( - list(disable_volume_correction) if disable_volume_correction is not None else [False] * n - ) - if len(dvc) != n: - raise ValueError( - f"disable_volume_correction length must match ops ({n}), got {len(dvc)}" - ) - return [ - float(hlc.compute_corrected_volume(op.volume)) - if hlc is not None and not disabled - else float(op.volume) - for op, hlc, disabled in zip(ops, hlcs, dvc) - ] diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py index 4e05df46d3a..dfa85e17ab2 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -3,17 +3,12 @@ from __future__ import annotations import logging -from dataclasses import dataclass, fields, replace -from typing import TYPE_CHECKING, Callable, List, Optional, Sequence, Tuple, TypeVar, Union +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, TypeVar, Union from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop -from pylabrobot.hamilton.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 @@ -22,6 +17,7 @@ from .commands import ( Aspirate, + Dispense as DispenseCommand, DisableADC, DropTips, DropTipsRoll, @@ -35,9 +31,6 @@ _get_default_flow_rate, _get_tip_type_from_tip, ) -from .commands import ( - Dispense as DispenseCommand, -) if TYPE_CHECKING: from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck @@ -83,11 +76,6 @@ 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 @@ -107,11 +95,6 @@ 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 @@ -131,37 +114,6 @@ 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 # --------------------------------------------------------------------------- @@ -646,9 +598,7 @@ 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: 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. + - swap_speed: Swap speed on leaving liquid (uL/s, default: [20.0]*n). - mix_position_from_liquid_surface: Mix position offset from liquid surface (mm, default: [0.0]*n). - limit_curve_index: Limit curve index (default: [0]*n). @@ -657,7 +607,11 @@ async def aspirate( """ if not ops: return - params = _coerce_nimbus_aspirate_params(backend_params) + params = ( + backend_params + if isinstance(backend_params, NimbusPIPAspirateParams) + else NimbusPIPAspirateParams() + ) n = len(ops) @@ -716,71 +670,38 @@ async def aspirate( minimum_heights_mm = well_bottoms.copy() - 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) + volumes = [op.volume for op in ops] + flow_rates: List[float] = [ + op.flow_rate if op.flow_rate is not None else _get_default_flow_rate(op.tip, is_aspirate=True) + for op in ops ] blow_out_air_volumes = [ - op.blow_out_air_volume - if op.blow_out_air_volume is not None - else (hlc.aspiration_blow_out_volume if hlc is not None else 40.0) - for op, hlc in zip(ops, hlcs) + op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops ] - 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 = [ + mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_speed: List[float] = [ op.mix.flow_rate if op.mix is not None else ( op.flow_rate if op.flow_rate is not None - else ( - hlc.aspiration_mix_flow_rate - if hlc is not None - else _get_default_flow_rate(op.tip, is_aspirate=True) - ) + else _get_default_flow_rate(op.tip, is_aspirate=True) ) - for op, hlc in zip(ops, hlcs) + for op in ops ] - # Advanced parameters (backend lists override liquid-class defaults) + # Advanced parameters lld_mode = _fill_in_defaults(params.lld_mode, [0] * n) immersion_depth = _fill_in_defaults(params.immersion_depth, [0.0] * n) surface_following_distance = _fill_in_defaults(params.surface_following_distance, [0.0] * n) gamma_lld_sensitivity = _fill_in_defaults(params.gamma_lld_sensitivity, [0] * n) dp_lld_sensitivity = _fill_in_defaults(params.dp_lld_sensitivity, [0] * n) - settling_time = _fill_in_defaults( - params.settling_time, - [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], - ) + settling_time = _fill_in_defaults(params.settling_time, [1.0] * n) + transport_air_volume = _fill_in_defaults(params.transport_air_volume, [5.0] * n) + pre_wetting_volume = _fill_in_defaults(params.pre_wetting_volume, [0.0] * n) + swap_speed = _fill_in_defaults(params.swap_speed, [20.0] * n) mix_position_from_liquid_surface = _fill_in_defaults( params.mix_position_from_liquid_surface, [0.0] * n ) @@ -798,8 +719,7 @@ 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] - # 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] + swap_speed_units = [round(s * 10) for s in swap_speed] mix_volume_units = [round(v * 10) for v in mix_volume] mix_speed_units = [round(s * 10) for s in mix_speed] mix_position_from_liquid_surface_units = [ @@ -919,9 +839,7 @@ 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: 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. + - swap_speed: Swap speed on leaving liquid (uL/s, default: [20.0]*n). - mix_position_from_liquid_surface: Mix position offset from liquid surface (mm, default: [0.0]*n). - limit_curve_index: Limit curve index (default: [0]*n). @@ -933,7 +851,11 @@ async def dispense( """ if not ops: return - params = _coerce_nimbus_dispense_params(backend_params) + params = ( + backend_params + if isinstance(backend_params, NimbusPIPDispenseParams) + else NimbusPIPDispenseParams() + ) n = len(ops) @@ -992,78 +914,44 @@ async def dispense( minimum_heights_mm = well_bottoms.copy() - 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 = [ + volumes = [op.volume for op in ops] + flow_rates: List[float] = [ op.flow_rate if op.flow_rate is not None - 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) + else _get_default_flow_rate(op.tip, is_aspirate=False) + for op in ops ] blow_out_air_volumes = [ - op.blow_out_air_volume - if op.blow_out_air_volume is not None - else (hlc.dispense_blow_out_volume if hlc is not None else 40.0) - for op, hlc in zip(ops, hlcs) + op.blow_out_air_volume if op.blow_out_air_volume is not None else 40.0 for op in ops ] - 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 = [ + mix_volume: List[float] = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles: List[int] = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_speed: List[float] = [ op.mix.flow_rate if op.mix is not None else ( op.flow_rate if op.flow_rate is not None - else ( - hlc.dispense_mix_flow_rate - if hlc is not None - else _get_default_flow_rate(op.tip, is_aspirate=False) - ) + else _get_default_flow_rate(op.tip, is_aspirate=False) ) - for op, hlc in zip(ops, hlcs) + for op in ops ] - # Advanced parameters (backend lists override liquid-class defaults) + # Advanced parameters lld_mode = _fill_in_defaults(params.lld_mode, [0] * n) immersion_depth = _fill_in_defaults(params.immersion_depth, [0.0] * n) surface_following_distance = _fill_in_defaults(params.surface_following_distance, [0.0] * n) gamma_lld_sensitivity = _fill_in_defaults(params.gamma_lld_sensitivity, [0] * n) - settling_time = _fill_in_defaults( - params.settling_time, - [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], - ) + settling_time = _fill_in_defaults(params.settling_time, [1.0] * n) + transport_air_volume = _fill_in_defaults(params.transport_air_volume, [5.0] * n) + swap_speed = _fill_in_defaults(params.swap_speed, [20.0] * n) mix_position_from_liquid_surface = _fill_in_defaults( params.mix_position_from_liquid_surface, [0.0] * n ) limit_curve_index = _fill_in_defaults(params.limit_curve_index, [0] * n) - cut_off_speed = _fill_in_defaults( - params.cut_off_speed, - [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], - ) + cut_off_speed = _fill_in_defaults(params.cut_off_speed, [25.0] * n) + stop_back_volume = _fill_in_defaults(params.stop_back_volume, [0.0] * n) dispense_offset = _fill_in_defaults(params.dispense_offset, [0.0] * n) # Unit conversions @@ -1077,8 +965,7 @@ 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] - # 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] + swap_speed_units = [round(s * 10) for s in swap_speed] mix_volume_units = [round(v * 10) for v in mix_volume] mix_speed_units = [round(s * 10) for s in mix_speed] mix_position_from_liquid_surface_units = [ diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py deleted file mode 100644 index b25666b9ae7..00000000000 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py +++ /dev/null @@ -1,384 +0,0 @@ -"""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 -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={"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={"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={"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={"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={"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={"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/star/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py index a7986d354dc..cb69be087eb 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py @@ -16,15 +16,18 @@ get_tight_single_resource_liquid_op_offsets, get_wide_single_resource_liquid_op_offsets, ) -from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import HamiltonLiquidClass +from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import ( + HamiltonLiquidClass, + get_star_liquid_class, +) from pylabrobot.resources import Resource, Tip, TipSpot, Well from pylabrobot.resources.hamilton import HamiltonTip, TipDropMethod, TipPickupMethod, TipSize +from pylabrobot.resources.liquid import Liquid from .errors import ( STARFirmwareError, convert_star_firmware_error_to_plr_error, ) -from ..liquid_class_resolver import resolve_hamilton_liquid_classes from .pip_channel import PIPChannel if TYPE_CHECKING: @@ -154,6 +157,48 @@ class LLDMode(enum.Enum): # --------------------------------------------------------------------------- +def _resolve_liquid_classes( + explicit: Optional[List[Optional[HamiltonLiquidClass]]], + ops: list, + jet: Union[bool, List[bool]], + blow_out: Union[bool, List[bool]], + is_aspirate: bool, +) -> List[Optional[HamiltonLiquidClass]]: + """Resolve per-op Hamilton liquid classes. + + If ``explicit`` is None, auto-detect from tip properties for each op. + If ``explicit`` is a list, use it as-is (None entries stay None, matching legacy behavior). + """ + n = len(ops) + if isinstance(jet, bool): + jet = [jet] * n + if isinstance(blow_out, bool): + blow_out = [blow_out] * n + + if explicit is not None: + return list(explicit) + + result: List[Optional[HamiltonLiquidClass]] = [] + for i, op in enumerate(ops): + tip = op.tip + if not isinstance(tip, HamiltonTip): + result.append(None) + continue + result.append( + get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=jet[i], + blow_out=blow_out[i], + ) + ) + + return result + + def _fill(val: Optional[List], default: List) -> List: """Return *val* if given, otherwise *default*. Replace per-element None with default.""" if val is None: @@ -651,7 +696,7 @@ async def aspirate( n = len(ops) # Resolve liquid classes (auto-detect from tip if not provided). - hlcs = resolve_hamilton_liquid_classes( + hlcs = _resolve_liquid_classes( backend_params.hamilton_liquid_classes, ops, jet=backend_params.jet or False, @@ -1132,7 +1177,7 @@ async def dispense( ] # Resolve liquid classes. - hlcs = resolve_hamilton_liquid_classes( + hlcs = _resolve_liquid_classes( backend_params.hamilton_liquid_classes, ops, jet=jet, blow_out=blow_out, is_aspirate=False ) diff --git a/pylabrobot/hamilton/liquid_handlers/tests/__init__.py b/pylabrobot/hamilton/liquid_handlers/tests/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/pylabrobot/hamilton/liquid_handlers/tests/liquid_class_resolver_tests.py b/pylabrobot/hamilton/liquid_handlers/tests/liquid_class_resolver_tests.py deleted file mode 100644 index 75640f17201..00000000000 --- a/pylabrobot/hamilton/liquid_handlers/tests/liquid_class_resolver_tests.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Tests for :mod:`liquid_class_resolver`.""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest - -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.liquid_handlers.star.liquid_classes import get_star_liquid_class -from pylabrobot.resources.hamilton import HamiltonTip, TipPickupMethod, TipSize -from pylabrobot.resources.liquid import Liquid - - -def _hlc(**overrides: float) -> HamiltonLiquidClass: - base = dict( - curve={0.0: 0.0, 1000.0: 1000.0}, - aspiration_flow_rate=1.0, - aspiration_mix_flow_rate=2.0, - aspiration_air_transport_volume=3.0, - aspiration_blow_out_volume=4.0, - aspiration_swap_speed=5.0, - aspiration_settling_time=6.0, - aspiration_over_aspirate_volume=7.0, - aspiration_clot_retract_height=8.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, - ) - base.update(overrides) - return HamiltonLiquidClass(**base) - - -def test_resolve_explicit_returns_copy(): - h = _hlc() - out = resolve_hamilton_liquid_classes([h], [], jet=False, blow_out=False) - assert out == [h] - out[0] = None # type: ignore[assignment] - assert h is not None - - -def test_resolve_auto_non_hamilton_tip_is_none(): - op = SimpleNamespace(tip=object()) - assert resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False) == [None] - - -def test_resolve_auto_hamilton_tip_matches_get_star(): - 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, - ) - op = SimpleNamespace(tip=tip) - a = resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False)[0] - b = get_star_liquid_class( - tip_volume=tip.maximal_volume, - is_core=False, - is_tip=True, - has_filter=tip.has_filter, - liquid=Liquid.WATER, - jet=False, - blow_out=False, - ) - assert a is not None and b is not None - assert a.aspiration_flow_rate == b.aspiration_flow_rate - - -def test_resolve_custom_lookup(): - custom = _hlc(aspiration_flow_rate=99.0) - - def lookup(**kwargs): # noqa: ARG001 - return custom - - 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, - ) - op = SimpleNamespace(tip=tip) - got = resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False, lookup=lookup)[0] - assert got is not None - assert got.aspiration_flow_rate == 99.0 - - -def test_corrected_volumes_respects_disable_and_none_hlc(): - ops = [SimpleNamespace(volume=100.0)] - hlc = _hlc(curve={0.0: 0.0, 100.0: 200.0, 200.0: 400.0}) - assert corrected_volumes_for_ops(ops, [hlc], None) == [200.0] - assert corrected_volumes_for_ops(ops, [hlc], [True]) == [100.0] - assert corrected_volumes_for_ops(ops, [None], None) == [100.0] - - -def test_corrected_volumes_length_mismatch_raises(): - with pytest.raises(ValueError, match="hlcs length"): - corrected_volumes_for_ops([SimpleNamespace(volume=1.0)], []) From 66156e8c04187c6a286497d75f1e49cb4f54d3f8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 20 Apr 2026 20:37:42 -0700 Subject: [PATCH 11/14] Trim PR surface: remove notebook and simplify Nimbus driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete docs/user_guide/.../nimbus_v1_aspirate_dispense.ipynb and prune its toctree entry. - Delete NimbusResolvedInterfaces dataclass, `_nimbus_resolved` / `_resolved_interfaces` instance state, and the `nimbus_interfaces` property — never read externally. Setup consumes the resolution dict locally. - Move build_parameters logic into NimbusCommand with an is_dataclass() guard, dropping 10 identical `return self._build_structured_parameters()` overrides and the helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../00_liquid-handling/_liquid-handling.rst | 1 - .../nimbus_v1_aspirate_dispense.ipynb | 394 ------------------ .../liquid_handlers/nimbus/__init__.py | 1 - .../liquid_handlers/nimbus/chatterbox.py | 13 +- .../liquid_handlers/nimbus/commands.py | 34 +- .../hamilton/liquid_handlers/nimbus/driver.py | 49 +-- .../nimbus/tests/driver_tests.py | 12 - 7 files changed, 19 insertions(+), 485 deletions(-) delete mode 100644 docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_v1_aspirate_dispense.ipynb diff --git a/docs/user_guide/00_liquid-handling/_liquid-handling.rst b/docs/user_guide/00_liquid-handling/_liquid-handling.rst index 8b074f35523..d976603d54e 100644 --- a/docs/user_guide/00_liquid-handling/_liquid-handling.rst +++ b/docs/user_guide/00_liquid-handling/_liquid-handling.rst @@ -16,7 +16,6 @@ Examples: hamilton-vantage/_hamilton-vantage hamilton-prep/_hamilton-prep - hamilton-nimbus/nimbus_v1_aspirate_dispense opentrons/ot2/ot2 tecan-evo/_tecan-evo pumps/_pumps diff --git a/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_v1_aspirate_dispense.ipynb b/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_v1_aspirate_dispense.ipynb deleted file mode 100644 index 88a8d9afc10..00000000000 --- a/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_v1_aspirate_dispense.ipynb +++ /dev/null @@ -1,394 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "a7fe6b22", - "metadata": {}, - "source": [ - "# Nimbus v1 — aspirate / dispense\n", - "\n", - "**v1 shape:** `Nimbus` (device) → `NimbusDriver` (`HamiltonTCPClient`: TCP, introspection, resolved addresses) → `PIP` frontend → `NimbusPIPBackend` (pipette commands). After `setup()`, use **`nimbus.pip`** for moves; **`nimbus.driver`** for transport and low-level access." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "3e414ae0", - "metadata": {}, - "outputs": [], - "source": [ - "from __future__ import annotations\n", - "\n", - "import logging\n", - "import sys\n", - "\n", - "from pylabrobot.hamilton.liquid_handlers.nimbus import Nimbus, NimbusSetupParams\n", - "from pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend import (\n", - " NimbusPIPAspirateParams,\n", - " NimbusPIPDispenseParams,\n", - ")\n", - "from pylabrobot.hamilton.liquid_handlers.star.liquid_classes import get_star_liquid_class\n", - "from pylabrobot.resources import Cor_Axy_96_wellplate_500uL_Ub\n", - "from pylabrobot.resources.coordinate import Coordinate\n", - "from pylabrobot.resources.hamilton import HamiltonTip, NimbusDeck, hamilton_96_tiprack_300uL_filter\n", - "from pylabrobot.resources.liquid import Liquid\n", - "\n", - "logging.getLogger(\"pylabrobot\").setLevel(logging.DEBUG)\n", - "\n", - "\n", - "def nimbus_star_water_hlcs(pip, channels: list[int], *, blow_out: bool = False, liquid: Liquid = Liquid.WATER):\n", - " \"\"\"One STAR liquid class per channel from mounted HamiltonTips (Water, jet=False).\"\"\"\n", - " hlcs = []\n", - " for ch in channels:\n", - " tip = pip.head[ch].get_tip()\n", - " if not isinstance(tip, HamiltonTip):\n", - " raise TypeError(f\"Channel {ch} needs HamiltonTip, got {type(tip).__name__}\")\n", - " hlc = get_star_liquid_class(\n", - " tip_volume=tip.maximal_volume,\n", - " is_core=False,\n", - " is_tip=True,\n", - " has_filter=tip.has_filter,\n", - " liquid=liquid,\n", - " jet=False,\n", - " blow_out=blow_out,\n", - " )\n", - " if hlc is None:\n", - " raise RuntimeError(\"No STAR liquid class for this tip/liquid/jet/blow_out combo.\")\n", - " hlcs.append(hlc)\n", - " return hlcs" - ] - }, - { - "cell_type": "markdown", - "id": "b586973d", - "metadata": {}, - "source": [ - "## Connect\n", - "\n", - "`setup()`: TCP client registration, resolve **`nimbus_core`** / **`pipette`** (and optional **`door_lock`**), channel count, `InitializeSmartRoll` when needed, then **`PIP`** ready on **`nimbus.pip`**." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "7dec2d39", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-04-17 22:46:55,687 - pylabrobot.hamilton.tcp.client - INFO - Initializing Hamilton connection...\n", - "2026-04-17 22:46:55,697 - pylabrobot.hamilton.tcp.client - INFO - Registering Hamilton client...\n", - "2026-04-17 22:46:55,706 - pylabrobot.hamilton.tcp.client - INFO - Discovering Hamilton root objects...\n", - "2026-04-17 22:46:55,716 - pylabrobot.hamilton.tcp.client - INFO - Discovering Hamilton global objects...\n", - "2026-04-17 22:46:55,732 - pylabrobot.hamilton.tcp.client - INFO - Hamilton TCP client setup complete. Client ID: 2, globals: 1\n", - "2026-04-17 22:46:56,645 - pylabrobot.hamilton.tcp.interface_bundle - INFO - Nimbus interfaces: door_lock, nimbus_core, pipette\n", - "2026-04-17 22:46:57,469 - pylabrobot.hamilton.liquid_handlers.nimbus.driver - INFO - Channel configuration: 4 channels\n", - "2026-04-17 22:46:58,358 - pylabrobot.hamilton.liquid_handlers.nimbus.door - INFO - Door locked successfully\n", - "2026-04-17 22:46:58,763 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - Channel configuration set for 4 channels\n", - "2026-04-17 22:47:13,333 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - NimbusCore initialized with InitializeSmartRoll successfully\n" - ] - } - ], - "source": [ - "HOST = \"192.168.100.100\"\n", - "PORT = 2000\n", - "# Create deck to attach to Nimbus\n", - "deck = NimbusDeck()\n", - "tips = hamilton_96_tiprack_300uL_filter(name=\"tips_01\", with_tips=True)\n", - "plate = Cor_Axy_96_wellplate_500uL_Ub(name=\"plate_01\", with_lid=False)\n", - "deck.assign_child_resource(tips, location=Coordinate(x=305.750, y=126.537, z=128.620))\n", - "deck.assign_child_resource(plate, location=Coordinate(x=438.070, y=124.837, z=101.490))\n", - "\n", - "nimbus = Nimbus(deck=deck, host=HOST, port=PORT)\n", - "await nimbus.setup(NimbusSetupParams(deck=deck))" - ] - }, - { - "cell_type": "markdown", - "id": "a4453923", - "metadata": {}, - "source": [ - "## Interfaces\n" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "7eb18e35", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "nimbus_core: 'NimbusCORE' → 1:1:48896\n", - "pipette: 'NimbusCORE.Pipette' → 1:1:257\n", - "door_lock: 'NimbusCORE.DoorLock' → 1:1:268\n" - ] - } - ], - "source": [ - "from pylabrobot.hamilton.liquid_handlers.nimbus.driver import nimbus_interface_specs_for_root\n", - "\n", - "d = nimbus.driver\n", - "root = await d.introspection.get_object(d.get_root_object_addresses()[0])\n", - "for role, spec in nimbus_interface_specs_for_root(root.name).items():\n", - " addr = getattr(d.nimbus_interfaces, role)\n", - " if addr is None:\n", - " print(f\"{role}: {spec.path!r} → None\")\n", - " else:\n", - " print(f\"{role}: {spec.path!r} → {await d.resolve_path(spec.path)}\")" - ] - }, - { - "cell_type": "markdown", - "id": "7b89abf3", - "metadata": {}, - "source": [ - "## Pick up tips\n", - "\n", - "Tip column slice + **`[:nc]`** (works for 4- or 8-channel heads). Adjust column to match tips on your rack." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "92e6710a", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "channels: 4\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-04-17 22:47:51,227 - pylabrobot.hamilton.tcp.client - ERROR - Hamilton COMMAND_EXCEPTION (action=0x5) on 4 channel(s): ch0: Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 275) iface=1 action=6 (path=NimbusCORE.Channel, addr=1:1:275, method=[1:6] [1:6] PickupTip(zStartPosition: i32, zStopPosition: i32, zFinal: i32, volume: u32, colletCheck: i16) -> void), ch1: Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 274) iface=1 action=6 (path=NimbusCORE.Channel, addr=1:1:274, method=[1:6] [1:6] PickupTip(zStartPosition: i32, zStopPosition: i32, zFinal: i32, volume: u32, colletCheck: i16) -> void), ch2: Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 272) iface=1 action=6 (path=NimbusCORE.Channel, addr=1:1:272, method=[1:6] [1:6] PickupTip(zStartPosition: i32, zStopPosition: i32, zFinal: i32, volume: u32, colletCheck: i16) -> void), ch3: No Tip Picked Up. (HcResult=0x0F4B) at (1, 1, 273) iface=1 action=6 (path=NimbusCORE.Channel, addr=1:1:273, method=[1:6] [1:6] PickupTip(zStartPosition: i32, zStopPosition: i32, zFinal: i32, volume: u32, colletCheck: i16) -> void)\n" - ] - }, - { - "ename": "ChannelizedError", - "evalue": "ChannelizedError(errors={0: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 275) iface=1 action=6'), 1: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 274) iface=1 action=6'), 2: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 272) iface=1 action=6'), 3: RuntimeError('No Tip Picked Up. (HcResult=0x0F4B) at (1, 1, 273) iface=1 action=6')}, raw_response=b'!\\x00\\x02\\x00\\x15\\x00\\x0f\\x00\\xa0\\x000x0001.0x0001.0x0113:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0112:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0110:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0111:0x01,0x0006,0x0F4B\\x00', hoi_entries=[HcResultEntry(module_id=1, node_id=1, object_id=275, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=274, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=272, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=273, interface_id=1, action_id=6, result=3915)], hoi_exceptions={0: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 275) iface=1 action=6'), 1: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 274) iface=1 action=6'), 2: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 272) iface=1 action=6'), 3: RuntimeError('No Tip Picked Up. (HcResult=0x0F4B) at (1, 1, 273) iface=1 action=6')})", - "output_type": "error", - "traceback": [ - "\u001b[31m---------------------------------------------------------------------------\u001b[39m", - "\u001b[31mChannelizedError\u001b[39m Traceback (most recent call last)", - "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[4]\u001b[39m\u001b[32m, line 5\u001b[39m\n\u001b[32m 3\u001b[39m \u001b[38;5;28mprint\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mchannels: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mnc\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m)\n\u001b[32m 4\u001b[39m tip_pick = tips[\u001b[33m\"\u001b[39m\u001b[33mA9:H9\u001b[39m\u001b[33m\"\u001b[39m][:nc]\n\u001b[32m----> \u001b[39m\u001b[32m5\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m nimbus.pip.pick_up_tips(tip_pick)\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/repos/pull_Requests/prep/pylabrobot/capabilities/capability.py:46\u001b[39m, in \u001b[36mneed_capability_ready..wrapper\u001b[39m\u001b[34m(*args, **kwargs)\u001b[39m\n\u001b[32m 44\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28mself\u001b[39m.setup_finished:\n\u001b[32m 45\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[33m\"\u001b[39m\u001b[33mThe capability has not been set up. Call setup() on the parent device.\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m---> \u001b[39m\u001b[32m46\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mawait\u001b[39;00m func(*args, **kwargs)\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/repos/pull_Requests/prep/pylabrobot/capabilities/liquid_handling/pip.py:191\u001b[39m, in \u001b[36mPIP.pick_up_tips\u001b[39m\u001b[34m(self, tip_spots, use_channels, offsets, backend_params)\u001b[39m\n\u001b[32m 188\u001b[39m (\u001b[38;5;28mself\u001b[39m.head[channel].commit \u001b[38;5;28;01mif\u001b[39;00m success \u001b[38;5;28;01melse\u001b[39;00m \u001b[38;5;28mself\u001b[39m.head[channel].rollback)()\n\u001b[32m 190\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m error \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m191\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m error\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/repos/pull_Requests/prep/pylabrobot/capabilities/liquid_handling/pip.py:166\u001b[39m, in \u001b[36mPIP.pick_up_tips\u001b[39m\u001b[34m(self, tip_spots, use_channels, offsets, backend_params)\u001b[39m\n\u001b[32m 164\u001b[39m error: Optional[\u001b[38;5;167;01mBaseException\u001b[39;00m] = \u001b[38;5;28;01mNone\u001b[39;00m\n\u001b[32m 165\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m166\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.backend.pick_up_tips(\n\u001b[32m 167\u001b[39m ops=pickups, use_channels=use_channels, backend_params=backend_params\n\u001b[32m 168\u001b[39m )\n\u001b[32m 169\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mBaseException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n\u001b[32m 170\u001b[39m error = e\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/repos/pull_Requests/prep/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py:511\u001b[39m, in \u001b[36mNimbusPIPBackend.pick_up_tips\u001b[39m\u001b[34m(self, ops, use_channels, backend_params)\u001b[39m\n\u001b[32m 498\u001b[39m traverse_height_units = \u001b[38;5;28mround\u001b[39m(traverse_height * \u001b[32m100\u001b[39m)\n\u001b[32m 500\u001b[39m command = PickupTips(\n\u001b[32m 501\u001b[39m dest=\u001b[38;5;28mself\u001b[39m.pipette_address,\n\u001b[32m 502\u001b[39m channels_involved=channels_involved,\n\u001b[32m (...)\u001b[39m\u001b[32m 508\u001b[39m tip_types=tip_types_full,\n\u001b[32m 509\u001b[39m )\n\u001b[32m--> \u001b[39m\u001b[32m511\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.driver.send_command(command)\n\u001b[32m 512\u001b[39m logger.info(\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mPicked up tips on channels \u001b[39m\u001b[38;5;132;01m{\u001b[39;00muse_channels\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m)\n", - "\u001b[36mFile \u001b[39m\u001b[32m~/repos/pull_Requests/prep/pylabrobot/hamilton/tcp/client.py:621\u001b[39m, in \u001b[36mHamiltonTCPClient.send_command\u001b[39m\u001b[34m(self, command, ensure_connection, return_raw, raise_on_error, read_timeout)\u001b[39m\n\u001b[32m 606\u001b[39m channel_summary = \u001b[33m\"\u001b[39m\u001b[33m, \u001b[39m\u001b[33m\"\u001b[39m.join(\n\u001b[32m 607\u001b[39m (\n\u001b[32m 608\u001b[39m \u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mch\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mch\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mper_channel[ch]\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m (\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mcontext_by_channel[ch]\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m)\u001b[39m\u001b[33m\"\u001b[39m\n\u001b[32m (...)\u001b[39m\u001b[32m 612\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m ch \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28msorted\u001b[39m(per_channel)\n\u001b[32m 613\u001b[39m )\n\u001b[32m 614\u001b[39m logger.error(\n\u001b[32m 615\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mHamilton \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m (action=\u001b[39m\u001b[38;5;132;01m%#x\u001b[39;00m\u001b[33m) on \u001b[39m\u001b[38;5;132;01m%d\u001b[39;00m\u001b[33m channel(s): \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m\"\u001b[39m,\n\u001b[32m 616\u001b[39m action.name,\n\u001b[32m (...)\u001b[39m\u001b[32m 619\u001b[39m channel_summary,\n\u001b[32m 620\u001b[39m )\n\u001b[32m--> \u001b[39m\u001b[32m621\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m ChannelizedError(\n\u001b[32m 622\u001b[39m errors=per_channel,\n\u001b[32m 623\u001b[39m raw_response=response_message.hoi.params,\n\u001b[32m 624\u001b[39m hoi_entries=\u001b[38;5;28mlist\u001b[39m(entries),\n\u001b[32m 625\u001b[39m hoi_exceptions=hoi_exceptions,\n\u001b[32m 626\u001b[39m )\n\u001b[32m 627\u001b[39m logger.debug(\n\u001b[32m 628\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mHamilton \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m (action=\u001b[39m\u001b[38;5;132;01m%#x\u001b[39;00m\u001b[33m) suppressed; entries=\u001b[39m\u001b[38;5;132;01m%d\u001b[39;00m\u001b[33m (raise_on_error=False)\u001b[39m\u001b[33m\"\u001b[39m,\n\u001b[32m 629\u001b[39m action.name,\n\u001b[32m 630\u001b[39m action,\n\u001b[32m 631\u001b[39m \u001b[38;5;28mlen\u001b[39m(entries),\n\u001b[32m 632\u001b[39m )\n\u001b[32m 633\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m\n", - "\u001b[31mChannelizedError\u001b[39m: ChannelizedError(errors={0: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 275) iface=1 action=6'), 1: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 274) iface=1 action=6'), 2: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 272) iface=1 action=6'), 3: RuntimeError('No Tip Picked Up. (HcResult=0x0F4B) at (1, 1, 273) iface=1 action=6')}, raw_response=b'!\\x00\\x02\\x00\\x15\\x00\\x0f\\x00\\xa0\\x000x0001.0x0001.0x0113:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0112:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0110:0x01,0x0006,0x0F4E;0x0001.0x0001.0x0111:0x01,0x0006,0x0F4B\\x00', hoi_entries=[HcResultEntry(module_id=1, node_id=1, object_id=275, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=274, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=272, interface_id=1, action_id=6, result=3918), HcResultEntry(module_id=1, node_id=1, object_id=273, interface_id=1, action_id=6, result=3915)], hoi_exceptions={0: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 275) iface=1 action=6'), 1: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 274) iface=1 action=6'), 2: RuntimeError('Tip Detected Not Correct Tip. (HcResult=0x0F4E) at (1, 1, 272) iface=1 action=6'), 3: RuntimeError('No Tip Picked Up. (HcResult=0x0F4B) at (1, 1, 273) iface=1 action=6')})" - ] - } - ], - "source": [ - "# Example of a Channelized error from failed tip pickup\n", - "nc = nimbus.driver.pip.num_channels\n", - "print(f\"channels: {nc}\")\n", - "tip_pick = tips[\"A9:H9\"][:nc]\n", - "await nimbus.pip.pick_up_tips(tip_pick)" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "3192a2da", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-04-17 22:48:19,006 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - Picked up tips on channels [0, 1, 2, 3]\n" - ] - } - ], - "source": [ - "# Successful tip pickup\n", - "tip_pick = tips[\"E9:H9\"][:nc]\n", - "await nimbus.pip.pick_up_tips(tip_pick)" - ] - }, - { - "cell_type": "markdown", - "id": "37337994", - "metadata": {}, - "source": [ - "## Aspirate\n", - "\n", - "`vols`, `liquid_height` (mm from well bottom), `flow_rates` — length **`nc`** (see `PIP` docs).\n", - "\n", - "**Hamilton liquid classes:** `nimbus_star_water_hlcs` (defined with imports) builds per-channel classes; set **`blow_out_liquid_class`** to match partial vs blow-out STAR rows. **`swap_speed_mm_s`** sets leave-liquid speed (**mm/s**). Shared **`hlc_kw`** is reused for dispense below.\n", - "\n", - "**swap_speed vs logs:** The `\"Aspirated on channels …\"` line does **not** print `swap_speed`. Values are still set on the `Aspirate` command: firmware uses **0.01 mm/s** per element, so **`15.0` mm/s → `1500`** on each active channel index. Use a TCP/trace capture or a temporary `send_command` wrapper if you need to see the raw command object." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "a11988ea", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-04-17 22:48:28,053 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - Aspirated on channels [0, 1, 2, 3]\n" - ] - } - ], - "source": [ - "ch = list(range(nc))\n", - "blow_out_liquid_class = False # True → STAR blow-out / empty-tip style row\n", - "swap_speed_mm_s = 30.0\n", - "\n", - "hlc_kw = dict(\n", - " hamilton_liquid_classes=nimbus_star_water_hlcs(nimbus.pip, ch, blow_out=blow_out_liquid_class),\n", - " swap_speed=[swap_speed_mm_s] * nc,\n", - ")\n", - "\n", - "await nimbus.pip.aspirate(\n", - " plate[\"A1:H1\"][:nc],\n", - " vols=[50.0] * nc,\n", - " liquid_height=[2.0] * nc,\n", - " backend_params=NimbusPIPAspirateParams(**hlc_kw),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "2091b5d0", - "metadata": {}, - "source": [ - "## Dispense\n", - "\n", - "Example: second column (`A2:H2` vs `A1:H1`). Same **`hlc_kw`** as aspirate so liquid class + swap speed stay aligned." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "52eddbc5", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-04-17 22:48:31,971 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - Dispensed on channels [0, 1, 2, 3]\n" - ] - } - ], - "source": [ - "await nimbus.pip.dispense(\n", - " plate[\"A2:H2\"][:nc],\n", - " vols=[50.0] * nc,\n", - " liquid_height=[2.0] * nc,\n", - " backend_params=NimbusPIPDispenseParams(**hlc_kw),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "34fe6a83", - "metadata": {}, - "source": [ - "## Drop tips\n", - "\n", - "To waste **`Trash`** sites (`waste_sites`), not back to the rack." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "95b64f3c", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-04-17 22:48:38,246 - pylabrobot.hamilton.liquid_handlers.nimbus.pip_backend - INFO - Dropped tips on channels [0, 1, 2, 3]\n" - ] - } - ], - "source": [ - "waste_sites = [deck.get_resource(f\"default_long_{i}\") for i in range(1, nc + 1)]\n", - "\n", - "await nimbus.pip.drop_tips(tip_pick)" - ] - }, - { - "cell_type": "markdown", - "id": "e861af9f", - "metadata": {}, - "source": [ - "## Cleanup\n", - "\n", - "Park if you want the head stowed; unlock door when **`door_lock`** exists; **`stop()`** closes TCP." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "d2f54b37", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-04-17 22:48:41,688 - pylabrobot.hamilton.liquid_handlers.nimbus.driver - INFO - Instrument parked successfully\n", - "2026-04-17 22:48:41,782 - pylabrobot.hamilton.liquid_handlers.nimbus.door - INFO - Door unlocked successfully\n", - "2026-04-17 22:48:41,783 - pylabrobot.io.socket - INFO - Closing connection to socket 192.168.100.100:2000\n", - "2026-04-17 22:48:41,784 - pylabrobot.hamilton.tcp.client - INFO - Hamilton TCP client stopped\n" - ] - } - ], - "source": [ - "await nimbus.park()\n", - "if nimbus.driver.door is not None:\n", - " await nimbus.unlock_door()\n", - "\n", - "await nimbus.stop()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "682744ae", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": ".venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.8" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py index 2a91a3d4223..c04ff8393d4 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py @@ -2,7 +2,6 @@ from .door import NimbusDoor from .driver import ( NimbusDriver, - NimbusResolvedInterfaces, NimbusSetupParams, nimbus_interface_specs_for_root, ) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py index acd828dd9e2..959a31e006c 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py @@ -11,7 +11,7 @@ from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck from .door import NimbusDoor -from .driver import NimbusDriver, NimbusResolvedInterfaces, NimbusSetupParams +from .driver import NimbusDriver, NimbusSetupParams logger = logging.getLogger(__name__) @@ -36,15 +36,8 @@ async def setup(self, backend_params: Optional[BackendParams] = None): # Use canned addresses (skip TCP connection entirely) pipette_address = Address(1, 1, 257) - nimbus_core_address = Address(1, 1, 48896) - self._nimbus_core_address = nimbus_core_address + self._nimbus_core_address = Address(1, 1, 48896) door_address = Address(1, 1, 268) - self._resolved_interfaces = { - "nimbus_core": nimbus_core_address, - "pipette": pipette_address, - "door_lock": door_address, - } - self._nimbus_resolved = NimbusResolvedInterfaces.from_resolution_map(self._resolved_interfaces) self.pip = NimbusPIPBackend( driver=self, deck=self.deck, address=pipette_address, num_channels=self._num_channels @@ -57,8 +50,6 @@ async def stop(self): if self.door is not None: await self.door._on_stop() self.door = None - self._resolved_interfaces = {} - self._nimbus_resolved = None async def send_command( self, diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py index 2b087dc2053..9cc180f3ca4 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py @@ -8,7 +8,7 @@ import enum import logging -from dataclasses import dataclass +from dataclasses import dataclass, is_dataclass from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.messages import HoiParams, HoiParamsParser @@ -31,14 +31,18 @@ class NimbusCommand(TCPCommand): - """Thin Nimbus command base for namespace clarity.""" + """Thin Nimbus command base for namespace clarity. + + Dataclass subclasses with wire-annotated fields are auto-serialized via + :meth:`HoiParams.from_struct`. Non-dataclass commands (e.g. status queries + with no payload) inherit empty params from :class:`TCPCommand`. + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 1 - def _build_structured_parameters(self) -> HoiParams: - """Serialize wire-annotated dataclass payload fields in declaration order.""" - return HoiParams.from_struct(self) + def build_parameters(self) -> HoiParams: + return HoiParams.from_struct(self) if is_dataclass(self) else HoiParams() # ============================================================================ @@ -196,8 +200,6 @@ class InitializeSmartRoll(NimbusCommand): def __post_init__(self): super().__init__(self.dest) - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() class IsInitialized(NimbusCommand): @@ -275,8 +277,6 @@ class SetChannelConfiguration(NimbusCommand): def __post_init__(self): super().__init__(self.dest) - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() @dataclass @@ -300,8 +300,6 @@ class GetChannelConfiguration(NimbusCommand): def __post_init__(self): super().__init__(self.dest) - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() @classmethod def parse_response_parameters(cls, data: bytes) -> dict: @@ -350,8 +348,6 @@ class PickupTips(NimbusCommand): def __post_init__(self): super().__init__(self.dest) - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() @dataclass @@ -383,8 +379,6 @@ class DropTips(NimbusCommand): def __post_init__(self): super().__init__(self.dest) - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() @dataclass @@ -415,8 +409,6 @@ class DropTipsRoll(NimbusCommand): def __post_init__(self): super().__init__(self.dest) - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() @dataclass @@ -437,8 +429,6 @@ class EnableADC(NimbusCommand): def __post_init__(self): super().__init__(self.dest) - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() @dataclass @@ -459,8 +449,6 @@ class DisableADC(NimbusCommand): def __post_init__(self): super().__init__(self.dest) - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() @dataclass @@ -528,8 +516,6 @@ class Aspirate(NimbusCommand): def __post_init__(self): super().__init__(self.dest) - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() @dataclass @@ -597,5 +583,3 @@ class Dispense(NimbusCommand): def __post_init__(self): super().__init__(self.dest) - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py index 544633fd58d..14619f4cc46 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -4,7 +4,7 @@ import logging from dataclasses import dataclass -from typing import Dict, Mapping, Optional, Set, Tuple +from typing import Dict, Optional, Set, Tuple from pylabrobot.capabilities.capability import BackendParams from pylabrobot.hamilton.tcp.client import HamiltonTCPClient @@ -32,27 +32,6 @@ def nimbus_interface_specs_for_root(root_name: str) -> Dict[str, InterfacePathSp } -@dataclass(frozen=True) -class NimbusResolvedInterfaces: - """Concrete Nimbus firmware handles after :meth:`NimbusDriver.setup`.""" - - nimbus_core: Address - pipette: Address - door_lock: Optional[Address] - - @staticmethod - def from_resolution_map(m: Mapping[str, Optional[Address]]) -> NimbusResolvedInterfaces: - nc = m.get("nimbus_core") - pip = m.get("pipette") - if nc is None or pip is None: - raise RuntimeError("internal: missing required Nimbus interfaces") - return NimbusResolvedInterfaces( - nimbus_core=nc, - pipette=pip, - door_lock=m.get("door_lock"), - ) - - @dataclass class NimbusSetupParams(BackendParams): require_door_lock: bool = False @@ -110,18 +89,10 @@ def __init__( self.deck = deck self._nimbus_core_address: Optional[Address] = None - self._resolved_interfaces: Dict[str, Optional[Address]] = {} - self._nimbus_resolved: Optional[NimbusResolvedInterfaces] = None self.pip: NimbusPIPBackend # set in setup() self.door: Optional[NimbusDoor] = None # set in setup() if available - @property - def nimbus_interfaces(self) -> NimbusResolvedInterfaces: - if self._nimbus_resolved is None: - raise RuntimeError("Nimbus interfaces not resolved. Call setup() first.") - return self._nimbus_resolved - @property def nimbus_core_address(self) -> Address: if self._nimbus_core_address is None: @@ -152,15 +123,13 @@ async def setup(self, backend_params: Optional[BackendParams] = None): ) specs = nimbus_interface_specs_for_root(root_info.name) - self._resolved_interfaces = await resolve_interface_path_specs( - self, specs, instrument_label="Nimbus" - ) - self._nimbus_resolved = NimbusResolvedInterfaces.from_resolution_map(self._resolved_interfaces) - self._nimbus_core_address = self._nimbus_resolved.nimbus_core - - nimbus_core_address = self._nimbus_resolved.nimbus_core - pipette_address = self._nimbus_resolved.pipette - door_address = self._nimbus_resolved.door_lock + resolved = await resolve_interface_path_specs(self, specs, instrument_label="Nimbus") + nimbus_core_address = resolved.get("nimbus_core") + pipette_address = resolved.get("pipette") + door_address = resolved.get("door_lock") + if nimbus_core_address is None or pipette_address is None: + raise RuntimeError("internal: missing required Nimbus interfaces") + self._nimbus_core_address = nimbus_core_address await self._assert_required_methods( nimbus_core_address, @@ -199,8 +168,6 @@ async def stop(self): await self.door._on_stop() await super().stop() self.door = None - self._resolved_interfaces = {} - self._nimbus_resolved = None async def _assert_required_methods( self, diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py index 1fc2aa2686f..76f27ff9bca 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py @@ -7,7 +7,6 @@ from pylabrobot.hamilton.liquid_handlers.nimbus.commands import GetChannelConfiguration_1 from pylabrobot.hamilton.liquid_handlers.nimbus.driver import ( NimbusDriver, - NimbusResolvedInterfaces, nimbus_interface_specs_for_root, ) from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES @@ -100,17 +99,6 @@ def test_nimbus_interface_specs_for_root_paths(): assert s["door_lock"].required is False -def test_nimbus_resolved_interfaces_from_map_optional_door(): - core = Address(1, 1, 100) - pip = Address(1, 1, 200) - r = NimbusResolvedInterfaces.from_resolution_map( - {"nimbus_core": core, "pipette": pip, "door_lock": None} - ) - assert r.nimbus_core == core - assert r.pipette == pip - assert r.door_lock is None - - def test_resolve_interface_path_specs_required_missing_raises(): async def _run() -> None: from unittest.mock import AsyncMock From e794c253a310235d0c53e7c6c60185c221f74223 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:40:56 -0700 Subject: [PATCH 12/14] Update from Prep-v1 branch: Introspection API improvements and consistency --- .../liquid_handlers/nimbus/__init__.py | 1 + .../liquid_handlers/nimbus/chatterbox.py | 33 +- .../liquid_handlers/nimbus/commands.py | 34 +- .../hamilton/liquid_handlers/nimbus/driver.py | 73 +- .../hamilton/liquid_handlers/nimbus/nimbus.py | 21 +- .../liquid_handlers/nimbus/pip_backend.py | 224 ++++-- .../nimbus/tests/driver_tests.py | 44 +- .../nimbus/tests/pip_backend_tests.py | 384 ++++++++++ pylabrobot/hamilton/tcp/client.py | 112 ++- pylabrobot/hamilton/tcp/commands.py | 2 +- pylabrobot/hamilton/tcp/error_tables.py | 551 +++++++++++++- pylabrobot/hamilton/tcp/introspection.py | 686 ++++++++++-------- pylabrobot/hamilton/tcp/messages.py | 73 +- pylabrobot/hamilton/tcp/tcp_tests.py | 151 +++- 14 files changed, 1898 insertions(+), 491 deletions(-) create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py index c04ff8393d4..2a91a3d4223 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py @@ -2,6 +2,7 @@ from .door import NimbusDoor from .driver import ( NimbusDriver, + NimbusResolvedInterfaces, NimbusSetupParams, nimbus_interface_specs_for_root, ) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py index 959a31e006c..76e994ae938 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py @@ -8,10 +8,9 @@ from pylabrobot.capabilities.capability import BackendParams from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.packets import Address -from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck from .door import NimbusDoor -from .driver import NimbusDriver, NimbusSetupParams +from .driver import NimbusDriver, NimbusResolvedInterfaces, NimbusSetupParams logger = logging.getLogger(__name__) @@ -23,33 +22,49 @@ class NimbusChatterboxDriver(NimbusDriver): and use canned addresses and responses instead. """ - def __init__(self, deck: NimbusDeck, num_channels: int = 8): + def __init__(self, num_channels: int = 8): # Pass dummy host — Socket is created but never opened - super().__init__(deck=deck, host="chatterbox", port=2000) + super().__init__(host="chatterbox", port=2000) self._num_channels = num_channels async def setup(self, backend_params: Optional[BackendParams] = None): from .pip_backend import NimbusPIPBackend - if not isinstance(backend_params, NimbusSetupParams): - backend_params = NimbusSetupParams() + 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__}" + ) # Use canned addresses (skip TCP connection entirely) pipette_address = Address(1, 1, 257) - self._nimbus_core_address = Address(1, 1, 48896) + nimbus_core_address = Address(1, 1, 48896) + self._nimbus_core_address = nimbus_core_address door_address = Address(1, 1, 268) + self._resolved_interfaces = { + "nimbus_core": nimbus_core_address, + "pipette": pipette_address, + "door_lock": door_address, + } + self._nimbus_resolved = NimbusResolvedInterfaces.from_resolution_map(self._resolved_interfaces) self.pip = NimbusPIPBackend( - driver=self, deck=self.deck, address=pipette_address, num_channels=self._num_channels + driver=self, deck=params.deck, address=pipette_address, num_channels=self._num_channels ) self.door = NimbusDoor(driver=self, address=door_address) - if backend_params.require_door_lock and self.door is None: + if params.require_door_lock and self.door is None: raise RuntimeError("DoorLock is required but not available on this instrument.") async def stop(self): if self.door is not None: await self.door._on_stop() self.door = None + self._resolved_interfaces = {} + self._nimbus_resolved = None async def send_command( self, diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py index 9cc180f3ca4..2b087dc2053 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py @@ -8,7 +8,7 @@ import enum import logging -from dataclasses import dataclass, is_dataclass +from dataclasses import dataclass from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.messages import HoiParams, HoiParamsParser @@ -31,18 +31,14 @@ class NimbusCommand(TCPCommand): - """Thin Nimbus command base for namespace clarity. - - Dataclass subclasses with wire-annotated fields are auto-serialized via - :meth:`HoiParams.from_struct`. Non-dataclass commands (e.g. status queries - with no payload) inherit empty params from :class:`TCPCommand`. - """ + """Thin Nimbus command base for namespace clarity.""" protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 1 - def build_parameters(self) -> HoiParams: - return HoiParams.from_struct(self) if is_dataclass(self) else HoiParams() + def _build_structured_parameters(self) -> HoiParams: + """Serialize wire-annotated dataclass payload fields in declaration order.""" + return HoiParams.from_struct(self) # ============================================================================ @@ -200,6 +196,8 @@ class InitializeSmartRoll(NimbusCommand): def __post_init__(self): super().__init__(self.dest) + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() class IsInitialized(NimbusCommand): @@ -277,6 +275,8 @@ class SetChannelConfiguration(NimbusCommand): def __post_init__(self): super().__init__(self.dest) + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() @dataclass @@ -300,6 +300,8 @@ class GetChannelConfiguration(NimbusCommand): def __post_init__(self): super().__init__(self.dest) + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() @classmethod def parse_response_parameters(cls, data: bytes) -> dict: @@ -348,6 +350,8 @@ class PickupTips(NimbusCommand): def __post_init__(self): super().__init__(self.dest) + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() @dataclass @@ -379,6 +383,8 @@ class DropTips(NimbusCommand): def __post_init__(self): super().__init__(self.dest) + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() @dataclass @@ -409,6 +415,8 @@ class DropTipsRoll(NimbusCommand): def __post_init__(self): super().__init__(self.dest) + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() @dataclass @@ -429,6 +437,8 @@ class EnableADC(NimbusCommand): def __post_init__(self): super().__init__(self.dest) + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() @dataclass @@ -449,6 +459,8 @@ class DisableADC(NimbusCommand): def __post_init__(self): super().__init__(self.dest) + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() @dataclass @@ -516,6 +528,8 @@ class Aspirate(NimbusCommand): def __post_init__(self): super().__init__(self.dest) + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() @dataclass @@ -583,3 +597,5 @@ class Dispense(NimbusCommand): def __post_init__(self): super().__init__(self.dest) + def build_parameters(self) -> HoiParams: + return self._build_structured_parameters() diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py index 14619f4cc46..279e3ed9aeb 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -4,7 +4,7 @@ import logging from dataclasses import dataclass -from typing import Dict, Optional, Set, Tuple +from typing import Dict, Mapping, Optional, Set from pylabrobot.capabilities.capability import BackendParams from pylabrobot.hamilton.tcp.client import HamiltonTCPClient @@ -32,8 +32,30 @@ def nimbus_interface_specs_for_root(root_name: str) -> Dict[str, InterfacePathSp } +@dataclass(frozen=True) +class NimbusResolvedInterfaces: + """Concrete Nimbus firmware handles after :meth:`NimbusDriver.setup`.""" + + nimbus_core: Address + pipette: Address + door_lock: Optional[Address] + + @staticmethod + def from_resolution_map(m: Mapping[str, Optional[Address]]) -> NimbusResolvedInterfaces: + nc = m.get("nimbus_core") + pip = m.get("pipette") + if nc is None or pip is None: + raise RuntimeError("internal: missing required Nimbus interfaces") + return NimbusResolvedInterfaces( + nimbus_core=nc, + pipette=pip, + door_lock=m.get("door_lock"), + ) + + @dataclass class NimbusSetupParams(BackendParams): + deck: Optional[NimbusDeck] = None require_door_lock: bool = False @@ -44,6 +66,8 @@ class NimbusDriver(HamiltonTCPClient): manages the PIP backend and door subsystem. """ + _ERROR_CODES = NIMBUS_ERROR_CODES + _REQUIRED_METHODS_CORE: Set[int] = { 3, 14, @@ -65,7 +89,6 @@ class NimbusDriver(HamiltonTCPClient): def __init__( self, - deck: NimbusDeck, host: str, port: int = 2000, read_timeout: float = 300.0, @@ -73,9 +96,7 @@ def __init__( auto_reconnect: bool = True, max_reconnect_attempts: int = 3, connection_timeout: int = 600, - error_codes: Optional[Dict[Tuple[int, int, int, int, int], str]] = None, ): - merged_error_codes = {**NIMBUS_ERROR_CODES, **(error_codes or {})} super().__init__( host=host, port=port, @@ -84,15 +105,21 @@ def __init__( auto_reconnect=auto_reconnect, max_reconnect_attempts=max_reconnect_attempts, connection_timeout=connection_timeout, - error_codes=merged_error_codes, ) - self.deck = deck self._nimbus_core_address: Optional[Address] = None + self._resolved_interfaces: Dict[str, Optional[Address]] = {} + self._nimbus_resolved: Optional[NimbusResolvedInterfaces] = None self.pip: NimbusPIPBackend # set in setup() self.door: Optional[NimbusDoor] = None # set in setup() if available + @property + def nimbus_interfaces(self) -> NimbusResolvedInterfaces: + if self._nimbus_resolved is None: + raise RuntimeError("Nimbus interfaces not resolved. Call setup() first.") + return self._nimbus_resolved + @property def nimbus_core_address(self) -> Address: if self._nimbus_core_address is None: @@ -105,10 +132,16 @@ async def setup(self, backend_params: Optional[BackendParams] = None): Args: backend_params: Optional :class:`NimbusSetupParams`. """ - if not isinstance(backend_params, NimbusSetupParams): - backend_params = NimbusSetupParams() + 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__}" + ) - assert self.deck is not None, "NimbusDriver requires a deck before setup()" # TCP connection + Protocol 7 + Protocol 3 + root discovery await super().setup() @@ -123,13 +156,15 @@ async def setup(self, backend_params: Optional[BackendParams] = None): ) specs = nimbus_interface_specs_for_root(root_info.name) - resolved = await resolve_interface_path_specs(self, specs, instrument_label="Nimbus") - nimbus_core_address = resolved.get("nimbus_core") - pipette_address = resolved.get("pipette") - door_address = resolved.get("door_lock") - if nimbus_core_address is None or pipette_address is None: - raise RuntimeError("internal: missing required Nimbus interfaces") - self._nimbus_core_address = nimbus_core_address + self._resolved_interfaces = await resolve_interface_path_specs( + self, specs, instrument_label="Nimbus" + ) + self._nimbus_resolved = NimbusResolvedInterfaces.from_resolution_map(self._resolved_interfaces) + self._nimbus_core_address = self._nimbus_resolved.nimbus_core + + nimbus_core_address = self._nimbus_resolved.nimbus_core + pipette_address = self._nimbus_resolved.pipette + door_address = self._nimbus_resolved.door_lock await self._assert_required_methods( nimbus_core_address, @@ -150,12 +185,12 @@ async def setup(self, backend_params: Optional[BackendParams] = None): # 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 + driver=self, deck=params.deck, address=pipette_address, num_channels=num_channels ) if door_address is not None: self.door = NimbusDoor(driver=self, address=door_address) - elif backend_params.require_door_lock: + elif params.require_door_lock: raise RuntimeError("DoorLock is required but not available on this instrument.") # Initialize subsystems @@ -168,6 +203,8 @@ async def stop(self): await self.door._on_stop() await super().stop() self.door = None + self._resolved_interfaces = {} + self._nimbus_resolved = None async def _assert_required_methods( self, diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py index f475c913e4e..c03ee1901f8 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py @@ -26,11 +26,11 @@ 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 @@ -44,13 +44,22 @@ async def setup(self, backend_params: Optional[BackendParams] = None): runs InitializeSmartRoll, and wires the PIP capability frontend to the driver's PIP backend. """ - if not isinstance(backend_params, NimbusSetupParams): - backend_params = NimbusSetupParams() + if backend_params is None: + params = NimbusSetupParams(deck=self.deck) + elif isinstance(backend_params, NimbusSetupParams): + params = backend_params + if params.deck is None: + params = NimbusSetupParams(deck=self.deck, require_door_lock=params.require_door_lock) + else: + raise TypeError( + "Nimbus.setup expected NimbusSetupParams | None for backend_params, " + f"got {type(backend_params).__name__}" + ) try: - await self.driver.setup(backend_params=backend_params) + await self.driver.setup(backend_params=params) - self.pip = PIP(backend=self.driver.pip, deck=self.deck) + self.pip = PIP(backend=self.driver.pip) self._capabilities = [self.pip] await self.pip._on_setup() self._setup_finished = True diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py index dfa85e17ab2..7c24c29e5b3 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -3,12 +3,17 @@ 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 @@ -17,7 +22,6 @@ from .commands import ( Aspirate, - Dispense as DispenseCommand, DisableADC, DropTips, DropTipsRoll, @@ -31,6 +35,9 @@ _get_default_flow_rate, _get_tip_type_from_tip, ) +from .commands import ( + Dispense as DispenseCommand, +) if TYPE_CHECKING: from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck @@ -76,6 +83,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 +107,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 +131,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,7 +177,7 @@ 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, @@ -151,6 +199,12 @@ def pipette_address(self) -> Address: raise RuntimeError("Pipette address not set. Call setup() first.") return self.address + def _ensure_deck(self) -> "NimbusDeck": + """Return the deck, raising if not set.""" + if self.deck is None: + raise RuntimeError("Deck must be set for pipetting operations.") + return self.deck + async def _on_setup(self, backend_params: Optional[BackendParams] = None): """Initialize SmartRoll if not already initialized.""" del backend_params @@ -169,6 +223,7 @@ async def _on_stop(self): 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( @@ -403,6 +458,7 @@ async def pick_up_tips( """ if not ops: return + self._ensure_deck() params = ( backend_params if isinstance(backend_params, NimbusPIPPickUpTipsParams) @@ -489,6 +545,7 @@ async def drop_tips( """ if not ops: return + self._ensure_deck() params = ( backend_params if isinstance(backend_params, NimbusPIPDropTipsParams) @@ -598,7 +655,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). @@ -607,11 +666,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) @@ -651,7 +706,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 = [] @@ -670,38 +725,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 ) @@ -719,7 +807,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 = [ @@ -839,7 +928,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). @@ -851,11 +942,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) @@ -895,7 +982,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 = [] @@ -914,44 +1001,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 @@ -965,7 +1086,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 = [ diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py index 76f27ff9bca..c5266e0daf8 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py @@ -7,21 +7,16 @@ from pylabrobot.hamilton.liquid_handlers.nimbus.commands import GetChannelConfiguration_1 from pylabrobot.hamilton.liquid_handlers.nimbus.driver import ( NimbusDriver, + NimbusResolvedInterfaces, nimbus_interface_specs_for_root, ) -from pylabrobot.hamilton.tcp.error_tables import NIMBUS_ERROR_CODES from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.packets import Address -from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck - -# Stable key from NIMBUS_ERROR_CODES for merge-override tests (must exist in table). -_NIMBUS_OVERRIDE_KEY = (0x0001, 0x0001, 0x0101, 1, 0x0F01) -_NIMBUS_OTHER_KEY = (0x0001, 0x0001, 0x0101, 1, 0x0F02) def test_chatterbox_setup_and_command_roundtrip(): async def _run() -> None: - driver = NimbusChatterboxDriver(deck=NimbusDeck(), num_channels=8) + driver = NimbusChatterboxDriver(num_channels=8) await driver.setup() assert driver.nimbus_core_address == Address(1, 1, 48896) @@ -29,9 +24,6 @@ async def _run() -> None: response = await driver.send_command( GetChannelConfiguration_1(driver.nimbus_core_address), - ensure_connection=False, - return_raw=False, - raise_on_error=False, read_timeout=0.1, ) assert response == {"channels": 8} @@ -43,7 +35,7 @@ async def _run() -> None: def test_assert_required_methods_missing_raises(): async def _run() -> None: - driver = NimbusDriver(deck=NimbusDeck(), host="127.0.0.1") + driver = NimbusDriver(host="127.0.0.1") class _Method: def __init__(self, method_id: int): @@ -68,24 +60,9 @@ async def methods_for_interface(self, address, interface_id): # noqa: ARG002 asyncio.run(_run()) -def test_nimbus_driver_error_codes_user_values_override_table(): - """NimbusDriver merges NIMBUS_ERROR_CODES with caller dict; same-key entries use the caller. - - Covers the __init__ merge policy used for instrument-specific error enrichment, not - exercised elsewhere (tcp_tests do not assert Nimbus defaults). - """ - driver = NimbusDriver( - deck=NimbusDeck(), - host="127.0.0.1", - error_codes={_NIMBUS_OVERRIDE_KEY: "custom text for tests"}, - ) - assert driver._error_codes[_NIMBUS_OVERRIDE_KEY] == "custom text for tests" - assert driver._error_codes[_NIMBUS_OTHER_KEY] == NIMBUS_ERROR_CODES[_NIMBUS_OTHER_KEY] - - def test_nimbus_core_address_raises_before_setup(): """Property requires setup() to have discovered and stored NimbusCore.""" - driver = NimbusDriver(deck=NimbusDeck(), host="127.0.0.1") + driver = NimbusDriver(host="127.0.0.1") with pytest.raises(RuntimeError, match="Nimbus root address not discovered"): _ = driver.nimbus_core_address @@ -99,6 +76,17 @@ def test_nimbus_interface_specs_for_root_paths(): assert s["door_lock"].required is False +def test_nimbus_resolved_interfaces_from_map_optional_door(): + core = Address(1, 1, 100) + pip = Address(1, 1, 200) + r = NimbusResolvedInterfaces.from_resolution_map( + {"nimbus_core": core, "pipette": pip, "door_lock": None} + ) + assert r.nimbus_core == core + assert r.pipette == pip + assert r.door_lock is None + + def test_resolve_interface_path_specs_required_missing_raises(): async def _run() -> None: from unittest.mock import AsyncMock @@ -121,7 +109,7 @@ def test_assert_required_methods_succeeds_when_all_present(): """Complements test_assert_required_methods_missing_raises: no false positive when the set is satisfied.""" async def _run() -> None: - driver = NimbusDriver(deck=NimbusDeck(), host="127.0.0.1") + driver = NimbusDriver(host="127.0.0.1") class _Method: def __init__(self, method_id: int): 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..b25666b9ae7 --- /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 +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={"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={"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={"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={"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={"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={"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/tcp/client.py b/pylabrobot/hamilton/tcp/client.py index e53b7e2953f..e881032c86d 100644 --- a/pylabrobot/hamilton/tcp/client.py +++ b/pylabrobot/hamilton/tcp/client.py @@ -10,7 +10,7 @@ import asyncio import logging from dataclasses import dataclass -from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union, cast +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 @@ -26,7 +26,6 @@ HamiltonIntrospection, MethodDescriptor, ObjectRegistry, - flatten_firmware_tree, ) from pylabrobot.hamilton.tcp.messages import ( CommandResponse, @@ -80,7 +79,7 @@ class _HcResultDescriptionHelper: """Resolves ``HcResultEntry`` to display strings and optional method context. Thin adapter over :attr:`HamiltonTCPClient.introspection` for Interface-0 metadata lookups - used after static ``error_codes`` and :data:`HC_RESULT_PROTOCOL` tables. + used after :attr:`HamiltonTCPClient._ERROR_CODES` and :data:`HC_RESULT_PROTOCOL` tables. """ def __init__(self, client: HamiltonTCPClient) -> None: @@ -112,9 +111,9 @@ async def describe_entry(self, entry: HcResultEntry) -> Tuple[Optional[str], str entry.action_id, entry.result, ) - desc = self._client._error_codes.get(key_iface) + desc = self._client._ERROR_CODES.get(key_iface) if desc is None and key_action != key_iface: - desc = self._client._error_codes.get(key_action) + desc = self._client._ERROR_CODES.get(key_action) if desc is None: desc = HC_RESULT_PROTOCOL.get(entry.result) if desc is None: @@ -158,6 +157,8 @@ async def _lookup_method_descriptor( 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, @@ -167,7 +168,6 @@ def __init__( auto_reconnect: bool = True, max_reconnect_attempts: int = 3, connection_timeout: int = 600, - error_codes: Optional[Dict[Tuple[int, int, int, int, int], str]] = None, ): super().__init__() @@ -188,12 +188,10 @@ def __init__( self._client_id: Optional[int] = None self.client_address: Optional[Address] = None self._sequence_numbers: Dict[Address, int] = {} - self._discovered_objects: Dict[str, list[Address]] = {} self._instrument_addresses: Dict[str, Address] = {} self._registry = ObjectRegistry() self._global_object_addresses: list[Address] = [] self._event_handlers: list[Callable[[CommandResponse], None]] = [] - self._error_codes: Dict[Tuple[int, int, int, int, int], str] = error_codes or {} self._introspection_impl: Optional[HamiltonIntrospection] = None self._hc_result_text = _HcResultDescriptionHelper(self) @@ -208,11 +206,9 @@ def global_object_addresses(self) -> Sequence[Address]: return tuple(self._global_object_addresses) def get_root_object_addresses(self) -> list[Address]: - """Roots from the registry, or from legacy ``_discovered_objects``.""" - roots = self._registry.get_root_addresses() - if roots: - return list(roots) - return list(self._discovered_objects.get("root", [])) + """Root address from the registry as a single-element list (protocol compatibility shim).""" + addr = self._registry.get_root_address() + return [addr] if addr is not None else [] @property def introspection(self) -> HamiltonIntrospection: @@ -369,9 +365,9 @@ async def setup(self, backend_params: Optional[BackendParams] = None): await self._discover_root() await self._discover_globals() - root_addresses = self._registry.get_root_addresses() - if root_addresses: - root_info = await self.introspection.get_object(root_addresses[0]) + 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) @@ -451,8 +447,11 @@ async def _discover_root(self): assert isinstance(response, RegistrationResponse) root_objects = self._parse_registration_response(response) - self._discovered_objects["root"] = root_objects - self._registry.set_root_addresses(root_objects) + 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...") @@ -519,9 +518,63 @@ def _allocate_sequence_number(self, dest_address: Address) -> int: 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: + """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 = ( @@ -709,8 +762,8 @@ async def send_command( raise last_error async def resolve_path(self, path: str) -> Address: - """Resolve strict dot-path target to Address.""" - return await self._registry.resolve(path, self) + """Resolve dot-path to Address (delegates to introspection).""" + return await self.introspection.resolve_path(path) async def resolve_target( self, @@ -723,19 +776,6 @@ async def resolve_target( resolved = aliases.get(target, target) if aliases is not None else target return await self.resolve_path(resolved) - async def get_firmware_tree(self, refresh: bool = False): - """Return cached firmware tree, or build it through introspection.""" - return await self.introspection.get_firmware_tree(refresh=refresh) - - async def get_firmware_tree_flat(self, refresh: bool = False): - """Firmware object tree as a flat list of ``(path, address, object_info)`` per node. - - Same preorder as :func:`~pylabrobot.hamilton.tcp.introspection.flatten_firmware_tree`; - convenient for dot-path indexing without walking :class:`FirmwareTree` manually. - """ - tree = await self.get_firmware_tree(refresh=refresh) - return flatten_firmware_tree(tree) - async def stop(self): try: await self.io.stop() diff --git a/pylabrobot/hamilton/tcp/commands.py b/pylabrobot/hamilton/tcp/commands.py index b4dff84cc8d..73bad223bfc 100644 --- a/pylabrobot/hamilton/tcp/commands.py +++ b/pylabrobot/hamilton/tcp/commands.py @@ -44,7 +44,7 @@ def __init__(self, dest: Address, value: int): self.value = value def build_parameters(self) -> HoiParams: - return HoiParams().add(self.value, I32) + return HoiParams().i32(self.value) @classmethod def parse_response_parameters(cls, data: bytes) -> dict: diff --git a/pylabrobot/hamilton/tcp/error_tables.py b/pylabrobot/hamilton/tcp/error_tables.py index 7a4ec26ee68..b2f2a58e935 100644 --- a/pylabrobot/hamilton/tcp/error_tables.py +++ b/pylabrobot/hamilton/tcp/error_tables.py @@ -13,8 +13,8 @@ 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`` : empty placeholder. Prep-specific codes surface as hex - until a Prep reference table is available and the generator is re-run. +- ``PREP_ERROR_CODES`` : Prep-specific codes (pipettor and MPH). Module-specific + range starting at 0x0F01. """ from __future__ import annotations @@ -1854,5 +1854,548 @@ ): "Cannot perform the requested operation because LLD detection is not enabled.", } -# Placeholder: awaiting a Prep-module decompile with AddErrorData calls. -PREP_ERROR_CODES: Dict[Tuple[int, int, int, int, int], str] = {} +# 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/introspection.py b/pylabrobot/hamilton/tcp/introspection.py index 4d6eae933a8..05fa410d0c9 100644 --- a/pylabrobot/hamilton/tcp/introspection.py +++ b/pylabrobot/hamilton/tcp/introspection.py @@ -51,7 +51,6 @@ from pylabrobot.hamilton.tcp.packets import Address from pylabrobot.hamilton.tcp.protocol import HamiltonProtocol from pylabrobot.hamilton.tcp.wire_types import ( - U8, U16, U32, HamiltonDataType, @@ -79,24 +78,22 @@ class HamiltonTCPIntrospectionBackend(Protocol): """ @property - def registry(self) -> Any: ... - - def get_root_object_addresses(self) -> list[Address]: ... + def registry(self) -> ObjectRegistry: ... @property def global_object_addresses(self) -> Sequence[Address]: ... 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, + self, command: TCPCommand, *, read_timeout: Optional[float] = None ) -> Any: ... - async def resolve_path(self, path: str) -> Address: ... + async def send_query( + self, command: TCPCommand, *, read_timeout: Optional[float] = None + ) -> Optional[tuple]: ... + + async def send_discovery_command( + self, command: TCPCommand, *, read_timeout: Optional[float] = None + ) -> Any: ... async def _subobject_address_and_info( @@ -150,111 +147,153 @@ def resolve_type_id(type_id: int) -> str: # ============================================================================ # INTROSPECTION TYPE MAPPING (2D table from HoiObject.mHoiParamTypes) # ============================================================================ -# Introspection type IDs are separate from HamiltonDataType wire encoding types. +# Introspection type IDs are a unique interface-0 typing system, distinct from +# the standard HamiltonDataType wire-encoding type IDs. # Rows = firmware scalar or array kinds; columns = In, Out, InOut, RetVal # (HoiParameterType.Direction). Source: vendor protocol reference mHoiParamTypes[31,4]. @dataclass(frozen=True) class _HoiTypeRow: - """One row in vendor mHoiParamTypes[31,4] with readable display metadata.""" + """One row in vendor mHoiParamTypes[31,4] with readable display metadata. + + ``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. + + ``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). + """ - dotnet_name: str display_name: str - ids: tuple[int, int, int, int] # [In, Out, InOut, RetVal] + ids: tuple[int, int, int, int] # Interface-0 column order: [In, Out, InOut, RetVal] + is_complex: bool = False + is_struct_kind: bool = False + is_enum_kind: bool = False _HOI_TYPE_ROWS: tuple[_HoiTypeRow, ...] = ( - _HoiTypeRow("i8", "i8", (1, 17, 9, 25)), - _HoiTypeRow("i16", "i16", (3, 19, 11, 27)), - _HoiTypeRow("i32", "i32", (5, 21, 13, 29)), - _HoiTypeRow("u8", "u8", (2, 18, 10, 26)), - _HoiTypeRow("u16", "u16", (4, 20, 12, 28)), - _HoiTypeRow("u32", "u32", (6, 22, 14, 30)), - _HoiTypeRow("str", "str", (7, 23, 15, 31)), - _HoiTypeRow("bool", "bool", (33, 35, 34, 36)), - _HoiTypeRow("i8[]", "List[i8]", (37, 39, 38, 40)), - _HoiTypeRow("i16[]", "List[i16]", (41, 43, 42, 44)), - _HoiTypeRow("i32[]", "List[i32]", (49, 51, 50, 52)), - _HoiTypeRow("u8[]", "bytes", (8, 24, 16, 32)), - _HoiTypeRow("u16[]", "List[u16]", (45, 47, 46, 48)), - _HoiTypeRow("u32[]", "List[u32]", (53, 55, 54, 56)), - _HoiTypeRow("bool[]", "List[bool]", (66, 68, 67, 69)), - _HoiTypeRow("HcResult", "HcResult", (70, 72, 71, 73)), - _HoiTypeRow("struct", "struct", (57, 59, 58, 60)), - _HoiTypeRow("struct[]", "List[struct]", (61, 63, 62, 64)), - _HoiTypeRow("str[]", "List[str]", (74, 76, 75, 77)), - _HoiTypeRow("enum", "enum", (78, 80, 79, 81)), - _HoiTypeRow("enum[]", "List[enum]", (82, 84, 83, 85)), - _HoiTypeRow("i64", "i64", (86, 88, 87, 89)), - _HoiTypeRow("u64", "u64", (90, 92, 91, 93)), - _HoiTypeRow("f32", "f32", (94, 96, 95, 97)), - _HoiTypeRow("f64", "f64", (98, 100, 99, 101)), - _HoiTypeRow("i64[]", "List[i64]", (102, 104, 103, 105)), - _HoiTypeRow("u64[]", "List[u64]", (106, 108, 107, 109)), - _HoiTypeRow("f32[]", "List[f32]", (110, 112, 111, 113)), - _HoiTypeRow("f64[]", "List[f64]", (114, 116, 115, 117)), - _HoiTypeRow("HoiResult", "HoiResult", (118, 120, 119, 121)), - _HoiTypeRow("padding", "padding", (0, 0, 0, 0)), -) - -_COMPLEX_METHOD_ROW_NAMES = frozenset( - { - "HcResult", - "struct", - "struct[]", - "str[]", - "enum", - "enum[]", - "HoiResult", - } + _HoiTypeRow("i8", (1, 17, 9, 25)), + _HoiTypeRow("i16", (3, 19, 11, 27)), + _HoiTypeRow("i32", (5, 21, 13, 29)), + _HoiTypeRow("u8", (2, 18, 10, 26)), + _HoiTypeRow("u16", (4, 20, 12, 28)), + _HoiTypeRow("u32", (6, 22, 14, 30)), + _HoiTypeRow("str", (7, 23, 15, 31)), + _HoiTypeRow("bool", (33, 35, 34, 36)), + _HoiTypeRow("List[i8]", (37, 39, 38, 40)), + _HoiTypeRow("List[i16]", (41, 43, 42, 44)), + _HoiTypeRow("List[i32]", (49, 51, 50, 52)), + _HoiTypeRow("bytes", (8, 24, 16, 32)), + _HoiTypeRow("List[u16]", (45, 47, 46, 48)), + _HoiTypeRow("List[u32]", (53, 55, 54, 56)), + _HoiTypeRow("List[bool]", (66, 68, 67, 69)), + _HoiTypeRow("HcResult", (70, 72, 71, 73), is_complex=True), + _HoiTypeRow("struct", (57, 59, 58, 60), is_complex=True, is_struct_kind=True), + _HoiTypeRow("List[struct]", (61, 63, 62, 64), is_complex=True, is_struct_kind=True), + _HoiTypeRow("List[str]", (74, 76, 75, 77), is_complex=True), + _HoiTypeRow("enum", (78, 80, 79, 81), is_complex=True, is_enum_kind=True), + _HoiTypeRow("List[enum]", (82, 84, 83, 85), is_complex=True, is_enum_kind=True), + _HoiTypeRow("i64", (86, 88, 87, 89)), + _HoiTypeRow("u64", (90, 92, 91, 93)), + _HoiTypeRow("f32", (94, 96, 95, 97)), + _HoiTypeRow("f64", (98, 100, 99, 101)), + _HoiTypeRow("List[i64]", (102, 104, 103, 105)), + _HoiTypeRow("List[u64]", (106, 108, 107, 109)), + _HoiTypeRow("List[f32]", (110, 112, 111, 113)), + _HoiTypeRow("List[f64]", (114, 116, 115, 117)), + _HoiTypeRow("HoiResult", (118, 120, 119, 121), is_complex=True), + _HoiTypeRow("padding", (0, 0, 0, 0)), ) _HOI_PARAM_DIRECTION: tuple[str, ...] = ("In", "Out", "InOut", "RetVal") -def _build_introspection_maps() -> tuple[dict[int, str], set[int], set[int], set[int], set[int]]: +@dataclass(frozen=True) +class _IntrospectionTypeMaps: + """Derived classification maps built once from :data:`_HOI_TYPE_ROWS` at import time.""" + + type_names: dict[int, str] + argument_type_ids: frozenset[int] + return_element_type_ids: frozenset[int] + return_value_type_ids: frozenset[int] + complex_method_type_ids: frozenset[int] + complex_struct_type_ids: frozenset[int] + struct_ref_type_ids: frozenset[int] + enum_ref_type_ids: frozenset[int] + all_complex_type_ids: frozenset[int] + + +def _build_introspection_maps() -> _IntrospectionTypeMaps: names: dict[int, str] = {0: "void"} arg_ids: set[int] = set() ret_el_ids: set[int] = set() ret_val_ids: set[int] = set() complex_method_ids: set[int] = set() + struct_ref_ids: set[int] = set() + enum_ref_ids: set[int] = set() for row in _HOI_TYPE_ROWS: for ci, tid in enumerate(row.ids): if tid == 0: continue d = _HOI_PARAM_DIRECTION[ci] - disp = row.display_name - names[tid] = f"{disp} [{d}]" + names[tid] = f"{row.display_name} [{d}]" if ci in (0, 2): arg_ids.add(tid) elif ci == 1: ret_el_ids.add(tid) elif ci == 3: ret_val_ids.add(tid) - if row.dotnet_name in _COMPLEX_METHOD_ROW_NAMES: + if row.is_complex: complex_method_ids.add(tid) - - return names, arg_ids, ret_el_ids, ret_val_ids, complex_method_ids - - -( - _INTROSPECTION_TYPE_NAMES, - _ARGUMENT_TYPE_IDS, - _RETURN_ELEMENT_TYPE_IDS, - _RETURN_VALUE_TYPE_IDS, - _COMPLEX_METHOD_TYPE_IDS, -) = _build_introspection_maps() - -# Empirical device behavior: type_id=113 appears as Argument on some firmware, -# despite the static grid column implying RetVal. -_INTROSPECTION_TYPE_NAMES[113] = "List[f32] [In] (empirical)" -_ARGUMENT_TYPE_IDS.add(113) - -_COMPLEX_STRUCT_TYPE_IDS = {30, 31, 32, 35} # STRUCTURE=30, STRUCT_ARRAY=31, ENUM=32, ENUM_ARRAY=35 -_STRUCT_REF_TYPE_IDS = frozenset({30, 31, 57, 60, 61, 63, 64}) -_ENUM_REF_TYPE_IDS = frozenset({32, 35, 78, 81, 82, 85}) -_ALL_COMPLEX_TYPE_IDS = frozenset(_COMPLEX_METHOD_TYPE_IDS | _COMPLEX_STRUCT_TYPE_IDS) + if row.is_struct_kind: + struct_ref_ids.add(tid) + if row.is_enum_kind: + enum_ref_ids.add(tid) + + # GetStructs sentinels (Parameter.ParameterTypes values) — live in the GetStructs wire format + # only, not in _HOI_TYPE_ROWS. + complex_struct = frozenset( + { + HamiltonDataType.STRUCTURE, + HamiltonDataType.STRUCTURE_ARRAY, + HamiltonDataType.ENUM, + HamiltonDataType.ENUM_ARRAY, + } + ) + struct_ref_ids |= {HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY} + enum_ref_ids |= {HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY} + + # Empirical: type_id=113 (List[f32] column 3 = RetVal) appears as Argument on some firmware. + # TODO: Re-validate against hardware captures and remove if no longer observed. + names[113] = "List[f32] [In] (empirical)" + arg_ids.add(113) + + return _IntrospectionTypeMaps( + type_names=names, + argument_type_ids=frozenset(arg_ids), + return_element_type_ids=frozenset(ret_el_ids), + return_value_type_ids=frozenset(ret_val_ids), + complex_method_type_ids=frozenset(complex_method_ids), + complex_struct_type_ids=complex_struct, + struct_ref_type_ids=frozenset(struct_ref_ids), + enum_ref_type_ids=frozenset(enum_ref_ids), + all_complex_type_ids=frozenset(complex_method_ids | complex_struct), + ) + + +_MAPS = _build_introspection_maps() +_INTROSPECTION_TYPE_NAMES = _MAPS.type_names +_ARGUMENT_TYPE_IDS = _MAPS.argument_type_ids +_RETURN_ELEMENT_TYPE_IDS = _MAPS.return_element_type_ids +_RETURN_VALUE_TYPE_IDS = _MAPS.return_value_type_ids +_COMPLEX_METHOD_TYPE_IDS = _MAPS.complex_method_type_ids +_COMPLEX_STRUCT_TYPE_IDS = _MAPS.complex_struct_type_ids +_STRUCT_REF_TYPE_IDS = _MAPS.struct_ref_type_ids +_ENUM_REF_TYPE_IDS = _MAPS.enum_ref_type_ids +_ALL_COMPLEX_TYPE_IDS = _MAPS.all_complex_type_ids def get_introspection_type_category(type_id: int) -> str: @@ -306,73 +345,33 @@ class ObjectInfo: class ObjectRegistry: - """Object graph cache keyed by both path and address.""" + """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_addresses: List[Address] = [] + self._root_address: Optional[Address] = None - def set_root_addresses(self, addresses: List[Address]) -> None: - self._root_addresses = list(addresses) + def set_root_address(self, address: Address) -> None: + self._root_address = address - def get_root_addresses(self) -> List[Address]: - return list(self._root_addresses) + 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) - async def resolve(self, path: str, transport: Any) -> Address: - """Resolve dot-path to address via lazy introspection.""" - if path in self._objects: - return cast(Address, self._objects[path].address) - - parts = [p for p in path.split(".") if p] - if not parts: - raise KeyError(f"Invalid path: '{path}'") - - parent_path = ".".join(parts[:-1]) - child_name = parts[-1] - - introspection_obj = getattr(transport, "introspection", None) - if introspection_obj is None: - raise TypeError("ObjectRegistry.resolve requires transport.introspection") - introspection = cast("HamiltonIntrospection", introspection_obj) - if not parent_path: - if not self._root_addresses: - raise KeyError("No root addresses; run discovery first") - parent_addr = self._root_addresses[0] - parent_info = await introspection.get_object(parent_addr) - parent_info.children = {} - self.register(parent_info.name, parent_info) - if parent_info.name == child_name: - return parent_info.address - raise KeyError(f"Root object is '{parent_info.name}', not '{child_name}'") - - parent_addr = await self.resolve(parent_path, transport) - parent_info = self._objects[parent_path] - supported = await introspection.get_supported_interface0_method_ids(parent_addr) - if GET_SUBOBJECT_ADDRESS not in supported: - raise KeyError( - f"Object at path '{parent_path}' does not support GetSubobjectAddress " - f"(interface 0, method 3); cannot resolve child '{child_name}'" - ) - - for i in range(parent_info.subobject_count): - sub_addr, sub_info = await _subobject_address_and_info(introspection, parent_addr, i) - sub_info.children = {} - child_path = f"{parent_path}.{sub_info.name}" - parent_info.children[sub_info.name] = sub_info - self.register(child_path, sub_info) - if sub_info.name == child_name: - return sub_info.address - - raise KeyError(f"Child '{child_name}' not found under '{parent_path}'") - @dataclass class FirmwareTreeNode: @@ -413,18 +412,16 @@ def __str__(self) -> str: @dataclass class FirmwareTree: - """Structured firmware tree produced by introspection traversal.""" + """Structured firmware tree produced by introspection traversal. - roots: List[FirmwareTreeNode] = field(default_factory=list) + Both Prep and Nimbus expose exactly one root object; the single-root + invariant is enforced at discovery time in the TCP client. + """ + + root: FirmwareTreeNode def format(self) -> str: - if not self.roots: - return "" - lines: List[str] = [] - for idx, root in enumerate(self.roots): - root_is_last = idx == len(self.roots) - 1 - lines.extend(root.format_lines(prefix="", is_last=root_is_last, is_root=True)) - return "\n".join(lines) + return "\n".join(self.root.format_lines(prefix="", is_last=True, is_root=True)) def __str__(self) -> str: return self.format() @@ -433,7 +430,7 @@ def __str__(self) -> str: def flatten_firmware_tree(tree: FirmwareTree) -> List[Tuple[str, Address, ObjectInfo]]: """Preorder flattening of :class:`FirmwareTree` for path-keyed lookups. - Returns ``(dot_path, address, object_info)`` for each node (roots first, DFS). + Returns ``(dot_path, address, object_info)`` for each node (root first, DFS). """ out: List[Tuple[str, Address, ObjectInfo]] = [] @@ -442,43 +439,41 @@ def walk(node: FirmwareTreeNode) -> None: for child in node.children: walk(child) - for root in tree.roots: - walk(root) + walk(tree.root) return out @dataclass class ParameterType: - """A resolved type reference used for both method parameters and struct fields. + """A resolved type reference from either GetMethod parameterTypes or GetStructs field types. Simple types (i8, f32, etc.) have only type_id set. - Complex references additionally carry source_id (the interface defining the - struct/enum) and ref_id (struct_id or enum_id within that interface). - These are encoded as 3-byte triples [type_id, source_id, ref_id] in two - distinct contexts that each use a different sentinel byte: + Complex 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. - - GetMethod parameterTypes: sentinels in _COMPLEX_METHOD_TYPE_IDS (57, 61 …) - - GetStructs structureElementTypes: sentinel 0xE8 (_COMPLEX_STRUCT_TYPE_IDS) + Wire widths vary by context and source_id (see _parse_method_param_types and + _parse_struct_field_types for the exact encoding from HoiObject.cs). """ type_id: int source_id: Optional[int] = None ref_id: Optional[int] = None - _byte_width: int = 1 # Bytes consumed in struct element_types (1=simple, 3=ref, 7+=inline) + _byte_width: int = 1 # bytes consumed from the wire blob (1=simple, 3=ref, 7=node-global struct field, variable=node-global method param) @property def is_complex(self) -> bool: - """True if this is a 3-byte complex reference (method param or struct field).""" + """True if this entry has a source_id/ref_id pair (struct or enum reference).""" return self.type_id in _ALL_COMPLEX_TYPE_IDS @property def is_struct_ref(self) -> bool: - """True if this is a struct reference (type 30 in struct context, 57/61 in method context).""" + """True if this references a struct definition (all directions: In/Out/InOut/RetVal).""" return self.type_id in _STRUCT_REF_TYPE_IDS @property def is_enum_ref(self) -> bool: - """True if this is an enum reference (type 32 in struct context, 78/81/82/85 in method).""" + """True if this references an enum definition (all directions: In/Out/InOut/RetVal).""" return self.type_id in _ENUM_REF_TYPE_IDS def resolve_name( @@ -506,61 +501,87 @@ def resolve_name( return f"{base}(iface={self.source_id}, id={self.ref_id})" -def _parse_type_seq( +def _parse_method_param_types( data: bytes | list[int], - complex_ids: set[int], ) -> List[ParameterType]: - """Shared variable-width parser for Hamilton type-ID byte sequences. + """Parse GetMethod parameterTypes byte stream. - Both GetMethod parameterTypes and GetStructs structureElementTypes encode types - as a byte stream where simple types occupy 1 byte and complex references have - variable width. + Source: HoiObject.HandleStruct in HoiObject.cs. - For struct element types (complex_ids = _COMPLEX_STRUCT_TYPE_IDS), complex - sentinels (30=STRUCTURE, 31=STRUCT_ARRAY, 32=ENUM, 35=ENUM_ARRAY) have two - encoding formats determined by the second byte: + 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[ParameterType] = [] + i = 0 + while i < len(ints): + tid = ints[i] + 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( + ParameterType(tid, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=end - i) + ) + i = end + else: + result.append(ParameterType(tid, source_id=source_id, ref_id=ref_id, _byte_width=3)) + i += 3 + else: + result.append(ParameterType(tid)) + i += 1 + return result - - **Reference** (second byte ≤ 3): 3 bytes ``[sentinel, source_id, ref_id]`` - where source 1=global, 2=local, 3=network. - - **Inline definition** (second byte = 4): variable width, terminated by - ``0xEE`` (238). Typically 7 bytes: ``[sentinel, 4, base_type, 0, 1, 0, 0xEE]``. - The ``base_type`` specifies the underlying wire type (1=I8, 2=I16, 3=I32). - For method parameter types, only the 3-byte reference format is used. +def _parse_struct_field_types( + data: bytes | list[int], +) -> List[ParameterType]: + """Parse GetStructs structureElementTypes byte stream. - Args: - data: Raw bytes or list of ints to parse. - complex_ids: Set of type_id values that introduce a multi-byte entry. + Source: HoiObject.GetStructs in HoiObject.cs. - Returns: - List of ParameterType, one per logical type entry. + 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. """ - _INLINE_MARKER = 4 - _INLINE_TERMINATOR = 0xEE # 238 + _NODE_GLOBAL = 4 + _NODE_GLOBAL_WIDTH = 7 ints = list(data) if isinstance(data, bytes) else data result: List[ParameterType] = [] i = 0 while i < len(ints): tid = ints[i] - if tid in complex_ids and i + 2 < len(ints): - second = ints[i + 1] - if second == _INLINE_MARKER: - # Inline type definition: scan forward to 0xEE terminator - end = i + 2 - while end < len(ints) and ints[end] != _INLINE_TERMINATOR: - end += 1 - end += 1 # consume the 0xEE byte itself - # Store as ParameterType with the base wire type from byte [i+2] - width = end - i - base_type = ints[i + 2] if i + 2 < len(ints) else 0 + 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( - ParameterType(tid, source_id=_INLINE_MARKER, ref_id=base_type, _byte_width=width) + ParameterType(tid, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=_NODE_GLOBAL_WIDTH) ) - i = end + i += _NODE_GLOBAL_WIDTH else: - # Standard 3-byte reference: [sentinel, source_id, ref_id] - result.append(ParameterType(tid, source_id=second, ref_id=ints[i + 2], _byte_width=3)) + result.append(ParameterType(tid, source_id=source_id, ref_id=ref_id, _byte_width=3)) i += 3 else: result.append(ParameterType(tid)) @@ -569,7 +590,7 @@ def _parse_type_seq( def _parse_type_ids(raw: str | bytes | None) -> List[ParameterType]: - """Parse GetMethod parameterTypes blob. Thin wrapper around _parse_type_seq. + """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. @@ -577,7 +598,7 @@ def _parse_type_ids(raw: str | bytes | None) -> List[ParameterType]: if raw is None: return [] data: list[int] = list(raw) if isinstance(raw, bytes) else [ord(c) for c in raw] - return _parse_type_seq(data, _COMPLEX_METHOD_TYPE_IDS) + return _parse_method_param_types(data) @dataclass @@ -727,10 +748,7 @@ class TypeRegistry: 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 (same-interface references) appears in nested struct field type bytes; - indexing for method-level params and whether to use GlobalTypePool.resolve_struct_local - vs. this registry should be validated on hardware — do not assume the same 1-based rule - as source_id=2 locals. + 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. @@ -933,13 +951,13 @@ def get_struct_string(self, registry: Optional["TypeRegistry"] = None) -> str: struct_id=3, name="DateTime", fields={ - "year": ParameterType(type_id=5), # U16 - "month": ParameterType(type_id=4), # U8 (padded) - "day": ParameterType(type_id=4), - "hour": ParameterType(type_id=4), - "minute": ParameterType(type_id=4), - "second": ParameterType(type_id=4), - "millisecond": ParameterType(type_id=5), # U16 + "year": ParameterType(type_id=HamiltonDataType.U16), + "month": ParameterType(type_id=HamiltonDataType.U8), + "day": ParameterType(type_id=HamiltonDataType.U8), + "hour": ParameterType(type_id=HamiltonDataType.U8), + "minute": ParameterType(type_id=HamiltonDataType.U8), + "second": ParameterType(type_id=HamiltonDataType.U8), + "millisecond": ParameterType(type_id=HamiltonDataType.U16), }, interface_id=3, ) @@ -1069,7 +1087,7 @@ def __init__(self, object_address: Address, method_index: int): def build_parameters(self) -> HoiParams: """Build parameters for get_method command.""" - return HoiParams().add(self.method_index, U32) + return HoiParams().u32(self.method_index) @classmethod def parse_response_parameters(cls, data: bytes) -> dict: @@ -1082,7 +1100,8 @@ def parse_response_parameters(cls, data: bytes) -> dict: _, name = parser.parse_next() # The remaining fragments are STRING types containing type IDs as bytes. - # Complex types (struct/enum refs) are 3-byte triples [type_id, source_id, ref_id]. + # 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 ParameterType count). parameter_labels_str = None @@ -1155,7 +1174,7 @@ def __init__(self, object_address: Address, subobject_index: int): def build_parameters(self) -> HoiParams: """Build parameters for get_subobject_address command.""" - return HoiParams().add(self.subobject_index, U16) + return HoiParams().u16(self.subobject_index) @dataclass(frozen=True) class Response: @@ -1205,7 +1224,7 @@ def __init__(self, object_address: Address, target_interface_id: int): def build_parameters(self) -> HoiParams: """Build parameters for get_enums command.""" - return HoiParams().add(self.target_interface_id, U8) + return HoiParams().u8(self.target_interface_id) @dataclass(frozen=True) class Response: @@ -1229,7 +1248,7 @@ def __init__(self, object_address: Address, target_interface_id: int): def build_parameters(self) -> HoiParams: """Build parameters for get_structs command.""" - return HoiParams().add(self.target_interface_id, U8) + return HoiParams().u8(self.target_interface_id) @dataclass(frozen=True) class Response: @@ -1288,9 +1307,9 @@ def __init__(self, backend: HamiltonTCPIntrospectionBackend): self.backend = backend # Session caches (invalidated when the client drops the introspection facet, e.g. reconnect). self._method_table_by_address: Dict[Address, List[MethodInfo]] = {} - self._structs_by_addr_iface: Dict[Tuple[Address, int], Dict[int, StructInfo]] = {} - self._enums_by_addr_iface: Dict[Tuple[Address, int], Dict[int, EnumInfo]] = {} - self._iface_types_loaded: Set[Tuple[Address, int]] = set() + 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]] = {} @@ -1300,9 +1319,7 @@ def __init__(self, backend: HamiltonTCPIntrospectionBackend): 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._structs_by_addr_iface.clear() - self._enums_by_addr_iface.clear() - self._iface_types_loaded.clear() + self._iface_types.clear() self._interfaces_by_address.clear() self._hc_result_text_by_addr_iface.clear() self._supported_i0_by_address.clear() @@ -1313,11 +1330,11 @@ 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*.""" - key = (addr, iface_id) - if key in self._structs_by_addr_iface: - registry.structs[iface_id] = dict(self._structs_by_addr_iface[key]) - if key in self._enums_by_addr_iface: - registry.enums[iface_id] = dict(self._enums_by_addr_iface[key]) + 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, @@ -1385,14 +1402,24 @@ async def _build_global_type_pool_impl(self, global_addresses: List[Address]) -> 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.structs.extend(structs) 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) - pool.enums.extend(enums) + if not first_nonzero_seen: + pool.enums.extend(enums) + first_nonzero_seen = True except _TRANSIENT_ERRORS: raise except Exception as e: @@ -1457,7 +1484,7 @@ async def ensure_structs_enums(self, address: Union[Address, str], interface_id: """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_loaded: + if key in self._iface_types: return supported = await self.get_supported_interface0_method_ids(addr) structs_map: Dict[int, StructInfo] = {} @@ -1468,14 +1495,12 @@ async def ensure_structs_enums(self, address: Union[Address, str], interface_id: if GET_ENUMS in supported: enums = await self.get_enums(addr, interface_id) enums_map = {e.enum_id: e for e in enums} - self._structs_by_addr_iface[key] = structs_map - self._enums_by_addr_iface[key] = enums_map + 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] = {} - self._iface_types_loaded.add(key) async def get_interface_name( self, address: Union[Address, str], interface_id: int @@ -1497,7 +1522,7 @@ async def get_hc_result_text( """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_loaded: + 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) @@ -1534,63 +1559,123 @@ async def signature_lines_for_interface( return lines async def _resolve_target_address(self, addr_or_path: Union[Address, str]) -> Address: - """Resolve Address or dot-path through the backend resolver consistently.""" + """Resolve Address or dot-path to Address.""" if isinstance(addr_or_path, str): - return cast(Address, await self.backend.resolve_path(addr_or_path)) + return await self.resolve_path(addr_or_path) return addr_or_path - async def _build_firmware_tree(self) -> FirmwareTree: - """Build a DFS firmware tree from discovered root addresses.""" - roots = self.backend.get_root_object_addresses() - tree = FirmwareTree() - if not roots: - return tree + 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. - visited: Set[Address] = set() + 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. + """ + if addr in visited: + return None + visited.add(addr) - async def walk(addr: Address, path: Optional[str] = None) -> Optional[FirmwareTreeNode]: - 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, - ) + 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.backend.registry.register(path, obj) - self.backend.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 - # Keep this guard even though Interface-0 method 3 (GetSubobjectAddress) - # appears ubiquitous in current PREP captures. If this remains stable - # across instruments/firmware, we can consider relaxing this check later. + 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.backend.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.backend.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.backend.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.backend.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: - return node + raise KeyError( + f"'{current_path}' does not support GetSubobjectAddress; cannot resolve child '{part}'" + ) + found: Optional[Address] = None 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_path = f"{path}.{sub_obj.name}" - child = await walk(sub_addr, child_path) - 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 + sub_addr, sub_obj = await _subobject_address_and_info(self, current_addr, i) + self.backend.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 - for addr in roots: - root_node = await walk(addr) - if root_node is not None: - tree.roots.append(root_node) - return tree + return current_addr + + async def _build_firmware_tree(self) -> FirmwareTree: + """Build a DFS firmware tree from the single registered root address.""" + root_addr = self.backend.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 FirmwareTree(root=node) async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTree: """Return cached firmware tree, or build and cache it when missing.""" @@ -1600,6 +1685,13 @@ async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTree: 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. @@ -1630,7 +1722,7 @@ async def get_object(self, address: Address) -> ObjectInfo: Object metadata """ command = GetObjectCommand(address) - response = await self.backend.send_command(command, ensure_connection=False) + response = await self.backend.send_discovery_command(command) if response is None: raise RuntimeError("GetObjectCommand returned None") @@ -1653,7 +1745,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, ensure_connection=False) + response = await self.backend.send_discovery_command(command) return MethodInfo( interface_id=response["interface_id"], @@ -1677,7 +1769,7 @@ 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, ensure_connection=False) + response = await self.backend.send_discovery_command(command) if response is None: raise RuntimeError("GetSubobjectAddressCommand returned None") @@ -1712,7 +1804,7 @@ async def get_interfaces( ) return [] command = GetInterfacesCommand(address) - response = await self.backend.send_command(command, ensure_connection=False) + response = await self.backend.send_discovery_command(command) if response is None: raise RuntimeError("GetInterfacesCommand returned None") @@ -1744,7 +1836,7 @@ 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, ensure_connection=False) + response = await self.backend.send_discovery_command(command) if response is None: raise RuntimeError("GetEnumsCommand returned None") @@ -1766,7 +1858,7 @@ async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo] offset += cnt return result - async def get_structs_raw(self, address: Address, interface_id: int) -> tuple[bytes, List[dict]]: + 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 @@ -1778,7 +1870,9 @@ async def get_structs_raw(self, address: Address, interface_id: int) -> tuple[by print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") """ command = GetStructsCommand(address, interface_id) - result = await self.backend.send_command(command, ensure_connection=False, return_raw=True) + result = await self.backend.send_query(command) + if result is None: + raise RuntimeError("GetStructs query returned no data.") (params,) = result return params, inspect_hoi_params(params) @@ -1802,7 +1896,7 @@ 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, ensure_connection=False) + response = await self.backend.send_discovery_command(command) if response is None: raise RuntimeError("GetStructsCommand returned None") @@ -1810,21 +1904,21 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI # 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; some entries are 3-byte triples + 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 (variable width: 1 byte for simple - # types, 3 bytes for 0xE8 complex references). field_counts gives the number - # of *logical* fields per struct, not the number of bytes to consume. + # 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_type_seq(type_bytes[byte_offset:], _COMPLEX_STRUCT_TYPE_IDS) + 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) @@ -1851,7 +1945,8 @@ async def build_type_registry( Args: address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). - global_pool: Optional GlobalTypePool for resolving source_id=1 refs. + 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). @@ -1859,6 +1954,8 @@ async def build_type_registry( TypeRegistry with all type information for this object """ 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) @@ -1899,7 +1996,8 @@ async def build_type_registry_with_children( 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. + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. If omitted, + :meth:`build_type_registry` attaches the session pool automatically. Returns: TypeRegistry that can resolve types from both parent and children. diff --git a/pylabrobot/hamilton/tcp/messages.py b/pylabrobot/hamilton/tcp/messages.py index 1de6eb5b93c..dea7d4c08e4 100644 --- a/pylabrobot/hamilton/tcp/messages.py +++ b/pylabrobot/hamilton/tcp/messages.py @@ -106,6 +106,71 @@ def add(self, value: Any, wire_type: Any) -> "HoiParams": wire_type = wire_type.__metadata__[0] return cast("HoiParams", wire_type.encode_into(value, self)) + # ------------------------------------------------------------------ + # Ergonomic shims — each delegates to add() with the matching alias + # from wire_types. No encoding logic lives here. + # ------------------------------------------------------------------ + + def i8(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import I8 + + return self.add(value, I8) + + def i16(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import I16 + + return self.add(value, I16) + + def i32(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import I32 + + return self.add(value, I32) + + def i64(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import I64 + + return self.add(value, I64) + + def u8(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import U8 + + return self.add(value, U8) + + def u16(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import U16 + + return self.add(value, U16) + + def u32(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import U32 + + return self.add(value, U32) + + def u64(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import U64 + + return self.add(value, U64) + + def f32(self, value: float) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import F32 + + return self.add(value, F32) + + def f64(self, value: float) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import F64 + + return self.add(value, F64) + + def bool_(self, value: bool) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import Bool + + return self.add(value, Bool) + + def str_(self, value: str) -> "HoiParams": + from pylabrobot.hamilton.tcp.wire_types import Str + + return self.add(value, Str) + # ------------------------------------------------------------------ # Generic dataclass serialiser (wire_types.py Annotated metadata) # ------------------------------------------------------------------ @@ -671,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 @@ -700,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() @@ -708,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/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py index 1c84b2076ee..7d94fb784de 100644 --- a/pylabrobot/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -312,7 +312,7 @@ async def _fake_resolve_path(path: str) -> Address: ) self.assertEqual(got, Address(1, 1, 999)) - def test_send_command_return_raw_returns_hoi_payload_tuple(self): + def test_send_query_returns_hoi_payload_tuple(self): class Cmd(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -340,7 +340,7 @@ async def _read_one_message(self, timeout=None): # type: ignore[override] client = FakeClient(host="127.0.0.1", port=0) client.client_address = Address(2, 1, 65535) - raw = asyncio.run(client.send_command(Cmd(Address(1, 1, 257)), return_raw=True)) + 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()) @@ -349,8 +349,7 @@ def test_get_firmware_tree_uses_cache_and_refresh(self): class Backend: def __init__(self): self._registry = ObjectRegistry() - self._registry.set_root_addresses([Address(1, 1, 100)]) - self._discovered_objects = {"root": [Address(1, 1, 100)]} + self._registry.set_root_address(Address(1, 1, 100)) self._fw_cache = None self._global_object_addresses = () @@ -362,9 +361,6 @@ def registry(self): def global_object_addresses(self): return self._global_object_addresses - def get_root_object_addresses(self): - return self._registry.get_root_addresses() or list(self._discovered_objects.get("root", [])) - def get_firmware_tree_cache(self): return self._fw_cache @@ -374,6 +370,12 @@ def set_firmware_tree_cache(self, tree): async def send_command(self, *a, **k): raise RuntimeError("unused in this test") + async def send_query(self, *a, **k): + raise RuntimeError("unused in this test") + + async def send_discovery_command(self, *a, **k): + raise RuntimeError("unused in this test") + async def resolve_path(self, path: str): raise RuntimeError("unused") @@ -410,9 +412,8 @@ async def fake_get_subobject_address(_addr: Address, idx: int) -> Address: self.assertIs(t1, t2) self.assertIsNot(t1, t3) - self.assertEqual(len(t1.roots), 1) - self.assertEqual(t1.roots[0].path, "Root") - self.assertEqual(len(t1.roots[0].children), 1) + self.assertEqual(t1.root.path, "Root") + self.assertEqual(len(t1.root.children), 1) self.assertIn("Root.Child", str(t1)) self.assertGreaterEqual(counts["obj"], 4) # built twice (initial + refresh) self.assertGreaterEqual(counts["sub"], 2) @@ -421,29 +422,29 @@ 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=1, address=a0) + 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) - child = FirmwareTreeNode(path="R.c", address=a1, object_info=o1, children=[]) - root = FirmwareTreeNode(path="R", address=a0, object_info=o0, children=[child]) - other_root = FirmwareTreeNode(path="S", address=a2, object_info=o2, children=[]) - tree = FirmwareTree(roots=[root, other_root]) + 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]) + tree = FirmwareTree(root=root) flat = flatten_firmware_tree(tree) - self.assertEqual([p for p, _, _ in flat], ["R", "R.c", "S"]) + 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=[]) - tree = FirmwareTree(roots=[root]) + tree = FirmwareTree(root=root) async def fake_get_firmware_tree(refresh: bool = False): del refresh return tree - client.get_firmware_tree = fake_get_firmware_tree # type: ignore[method-assign] - got = asyncio.run(client.get_firmware_tree_flat()) + 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) @@ -685,7 +686,10 @@ 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): - client = HamiltonTCPClient(host="127.0.0.1", port=0, error_codes=NIMBUS_ERROR_CODES) + 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] helper = _HcResultDescriptionHelper(client) @@ -726,13 +730,10 @@ def test_i16_array_roundtrip_decode_fragment(self): class TestIntrospectionTypeGridInvariants(unittest.TestCase): """Canonical integrity guard for HOI type table edits.""" - def test_grid_dimensions_match_protocol(self): + def test_grid_shape_and_padding_row_contract(self): rows = introspection_mod._HOI_TYPE_ROWS self.assertEqual(len(rows), 31) self.assertTrue(all(len(row.ids) == 4 for row in rows)) - - def test_only_padding_row_has_zero_ids(self): - rows = introspection_mod._HOI_TYPE_ROWS for idx, row in enumerate(rows): if idx == len(rows) - 1: self.assertEqual(row.ids, (0, 0, 0, 0)) @@ -755,7 +756,7 @@ def test_grid_categories_match_directions_with_empirical_exception(self): empirical_argument_ids = {113} for row in introspection_mod._HOI_TYPE_ROWS: in_id, out_id, inout_id, retval_id = row.ids - if row.ids == (0, 0, 0, 0): + if row.ids == (0, 0, 0, 0): # padding row continue for tid in (in_id, out_id, inout_id, retval_id): if tid == 0: @@ -773,6 +774,91 @@ def test_grid_categories_match_directions_with_empirical_exception(self): ) +class TestIntrospectionTypeSetsAndClassification(unittest.TestCase): + @staticmethod + def _ids_for_flag(flag: str) -> 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_all_complex_set_is_union_of_method_and_struct_sets(self): + self.assertEqual( + introspection_mod._ALL_COMPLEX_TYPE_IDS, + introspection_mod._COMPLEX_METHOD_TYPE_IDS | introspection_mod._COMPLEX_STRUCT_TYPE_IDS, + ) + + def test_struct_and_enum_reference_sets_are_disjoint(self): + self.assertTrue( + introspection_mod._STRUCT_REF_TYPE_IDS.isdisjoint(introspection_mod._ENUM_REF_TYPE_IDS) + ) + + def test_parameter_type_struct_refs_cover_all_directions_and_struct_sentinels(self): + struct_ids = self._ids_for_flag("is_struct_kind") + for tid in struct_ids + (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY): + pt = ParameterType(tid, source_id=2, ref_id=1) + self.assertTrue(pt.is_complex) + self.assertTrue(pt.is_struct_ref) + self.assertFalse(pt.is_enum_ref) + + def test_parameter_type_enum_refs_cover_all_directions_and_struct_sentinels(self): + enum_ids = self._ids_for_flag("is_enum_kind") + for tid in enum_ids + (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY): + pt = ParameterType(tid, source_id=2, ref_id=1) + self.assertTrue(pt.is_complex) + self.assertTrue(pt.is_enum_ref) + self.assertFalse(pt.is_struct_ref) + + def test_scalar_parameter_type_is_not_complex_or_reference(self): + # i32 In ID — first entry from a non-complex row + scalar_id = next( + row.ids[0] + for row in introspection_mod._HOI_TYPE_ROWS + if not row.is_complex and row.ids[0] != 0 and row.display_name == "i32" + ) + pt = ParameterType(scalar_id) + self.assertFalse(pt.is_complex) + self.assertFalse(pt.is_struct_ref) + self.assertFalse(pt.is_enum_ref) + + +class TestIntrospectionTypeParsers(unittest.TestCase): + def test_parse_method_param_types_supports_simple_ref_and_node_global(self): + # [i8 simple] + [struct ref source=2 id=1] + [struct ref 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.type_id for pt in parsed], [1, 57, 57]) + 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], [40, 30, 30]) + 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 _MinimalIntroBackend: """Protocol-shaped stub for :class:`HamiltonIntrospection` unit tests.""" @@ -787,9 +873,6 @@ def registry(self): def global_object_addresses(self): return () - def get_root_object_addresses(self): - return [] - def get_firmware_tree_cache(self): return None @@ -799,6 +882,14 @@ def set_firmware_tree_cache(self, tree): async def send_command(self, *a, **k): raise AssertionError("send_command should be patched out in introspection cache tests") + async def send_query(self, *a, **k): + raise AssertionError("send_query should be patched out in introspection cache tests") + + async def send_discovery_command(self, *a, **k): + raise AssertionError( + "send_discovery_command should be patched out in introspection cache tests" + ) + async def resolve_path(self, path: str): del path raise AssertionError("unused") @@ -854,9 +945,7 @@ async def test_lazy_signature_loads_only_referenced_iface(self): async def fake_ensure(addr, iface_id): touched.append(iface_id) key = (addr, iface_id) - self.intro._structs_by_addr_iface[key] = {0: st} - self.intro._enums_by_addr_iface[key] = {} - self.intro._iface_types_loaded.add(key) + self.intro._iface_types[key] = ({0: st}, {}) self.intro.ensure_structs_enums = fake_ensure # type: ignore[method-assign] From b34c7f35b4061045b7cc93b5dfb7451fbd951d25 Mon Sep 17 00:00:00 2001 From: Cody Moore <46687103+cmoscy@users.noreply.github.com> Date: Fri, 1 May 2026 22:35:45 -0700 Subject: [PATCH 13/14] TCP and Introspection cleanup and in progress Nimbus alignment --- .../liquid_handlers/nimbus/channels.py | 81 +++ .../liquid_handlers/nimbus/chatterbox.py | 47 +- .../liquid_handlers/nimbus/commands.py | 230 +++---- .../hamilton/liquid_handlers/nimbus/core.py | 322 ++++++++++ .../hamilton/liquid_handlers/nimbus/door.py | 13 +- .../hamilton/liquid_handlers/nimbus/driver.py | 54 +- .../hamilton/liquid_handlers/nimbus/info.py | 54 ++ .../liquid_handlers/nimbus/pip_backend.py | 35 +- .../nimbus/tests/driver_tests.py | 54 +- .../nimbus/tests/pip_backend_tests.py | 14 +- pylabrobot/hamilton/tcp/__init__.py | 6 + pylabrobot/hamilton/tcp/client.py | 180 ++---- pylabrobot/hamilton/tcp/commands.py | 37 +- pylabrobot/hamilton/tcp/introspection.py | 567 ++++++++---------- pylabrobot/hamilton/tcp/tcp_tests.py | 243 +++----- pylabrobot/hamilton/tcp/tests/__init__.py | 0 pylabrobot/hamilton/tcp/wire_types.py | 3 + 17 files changed, 1127 insertions(+), 813 deletions(-) create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/channels.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/core.py create mode 100644 pylabrobot/hamilton/liquid_handlers/nimbus/info.py delete mode 100644 pylabrobot/hamilton/tcp/tests/__init__.py 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 76e994ae938..959084b8b9f 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py @@ -7,8 +7,10 @@ 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 .commands import NimbusCommand, _UNRESOLVED from .door import NimbusDoor from .driver import NimbusDriver, NimbusResolvedInterfaces, NimbusSetupParams @@ -51,11 +53,24 @@ async def setup(self, backend_params: Optional[BackendParams] = None): "door_lock": door_address, } self._nimbus_resolved = NimbusResolvedInterfaces.from_resolution_map(self._resolved_interfaces) + path_to_addr = { + "NimbusCORE": nimbus_core_address, + "NimbusCORE.Pipette": pipette_address, + "NimbusCORE.DoorLock": door_address, + } + seed_paths = sorted(NimbusCommand._ALL_PATHS | set(path_to_addr)) + for idx, path in enumerate(seed_paths): + leaf = path.rsplit(".", 1)[-1] + addr = 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), + ) self.pip = NimbusPIPBackend( driver=self, deck=params.deck, address=pipette_address, num_channels=self._num_channels ) - self.door = NimbusDoor(driver=self, address=door_address) + self.door = NimbusDoor(driver=self) if params.require_door_lock and self.door is None: raise RuntimeError("DoorLock is required but not available on this instrument.") @@ -65,6 +80,8 @@ async def stop(self): self.door = None self._resolved_interfaces = {} self._nimbus_resolved = None + self._nimbus_core_address = None + self._invalidate_introspection_session() async def send_command( self, @@ -77,6 +94,24 @@ async def send_command( del ensure_connection, raise_on_error, read_timeout logger.info(f"[Chatterbox] {command.__class__.__name__}") + 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 + # Return canned responses for commands that need them from .commands import ( GetChannelConfiguration, @@ -87,15 +122,15 @@ async def send_command( ) if isinstance(command, GetChannelConfiguration_1): - return {"channels": self._num_channels} + return GetChannelConfiguration_1.Response(channels=self._num_channels, channel_types=[]) 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 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 2b087dc2053..7568884c0f9 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py @@ -8,10 +8,11 @@ import enum import logging -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import ClassVar, Optional, Set from pylabrobot.hamilton.tcp.commands import TCPCommand -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 ( @@ -29,16 +30,41 @@ logger = logging.getLogger(__name__) +_UNRESOLVED = Address(-1, -1, -1) + +@dataclass class NimbusCommand(TCPCommand): - """Thin Nimbus command base for namespace clarity.""" + """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 - def _build_structured_parameters(self) -> HoiParams: - """Serialize wire-annotated dataclass payload fields in declaration order.""" - return HoiParams.from_struct(self) + 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) # ============================================================================ @@ -133,44 +159,41 @@ def _get_default_flow_rate(tip: Tip, is_aspirate: bool) -> float: # ============================================================================ +@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" +@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" +@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 +@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" @dataclass @@ -181,11 +204,9 @@ class InitializeSmartRoll(NimbusCommand): - positions/distances: 0.01 mm """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 29 + firmware_path = "NimbusCORE" - dest: Address x_positions: I32Array y_positions: I32Array begin_tip_deposit_process: I32Array @@ -193,64 +214,45 @@ class InitializeSmartRoll(NimbusCommand): z_position_at_end_of_a_command: I32Array roll_distances: I32Array - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() - +@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 +@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 +@dataclass class GetChannelConfiguration_1(NimbusCommand): """Get channel configuration (NimbusCore root, interface_id=1, command_id=15).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 15 + firmware_path = "NimbusCORE" action_code = 0 - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetChannelConfiguration_1 response. - - Returns: (channels: u16, channel_types: List[i16]) - """ - parser = HoiParamsParser(data) - _, channels = parser.parse_next() - _, channel_types = parser.parse_next() - return {"channels": channels, "channel_types": channel_types} + @dataclass + class Response: + channels: U16 + channel_types: I16Array @dataclass @@ -263,21 +265,13 @@ class SetChannelConfiguration(NimbusCommand): - `enables`: booleans matching `indexes` order. """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 67 + firmware_path = "NimbusCORE.Pipette" - dest: Address channel: U16 indexes: I16Array enables: BoolArray - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() - @dataclass class GetChannelConfiguration(NimbusCommand): @@ -288,38 +282,24 @@ class GetChannelConfiguration(NimbusCommand): - `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) + firmware_path = "NimbusCORE.Pipette" + action_code = 0 - dest: Address channel: U16 indexes: I16Array - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() - - @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} + @dataclass + class Response: + enabled: BoolArray +@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" @dataclass @@ -334,11 +314,9 @@ class PickupTips(NimbusCommand): - `tip_types`: per-channel Nimbus tip type IDs. """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 4 + firmware_path = "NimbusCORE.Pipette" - dest: Address channels_involved: U16Array x_positions: I32Array y_positions: I32Array @@ -347,12 +325,6 @@ class PickupTips(NimbusCommand): end_tip_pick_up_process: I32Array tip_types: U16Array - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() - @dataclass class DropTips(NimbusCommand): @@ -366,11 +338,9 @@ class DropTips(NimbusCommand): - `default_waste`: when true, firmware default waste position is used. """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 5 + firmware_path = "NimbusCORE.Pipette" - dest: Address channels_involved: U16Array x_positions: I32Array y_positions: I32Array @@ -380,12 +350,6 @@ class DropTips(NimbusCommand): z_position_at_end_of_a_command: I32Array default_waste: Bool - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() - @dataclass class DropTipsRoll(NimbusCommand): @@ -398,11 +362,9 @@ class DropTipsRoll(NimbusCommand): - `channels_involved`: 1=active, 0=inactive. """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 82 + firmware_path = "NimbusCORE.Pipette" - dest: Address channels_involved: U16Array x_positions: I32Array y_positions: I32Array @@ -412,12 +374,6 @@ class DropTipsRoll(NimbusCommand): z_position_at_end_of_a_command: I32Array roll_distances: I32Array - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() - @dataclass class EnableADC(NimbusCommand): @@ -427,19 +383,11 @@ class EnableADC(NimbusCommand): - `channels_involved`: 1=active, 0=inactive. """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 43 + firmware_path = "NimbusCORE.Pipette" - dest: Address channels_involved: U16Array - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() - @dataclass class DisableADC(NimbusCommand): @@ -449,19 +397,11 @@ class DisableADC(NimbusCommand): - `channels_involved`: 1=active, 0=inactive. """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 44 + firmware_path = "NimbusCORE.Pipette" - dest: Address channels_involved: U16Array - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() - @dataclass class Aspirate(NimbusCommand): @@ -481,11 +421,9 @@ class Aspirate(NimbusCommand): - `tadm_enabled`: enable Total Aspiration/Dispense Monitoring. """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 6 + firmware_path = "NimbusCORE.Pipette" - dest: Address # Channel selectors/modes. aspirate_type: I16Array channels_involved: U16Array @@ -525,12 +463,6 @@ class Aspirate(NimbusCommand): limit_curve_index: U32Array recording_mode: U16 - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() - @dataclass class Dispense(NimbusCommand): @@ -550,11 +482,9 @@ class Dispense(NimbusCommand): - `tadm_enabled`: enable Total Aspiration/Dispense Monitoring. """ - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 7 + firmware_path = "NimbusCORE.Pipette" - dest: Address # Channel selectors/modes. dispense_type: I16Array channels_involved: U16Array @@ -593,9 +523,3 @@ class Dispense(NimbusCommand): tadm_enabled: Bool limit_curve_index: U32Array recording_mode: U16 - - def __post_init__(self): - super().__init__(self.dest) - - def build_parameters(self) -> HoiParams: - return self._build_structured_parameters() 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 279e3ed9aeb..5a5810533f9 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -4,10 +4,11 @@ import logging from dataclasses import dataclass -from typing import Dict, Mapping, Optional, Set +from typing import Any, Dict, Mapping, Optional, Set 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.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.packets import Address @@ -15,13 +16,17 @@ from .commands import ( GetChannelConfiguration_1, + NimbusCommand, Park, + _UNRESOLVED, ) from .door import NimbusDoor from .pip_backend import NimbusPIPBackend logger = logging.getLogger(__name__) +_EXPECTED_ROOT = "NimbusCORE" + def nimbus_interface_specs_for_root(root_name: str) -> Dict[str, InterfacePathSpec]: """Dot-paths under the instrument root (same mechanism as :class:`PrepDriver`).""" @@ -150,9 +155,9 @@ async def setup(self, backend_params: Optional[BackendParams] = None): raise RuntimeError("No root objects discovered during setup.") root_info = await self.introspection.get_object(root_objects[0]) - if "nimbus" not in root_info.name.lower(): + if root_info.name != _EXPECTED_ROOT: raise RuntimeError( - f"Expected a Nimbus root object, but discovered '{root_info.name}'. Wrong instrument?" + f"Expected root '{_EXPECTED_ROOT}' (Nimbus), but discovered '{root_info.name}'. Wrong instrument?" ) specs = nimbus_interface_specs_for_root(root_info.name) @@ -178,9 +183,9 @@ async def setup(self, backend_params: Optional[BackendParams] = None): ) # Query channel configuration - config = await self.send_command(GetChannelConfiguration_1(nimbus_core_address)) + config = await self.send_command(GetChannelConfiguration_1()) assert config is not None, "GetChannelConfiguration_1 command returned None" - num_channels = config["channels"] + num_channels = config.channels logger.info(f"Channel configuration: {num_channels} channels") # Create backends — each object stores its own address and state @@ -189,7 +194,7 @@ async def setup(self, backend_params: Optional[BackendParams] = None): ) if door_address is not None: - self.door = NimbusDoor(driver=self, address=door_address) + self.door = NimbusDoor(driver=self) elif params.require_door_lock: raise RuntimeError("DoorLock is required but not available on this instrument.") @@ -205,6 +210,7 @@ async def stop(self): self.door = None self._resolved_interfaces = {} self._nimbus_resolved = None + self._nimbus_core_address = None async def _assert_required_methods( self, @@ -224,5 +230,39 @@ async def _assert_required_methods( async def park(self): """Park the instrument.""" - await self.send_command(Park(self.nimbus_core_address)) + await self.send_command(Park()) 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..bdb6c93bc6e --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/info.py @@ -0,0 +1,54 @@ +"""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 .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) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py index 7c24c29e5b3..c8a5771250c 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -23,6 +23,7 @@ from .commands import ( Aspirate, DisableADC, + Dispense as DispenseCommand, DropTips, DropTipsRoll, EnableADC, @@ -35,9 +36,6 @@ _get_default_flow_rate, _get_tip_type_from_tip, ) -from .commands import ( - Dispense as DispenseCommand, -) if TYPE_CHECKING: from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck @@ -209,9 +207,9 @@ async def _on_setup(self, backend_params: Optional[BackendParams] = None): """Initialize SmartRoll if not already initialized.""" del backend_params # Query initialization status - init_status = await self.driver.send_command(IsInitialized(self.driver.nimbus_core_address)) + init_status = await self.driver.send_command(IsInitialized()) assert init_status is not None - is_initialized = init_status.get("initialized", False) + is_initialized = init_status.initialized if not is_initialized: await self._initialize_smart_roll() @@ -228,7 +226,6 @@ async def _initialize_smart_roll(self): 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], @@ -249,7 +246,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, @@ -418,9 +414,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: @@ -496,7 +492,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, @@ -586,7 +581,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, @@ -611,7 +605,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, @@ -676,9 +669,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: @@ -687,10 +680,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 @@ -862,7 +855,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, @@ -952,9 +944,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: @@ -963,10 +955,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 @@ -1142,7 +1134,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 index c5266e0daf8..f785cf15977 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py @@ -1,10 +1,16 @@ import asyncio +from dataclasses import dataclass from unittest.mock import AsyncMock, patch import pytest from pylabrobot.hamilton.liquid_handlers.nimbus.chatterbox import NimbusChatterboxDriver -from pylabrobot.hamilton.liquid_handlers.nimbus.commands import GetChannelConfiguration_1 +from pylabrobot.hamilton.liquid_handlers.nimbus.commands import ( + GetChannelConfiguration_1, + NimbusCommand, + Park, + _UNRESOLVED, +) from pylabrobot.hamilton.liquid_handlers.nimbus.driver import ( NimbusDriver, NimbusResolvedInterfaces, @@ -23,11 +29,53 @@ async def _run() -> None: assert driver.door is not None response = await driver.send_command( - GetChannelConfiguration_1(driver.nimbus_core_address), + GetChannelConfiguration_1(), read_timeout=0.1, ) - assert response == {"channels": 8} + assert response.channels == 8 + + 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 = GetChannelConfiguration_1() + 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()) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py index b25666b9ae7..d1d84162ddf 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/pip_backend_tests.py @@ -7,7 +7,7 @@ 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 +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, @@ -75,7 +75,7 @@ async def _run() -> None: ) driver = AsyncMock() - driver.send_command = AsyncMock(return_value={"enabled": [True]}) + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) backend = NimbusPIPBackend( driver=driver, # type: ignore[arg-type] @@ -139,7 +139,7 @@ async def _run() -> None: ) driver = AsyncMock() - driver.send_command = AsyncMock(return_value={"enabled": [True]}) + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) backend = NimbusPIPBackend( driver=driver, # type: ignore[arg-type] @@ -197,7 +197,7 @@ async def _run() -> None: ) driver = AsyncMock() - driver.send_command = AsyncMock(return_value={"enabled": [True]}) + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) backend = NimbusPIPBackend( driver=driver, # type: ignore[arg-type] @@ -252,7 +252,7 @@ async def _run() -> None: ) driver = AsyncMock() - driver.send_command = AsyncMock(return_value={"enabled": [True]}) + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) backend = NimbusPIPBackend( driver=driver, # type: ignore[arg-type] @@ -307,7 +307,7 @@ async def _run() -> None: ) driver = AsyncMock() - driver.send_command = AsyncMock(return_value={"enabled": [True]}) + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) backend = NimbusPIPBackend( driver=driver, # type: ignore[arg-type] @@ -362,7 +362,7 @@ async def _run() -> None: ) driver = AsyncMock() - driver.send_command = AsyncMock(return_value={"enabled": [True]}) + driver.send_command = AsyncMock(return_value=GetChannelConfiguration.Response(enabled=[True])) backend = NimbusPIPBackend( driver=driver, # type: ignore[arg-type] diff --git a/pylabrobot/hamilton/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py index 746045c949c..0ed2f0d703c 100644 --- a/pylabrobot/hamilton/tcp/__init__.py +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -10,9 +10,12 @@ ) from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.introspection import ( + Direction, FirmwareTree, MethodInfo, + MethodParamType, ObjectInfo, + StructFieldType, flatten_firmware_tree, ) from pylabrobot.hamilton.tcp.messages import ( @@ -52,7 +55,10 @@ "CommandMessage", "CommandResponse", "ConnectionPacket", + "Direction", "FirmwareTree", + "MethodParamType", + "StructFieldType", "flatten_firmware_tree", "HAMILTON_PROTOCOL_VERSION_MAJOR", "HAMILTON_PROTOCOL_VERSION_MINOR", diff --git a/pylabrobot/hamilton/tcp/client.py b/pylabrobot/hamilton/tcp/client.py index e881032c86d..333ccc82a8d 100644 --- a/pylabrobot/hamilton/tcp/client.py +++ b/pylabrobot/hamilton/tcp/client.py @@ -1,8 +1,7 @@ """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 (do not construct introspection classes -directly from application code). +Interface-0 discovery and type work. """ from __future__ import annotations @@ -48,112 +47,6 @@ logger = logging.getLogger(__name__) -@dataclass -class HamiltonError: - """Hamilton error response.""" - - error_code: int - error_message: str - interface_id: int - action_id: int - - -class ErrorParser: - """Parse Hamilton error responses.""" - - @staticmethod - def parse_error(data: bytes) -> HamiltonError: - """Parse error response from Hamilton instrument.""" - if len(data) < 8: - raise ValueError("Error response too short") - - error_code = Reader(data).u32() - error_message = data[4:].decode("utf-8", errors="replace") - - return HamiltonError( - error_code=error_code, error_message=error_message, interface_id=0, action_id=0 - ) - - -class _HcResultDescriptionHelper: - """Resolves ``HcResultEntry`` to display strings and optional method context. - - Thin adapter over :attr:`HamiltonTCPClient.introspection` for Interface-0 metadata lookups - used after :attr:`HamiltonTCPClient._ERROR_CODES` and :data:`HC_RESULT_PROTOCOL` tables. - """ - - def __init__(self, client: HamiltonTCPClient) -> None: - self._client = client - - def clear(self) -> None: - """No-op; introspection owns session caches.""" - return - - async def describe_entry(self, entry: HcResultEntry) -> Tuple[Optional[str], str]: - addr = Address(entry.module_id, entry.node_id, entry.object_id) - iface_name = await self._client.introspection.get_interface_name(addr, entry.interface_id) - - # Vendor tables (e.g. :data:`~pylabrobot.hamilton.tcp.error_tables.NIMBUS_ERROR_CODES`) - # key on ``(module, node, object_id, interface_id, hc_result)``. The wire ``action_id`` is the - # failing method id (e.g. PickupTips) 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._client._ERROR_CODES.get(key_iface) - if desc is None and key_action != key_iface: - desc = self._client._ERROR_CODES.get(key_action) - if desc is None: - desc = HC_RESULT_PROTOCOL.get(entry.result) - if desc is None: - desc = await self._client.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]: - addr = Address(entry.module_id, entry.node_id, entry.object_id) - path = self._client._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._client.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 - - class HamiltonTCPClient(Driver): """Standalone transport + discovery/introspection client for Hamilton TCP devices.""" @@ -193,7 +86,6 @@ def __init__( self._global_object_addresses: list[Address] = [] self._event_handlers: list[Callable[[CommandResponse], None]] = [] self._introspection_impl: Optional[HamiltonIntrospection] = None - self._hc_result_text = _HcResultDescriptionHelper(self) @property def registry(self) -> ObjectRegistry: @@ -206,7 +98,7 @@ def global_object_addresses(self) -> Sequence[Address]: return tuple(self._global_object_addresses) def get_root_object_addresses(self) -> list[Address]: - """Root address from the registry as a single-element list (protocol compatibility shim).""" + """Root address from the registry as a single-element list.""" addr = self._registry.get_root_address() return [addr] if addr is not None else [] @@ -214,12 +106,65 @@ def get_root_object_addresses(self) -> list[Address]: def introspection(self) -> HamiltonIntrospection: """Lazy Interface-0 / type introspection facet (canonical entry).""" if self._introspection_impl is None: - self._introspection_impl = HamiltonIntrospection(self) + 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. @@ -243,7 +188,6 @@ def _dispatch_event(self, response_message: CommandResponse) -> None: logger.exception("Event handler %r raised: %s", handler, exc) def _clear_session_state_for_setup(self) -> None: - self._hc_result_text.clear() self._global_object_addresses = [] self._invalidate_introspection_session() @@ -645,7 +589,7 @@ async def _send_raw( context_by_channel: Dict[int, Optional[str]] = {} hoi_exceptions: Dict[int, Exception] = {} for idx, entry in enumerate(entries): - _iface_name, desc = await self._hc_result_text.describe_entry(entry) + _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) @@ -653,7 +597,7 @@ async def _send_raw( channel = idx per_channel.setdefault(channel, err) if channel not in context_by_channel: - context_by_channel[channel] = await self._hc_result_text.format_entry_context(entry) + context_by_channel[channel] = await self._format_entry_context(entry) if raise_on_error: channel_summary = ", ".join( @@ -688,10 +632,10 @@ async def _send_raw( entry_errors: Dict[int, Exception] = {} context_by_idx: Dict[int, Optional[str]] = {} for idx, entry in enumerate(entries): - _iface_name, desc = await self._hc_result_text.describe_entry(entry) + _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._hc_result_text.format_entry_context(entry) + context_by_idx[idx] = await self._format_entry_context(entry) if raise_on_error: summary = ", ".join( @@ -731,9 +675,9 @@ async def _send_raw( 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._hc_result_text.describe_entry(e) + _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._hc_result_text.format_entry_context(e) + fatal_context_by_channel[ch] = await self._format_entry_context(e) logger.error( "Hamilton command fatal entries: %s", ", ".join( diff --git a/pylabrobot/hamilton/tcp/commands.py b/pylabrobot/hamilton/tcp/commands.py index 73bad223bfc..eac460f996e 100644 --- a/pylabrobot/hamilton/tcp/commands.py +++ b/pylabrobot/hamilton/tcp/commands.py @@ -27,30 +27,24 @@ class TCPCommand: """Base class for Hamilton TCP commands. - This replaces the old command base 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: + 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 @@ -84,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: diff --git a/pylabrobot/hamilton/tcp/introspection.py b/pylabrobot/hamilton/tcp/introspection.py index 05fa410d0c9..e2208b20b9c 100644 --- a/pylabrobot/hamilton/tcp/introspection.py +++ b/pylabrobot/hamilton/tcp/introspection.py @@ -1,11 +1,13 @@ """Hamilton TCP Introspection API. -Wraps a session backend (:class:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient`) -to provide dynamic discovery via Interface 0 methods (GetObject, GetMethod, +Provides dynamic discovery via Interface 0 methods (GetObject, GetMethod, GetStructs, GetEnums, GetInterfaces, GetSubobjectAddress). -**Canonical usage:** use :attr:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient.introspection` -(do not construct :class:`HamiltonIntrospection` from application code). +: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):** @@ -39,7 +41,8 @@ import logging from dataclasses import dataclass, field -from typing import Any, Dict, List, Literal, Optional, Protocol, Sequence, Set, Tuple, Union, cast +from enum import IntEnum +from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Set, Tuple, Union, cast from pylabrobot.hamilton.tcp.commands import TCPCommand from pylabrobot.hamilton.tcp.messages import ( @@ -65,35 +68,18 @@ logger = logging.getLogger(__name__) -class HamiltonTCPIntrospectionBackend(Protocol): - """Structural type for objects passed to :class:`HamiltonIntrospection`. +class Direction(IntEnum): + """Direction of a method parameter in the HOI introspection type system. - **Production:** :class:`~pylabrobot.hamilton.tcp.client.HamiltonTCPClient` implements this - Protocol (transport, registry, session caches, ``send_command``). - - **Tests:** provide a minimal object with the same methods/properties so introspection can be - exercised without a socket—see ``tcp_tests`` (e.g. fake ``Backend`` with registry roots and - patched ``HamiltonIntrospection`` methods). This is a typing contract only; there is no separate - runtime "backend" class besides the client. + Column order matches ``_HOI_TYPE_ROWS`` ids tuple: ``ids[Direction]`` gives the + direction-encoded HOI type ID for that row and direction. """ - @property - def registry(self) -> ObjectRegistry: ... - - @property - def global_object_addresses(self) -> Sequence[Address]: ... - - async def send_command( - self, command: TCPCommand, *, read_timeout: Optional[float] = None - ) -> Any: ... - - async def send_query( - self, command: TCPCommand, *, read_timeout: Optional[float] = None - ) -> Optional[tuple]: ... + In = 0 + Out = 1 + InOut = 2 + RetVal = 3 - async def send_discovery_command( - self, command: TCPCommand, *, read_timeout: Optional[float] = None - ) -> Any: ... async def _subobject_address_and_info( @@ -147,10 +133,9 @@ def resolve_type_id(type_id: int) -> str: # ============================================================================ # INTROSPECTION TYPE MAPPING (2D table from HoiObject.mHoiParamTypes) # ============================================================================ -# Introspection type IDs are a unique interface-0 typing system, distinct from -# the standard HamiltonDataType wire-encoding type IDs. -# Rows = firmware scalar or array kinds; columns = In, Out, InOut, RetVal -# (HoiParameterType.Direction). Source: vendor protocol reference mHoiParamTypes[31,4]. +# 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]. @dataclass(frozen=True) @@ -162,6 +147,10 @@ class _HoiTypeRow: interface-0 HOI type system, a unique typing scheme separate from the standard ``HamiltonDataType`` wire type IDs. + ``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. + ``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). @@ -169,162 +158,75 @@ class _HoiTypeRow: 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)), - _HoiTypeRow("i16", (3, 19, 11, 27)), - _HoiTypeRow("i32", (5, 21, 13, 29)), - _HoiTypeRow("u8", (2, 18, 10, 26)), - _HoiTypeRow("u16", (4, 20, 12, 28)), - _HoiTypeRow("u32", (6, 22, 14, 30)), - _HoiTypeRow("str", (7, 23, 15, 31)), - _HoiTypeRow("bool", (33, 35, 34, 36)), - _HoiTypeRow("List[i8]", (37, 39, 38, 40)), - _HoiTypeRow("List[i16]", (41, 43, 42, 44)), - _HoiTypeRow("List[i32]", (49, 51, 50, 52)), - _HoiTypeRow("bytes", (8, 24, 16, 32)), - _HoiTypeRow("List[u16]", (45, 47, 46, 48)), - _HoiTypeRow("List[u32]", (53, 55, 54, 56)), - _HoiTypeRow("List[bool]", (66, 68, 67, 69)), - _HoiTypeRow("HcResult", (70, 72, 71, 73), is_complex=True), - _HoiTypeRow("struct", (57, 59, 58, 60), is_complex=True, is_struct_kind=True), - _HoiTypeRow("List[struct]", (61, 63, 62, 64), is_complex=True, is_struct_kind=True), - _HoiTypeRow("List[str]", (74, 76, 75, 77), is_complex=True), - _HoiTypeRow("enum", (78, 80, 79, 81), is_complex=True, is_enum_kind=True), - _HoiTypeRow("List[enum]", (82, 84, 83, 85), is_complex=True, is_enum_kind=True), - _HoiTypeRow("i64", (86, 88, 87, 89)), - _HoiTypeRow("u64", (90, 92, 91, 93)), - _HoiTypeRow("f32", (94, 96, 95, 97)), - _HoiTypeRow("f64", (98, 100, 99, 101)), - _HoiTypeRow("List[i64]", (102, 104, 103, 105)), - _HoiTypeRow("List[u64]", (106, 108, 107, 109)), - _HoiTypeRow("List[f32]", (110, 112, 111, 113)), - _HoiTypeRow("List[f64]", (114, 116, 115, 117)), - _HoiTypeRow("HoiResult", (118, 120, 119, 121), is_complex=True), - _HoiTypeRow("padding", (0, 0, 0, 0)), + _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), ) -_HOI_PARAM_DIRECTION: tuple[str, ...] = ("In", "Out", "InOut", "RetVal") - - -@dataclass(frozen=True) -class _IntrospectionTypeMaps: - """Derived classification maps built once from :data:`_HOI_TYPE_ROWS` at import time.""" - - type_names: dict[int, str] - argument_type_ids: frozenset[int] - return_element_type_ids: frozenset[int] - return_value_type_ids: frozenset[int] - complex_method_type_ids: frozenset[int] - complex_struct_type_ids: frozenset[int] - struct_ref_type_ids: frozenset[int] - enum_ref_type_ids: frozenset[int] - all_complex_type_ids: frozenset[int] - - -def _build_introspection_maps() -> _IntrospectionTypeMaps: - names: dict[int, str] = {0: "void"} - arg_ids: set[int] = set() - ret_el_ids: set[int] = set() - ret_val_ids: set[int] = set() - complex_method_ids: set[int] = set() - struct_ref_ids: set[int] = set() - enum_ref_ids: set[int] = set() - for row in _HOI_TYPE_ROWS: - for ci, tid in enumerate(row.ids): - if tid == 0: - continue - d = _HOI_PARAM_DIRECTION[ci] - names[tid] = f"{row.display_name} [{d}]" - if ci in (0, 2): - arg_ids.add(tid) - elif ci == 1: - ret_el_ids.add(tid) - elif ci == 3: - ret_val_ids.add(tid) - if row.is_complex: - complex_method_ids.add(tid) - if row.is_struct_kind: - struct_ref_ids.add(tid) - if row.is_enum_kind: - enum_ref_ids.add(tid) - - # GetStructs sentinels (Parameter.ParameterTypes values) — live in the GetStructs wire format - # only, not in _HOI_TYPE_ROWS. - complex_struct = frozenset( - { - HamiltonDataType.STRUCTURE, - HamiltonDataType.STRUCTURE_ARRAY, - HamiltonDataType.ENUM, - HamiltonDataType.ENUM_ARRAY, - } - ) - struct_ref_ids |= {HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY} - enum_ref_ids |= {HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY} - - # Empirical: type_id=113 (List[f32] column 3 = RetVal) appears as Argument on some firmware. - # TODO: Re-validate against hardware captures and remove if no longer observed. - names[113] = "List[f32] [In] (empirical)" - arg_ids.add(113) - - return _IntrospectionTypeMaps( - type_names=names, - argument_type_ids=frozenset(arg_ids), - return_element_type_ids=frozenset(ret_el_ids), - return_value_type_ids=frozenset(ret_val_ids), - complex_method_type_ids=frozenset(complex_method_ids), - complex_struct_type_ids=complex_struct, - struct_ref_type_ids=frozenset(struct_ref_ids), - enum_ref_type_ids=frozenset(enum_ref_ids), - all_complex_type_ids=frozenset(complex_method_ids | complex_struct), - ) - - -_MAPS = _build_introspection_maps() -_INTROSPECTION_TYPE_NAMES = _MAPS.type_names -_ARGUMENT_TYPE_IDS = _MAPS.argument_type_ids -_RETURN_ELEMENT_TYPE_IDS = _MAPS.return_element_type_ids -_RETURN_VALUE_TYPE_IDS = _MAPS.return_value_type_ids -_COMPLEX_METHOD_TYPE_IDS = _MAPS.complex_method_type_ids -_COMPLEX_STRUCT_TYPE_IDS = _MAPS.complex_struct_type_ids -_STRUCT_REF_TYPE_IDS = _MAPS.struct_ref_type_ids -_ENUM_REF_TYPE_IDS = _MAPS.enum_ref_type_ids -_ALL_COMPLEX_TYPE_IDS = _MAPS.all_complex_type_ids - - -def get_introspection_type_category(type_id: int) -> str: - """Get category for introspection type ID. - - Args: - type_id: Introspection type ID - - Returns: - Category: "Argument", "ReturnElement", "ReturnValue", or "Unknown" - """ - if type_id in _ARGUMENT_TYPE_IDS: - return "Argument" - elif type_id in _RETURN_ELEMENT_TYPE_IDS: - return "ReturnElement" - elif type_id in _RETURN_VALUE_TYPE_IDS: - return "ReturnValue" - else: - return "Unknown" - +# 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 +) -def resolve_introspection_type_name(type_id: int) -> str: - """Resolve introspection type ID to readable name. +# 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: - Human-readable type name - """ - return _INTROSPECTION_TYPE_NAMES.get(type_id, f"UNKNOWN_TYPE_{type_id}") # ============================================================================ @@ -443,67 +345,71 @@ def walk(node: FirmwareTreeNode) -> None: return out + @dataclass -class ParameterType: - """A resolved type reference from either GetMethod parameterTypes or GetStructs field types. +class MethodParamType: + """A method parameter or return type from GetMethod, in the HOI introspection namespace. - Simple types (i8, f32, etc.) have only type_id set. - Complex references additionally carry source_id and ref_id: + ``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 widths vary by context and source_id (see _parse_method_param_types and - _parse_struct_field_types for the exact encoding from HoiObject.cs). """ - type_id: int + 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 (1=simple, 3=ref, 7=node-global struct field, variable=node-global method param) - - @property - def is_complex(self) -> bool: - """True if this entry has a source_id/ref_id pair (struct or enum reference).""" - return self.type_id in _ALL_COMPLEX_TYPE_IDS + _byte_width: int = 1 # bytes consumed from the wire blob @property def is_struct_ref(self) -> bool: - """True if this references a struct definition (all directions: In/Out/InOut/RetVal).""" - return self.type_id in _STRUCT_REF_TYPE_IDS + return self.wire_type in (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY) @property def is_enum_ref(self) -> bool: - """True if this references an enum definition (all directions: In/Out/InOut/RetVal).""" - return self.type_id in _ENUM_REF_TYPE_IDS + 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 source_id=2 (local) refs, pass ``ho_interface_id`` (the HOI interface id - of the method or struct owning this type) so resolution uses that interface's - table only. - """ - base = resolve_introspection_type_name(self.type_id) - if not self.is_complex or self.source_id is None or self.ref_id is None: + """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 registry is None: - return f"{base}(iface={self.source_id}, id={self.ref_id})" if self.is_struct_ref: - s = registry.resolve_struct(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) - return s.name if s else f"{base}(iface={self.source_id}, id={self.ref_id})" + 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: - e = registry.resolve_enum(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) - return e.name if e else f"{base}(iface={self.source_id}, id={self.ref_id})" + 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[ParameterType]: +) -> List[MethodParamType]: """Parse GetMethod parameterTypes byte stream. Source: HoiObject.HandleStruct in HoiObject.cs. @@ -520,10 +426,11 @@ def _parse_method_param_types( _SPACE = 0x20 ints = list(data) if isinstance(data, bytes) else data - result: List[ParameterType] = [] + 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] @@ -536,21 +443,75 @@ def _parse_method_param_types( if end < len(ints) and ints[end] == _SPACE: end += 1 # consume trailing ' ' result.append( - ParameterType(tid, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=end - i) + MethodParamType(wire_type, direction, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=end - i) ) i = end else: - result.append(ParameterType(tid, source_id=source_id, ref_id=ref_id, _byte_width=3)) + result.append(MethodParamType(wire_type, direction, source_id=source_id, ref_id=ref_id, _byte_width=3)) i += 3 else: - result.append(ParameterType(tid)) + 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[ParameterType]: +) -> List[StructFieldType]: """Parse GetStructs structureElementTypes byte stream. Source: HoiObject.GetStructs in HoiObject.cs. @@ -567,29 +528,30 @@ def _parse_struct_field_types( _NODE_GLOBAL_WIDTH = 7 ints = list(data) if isinstance(data, bytes) else data - result: List[ParameterType] = [] + 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( - ParameterType(tid, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=_NODE_GLOBAL_WIDTH) + StructFieldType(wire_type, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=_NODE_GLOBAL_WIDTH) ) i += _NODE_GLOBAL_WIDTH else: - result.append(ParameterType(tid, source_id=source_id, ref_id=ref_id, _byte_width=3)) + result.append(StructFieldType(wire_type, source_id=source_id, ref_id=ref_id, _byte_width=3)) i += 3 else: - result.append(ParameterType(tid)) + result.append(StructFieldType(wire_type)) i += 1 return result -def _parse_type_ids(raw: str | bytes | None) -> List[ParameterType]: +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 @@ -624,20 +586,10 @@ class MethodDescriptor: def id_string(self) -> str: return f"[{self.interface_id}:{self.method_id}]" - @staticmethod - def _signature_type_name(type_name: str) -> str: - """Strip direction markers for human-readable signatures.""" - cleaned = type_name - for marker in ("In", "Out", "RetVal"): - cleaned = cleaned.replace(f" [{marker}]", "") - return cleaned.strip() - def signature_string(self) -> str: """Render the canonical method descriptor as a signature string.""" if self.params: - param_str = ", ".join( - f"{p.name}: {self._signature_type_name(p.type_name)}" for p in self.params - ) + param_str = ", ".join(f"{p.name}: {p.type_name}" for p in self.params) else: param_str = "void" @@ -645,12 +597,11 @@ def signature_string(self) -> str: return_str = "void" elif self.return_shape == "scalar" and len(self.returns) == 1: ret = self.returns[0] - ret_type = self._signature_type_name(ret.type_name) - return_str = f"{ret.name}: {ret_type}" if ret.name != "ret0" else ret_type + return_str = f"{ret.name}: {ret.type_name}" if ret.name != "ret0" else ret.type_name else: return_str = ( "{ " - + ", ".join(f"{r.name}: {self._signature_type_name(r.type_name)}" for r in self.returns) + + ", ".join(f"{r.name}: {r.type_name}" for r in self.returns) + " }" ) @@ -674,9 +625,9 @@ class MethodInfo: call_type: int method_id: int name: str - parameter_types: list[ParameterType] = field(default_factory=list) + parameter_types: list[MethodParamType] = field(default_factory=list) parameter_labels: list[str] = field(default_factory=list) - return_types: list[ParameterType] = 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: @@ -697,14 +648,13 @@ def describe(self, registry: Optional["TypeRegistry"] = None) -> MethodDescripto return_type_names = [ rt.resolve_name(registry, ho_interface_id=iid) for rt in self.return_types ] - return_categories = [get_introspection_type_category(rt.type_id) 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(cat == "ReturnElement" for cat in return_categories): + 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 ReturnElement records and explicit multi-return methods. + # Includes Out/ReturnElement records and explicit multi-return methods. return_shape = "record" return MethodDescriptor( @@ -720,7 +670,7 @@ 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 struct(iface=1, id=57)). + their names (e.g. PickupTipParameters instead of structure(source=2, ref=1)). """ return self.describe(registry).signature_string() @@ -900,28 +850,28 @@ class StructInfo: ``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 ``ParameterType`` instances, preserving the + ``fields`` maps field names to ``StructFieldType`` instances, preserving the full (type_id, source_id, ref_id) triple for fields that are complex - references (type 30=STRUCTURE, 32=ENUM). Call ``get_struct_string(registry)`` + 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, "ParameterType"] # field_name -> ParameterType + 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 using HamiltonDataType resolver.""" - return {name: _resolve_struct_field_type(pt) for name, pt in self.fields.items()} + 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: _resolve_struct_field_type(pt, registry, ho_interface_id=ho_iid) - for name, pt in self.fields.items() + 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: @@ -936,8 +886,8 @@ def get_struct_string(self, registry: Optional["TypeRegistry"] = None) -> str: """ ho_iid = self.interface_id field_strs = [ - f"{name}: {_resolve_struct_field_type(pt, registry, ho_interface_id=ho_iid)}" - for name, pt 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}}" @@ -951,13 +901,13 @@ def get_struct_string(self, registry: Optional["TypeRegistry"] = None) -> str: struct_id=3, name="DateTime", fields={ - "year": ParameterType(type_id=HamiltonDataType.U16), - "month": ParameterType(type_id=HamiltonDataType.U8), - "day": ParameterType(type_id=HamiltonDataType.U8), - "hour": ParameterType(type_id=HamiltonDataType.U8), - "minute": ParameterType(type_id=HamiltonDataType.U8), - "second": ParameterType(type_id=HamiltonDataType.U8), - "millisecond": ParameterType(type_id=HamiltonDataType.U16), + "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, ) @@ -1020,34 +970,6 @@ def print_summary(self) -> None: # The HamiltonDataType namespace is used here, NOT the introspection type namespace. -def _resolve_struct_field_type( - pt: ParameterType, - registry: Optional["TypeRegistry"] = None, - *, - ho_interface_id: Optional[int] = None, -) -> str: - """Resolve a struct field's ParameterType to a human-readable type name. - - Struct field type_ids use the HamiltonDataType wire namespace (e.g. 40=F32, - 23=BOOL) -- not the method-parameter introspection namespace. Complex - references (30=STRUCTURE, 32=ENUM) are resolved via the TypeRegistry when provided. - - Pass ``ho_interface_id`` as the owning struct's HOI interface id for local - (source_id=2) field references. - """ - if pt.is_complex and pt.source_id is not None and pt.ref_id is not None: - if registry is not None: - if pt.is_struct_ref: - s = registry.resolve_struct(pt.source_id, pt.ref_id, ho_interface_id=ho_interface_id) - if s: - return f"struct({s.name})" - elif pt.is_enum_ref: - e = registry.resolve_enum(pt.source_id, pt.ref_id, ho_interface_id=ho_interface_id) - if e: - return e.name - return f"ref(iface={pt.source_id}, id={pt.ref_id})" - return resolve_type_id(pt.type_id) # HamiltonDataType resolver - # ============================================================================ # INTROSPECTION COMMAND CLASSES @@ -1102,7 +1024,7 @@ def parse_response_parameters(cls, data: bytes) -> dict: # 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 ParameterType count). + # Labels are comma-separated, one per *logical* parameter (matching MethodParamType count). parameter_labels_str = None if parser.has_remaining(): @@ -1125,27 +1047,26 @@ def parse_response_parameters(cls, data: bytes) -> dict: if parameter_labels_str: all_labels = [label.strip() for label in parameter_labels_str.split(",") if label.strip()] - parameter_types: list[ParameterType] = [] + parameter_types: list[MethodParamType] = [] parameter_labels: list[str] = [] - return_types: list[ParameterType] = [] + return_types: list[MethodParamType] = [] return_labels: list[str] = [] for i, pt in enumerate(all_types): - category = get_introspection_type_category(pt.type_id) label = all_labels[i] if i < len(all_labels) else None - if category == "Argument": + if pt.is_argument: parameter_types.append(pt) if label: parameter_labels.append(label) - elif category in ("ReturnElement", "ReturnValue"): + elif pt.is_return: return_types.append(pt) if label: return_labels.append(label) else: raise ValueError( - f"Unknown introspection type_id={pt.type_id} ({resolve_introspection_type_name(pt.type_id)}); " - "not in HoiObject mHoiParamTypes grid — update _HOI_TYPE_ROWS or add an override." + 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 { @@ -1294,18 +1215,24 @@ class HamiltonIntrospection: methods are supported and only calls those. Interfaces are per-object; there is no aggregation from children. + 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. + over constructing this class directly from application code. """ - def __init__(self, backend: HamiltonTCPIntrospectionBackend): - """Initialize introspection API. - - Args: - backend: Session implementing :class:`HamiltonTCPIntrospectionBackend` - """ - self.backend = backend - # Session caches (invalidated when the client drops the introspection facet, e.g. reconnect). + 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]] @@ -1346,9 +1273,9 @@ async def _ensure_parameter_types_for_signature( seen_structs: Set[Tuple[int, int]] = set() max_nodes = 256 - async def walk(types: List[ParameterType], ho_iface: int) -> None: + async def walk(types: List[Union[MethodParamType, StructFieldType]], ho_iface: int) -> None: for pt in types: - if not pt.is_complex or pt.source_id is None or pt.ref_id is None: + if pt.source_id is None or pt.ref_id is None: continue if pt.source_id in (1, 3): continue @@ -1535,7 +1462,7 @@ async def ensure_global_type_pool( addrs = ( list(global_addresses) if global_addresses is not None - else list(self.backend.global_object_addresses) + 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 @@ -1587,7 +1514,7 @@ async def _walk_node( object_info=obj, supported_interface0_methods=supported, ) - self.backend.registry.register(path, obj) + self._registry.register(path, obj) # Keep this guard even though Interface-0 method 3 (GetSubobjectAddress) # appears ubiquitous in current PREP captures. @@ -1615,7 +1542,7 @@ async def resolve_path(self, path: str) -> Address: firmware trees do not trigger a full tree walk. Raises :exc:`KeyError` if the path cannot be found. """ - cached = self.backend.registry.address_for(path) + cached = self._registry.address_for(path) if cached is not None: return cached @@ -1623,12 +1550,12 @@ async def resolve_path(self, path: str) -> Address: if not parts: raise KeyError(f"Invalid path: '{path}'") - root_addr = self.backend.registry.get_root_address() + 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.backend.registry.register(root_obj.name, root_obj) + 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: @@ -1638,7 +1565,7 @@ async def resolve_path(self, path: str) -> Address: current_path = parts[0] for part in parts[1:]: next_path = f"{current_path}.{part}" - cached = self.backend.registry.address_for(next_path) + cached = self._registry.address_for(next_path) if cached is not None: current_addr = cached current_path = next_path @@ -1654,7 +1581,7 @@ async def resolve_path(self, path: str) -> Address: 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.backend.registry.register(f"{current_path}.{sub_obj.name}", sub_obj) + self._registry.register(f"{current_path}.{sub_obj.name}", sub_obj) if sub_obj.name == part: found = sub_addr @@ -1667,7 +1594,7 @@ async def resolve_path(self, path: str) -> Address: async def _build_firmware_tree(self) -> FirmwareTree: """Build a DFS firmware tree from the single registered root address.""" - root_addr = self.backend.registry.get_root_address() + root_addr = self._registry.get_root_address() if root_addr is None: raise RuntimeError("Cannot build firmware tree: no root address registered") @@ -1722,7 +1649,7 @@ async def get_object(self, address: Address) -> ObjectInfo: Object metadata """ command = GetObjectCommand(address) - response = await self.backend.send_discovery_command(command) + response = await self._send_discovery_command(command) if response is None: raise RuntimeError("GetObjectCommand returned None") @@ -1745,7 +1672,7 @@ async def get_method(self, address: Address, method_index: int) -> MethodInfo: Method signature """ command = GetMethodCommand(address, method_index) - response = await self.backend.send_discovery_command(command) + response = await self._send_discovery_command(command) return MethodInfo( interface_id=response["interface_id"], @@ -1769,7 +1696,7 @@ async def get_subobject_address(self, address: Address, subobject_index: int) -> Subobject address """ command = GetSubobjectAddressCommand(address, subobject_index) - response = await self.backend.send_discovery_command(command) + response = await self._send_discovery_command(command) if response is None: raise RuntimeError("GetSubobjectAddressCommand returned None") @@ -1804,7 +1731,7 @@ async def get_interfaces( ) return [] command = GetInterfacesCommand(address) - response = await self.backend.send_discovery_command(command) + response = await self._send_discovery_command(command) if response is None: raise RuntimeError("GetInterfacesCommand returned None") @@ -1836,7 +1763,7 @@ 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_discovery_command(command) + response = await self._send_discovery_command(command) if response is None: raise RuntimeError("GetEnumsCommand returned None") @@ -1870,7 +1797,7 @@ async def _get_structs_raw(self, address: Address, interface_id: int) -> tuple[b print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") """ command = GetStructsCommand(address, interface_id) - result = await self.backend.send_query(command) + result = await self._send_query(command) if result is None: raise RuntimeError("GetStructs query returned no data.") (params,) = result @@ -1896,7 +1823,7 @@ 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_discovery_command(command) + response = await self._send_discovery_command(command) if response is None: raise RuntimeError("GetStructsCommand returned None") @@ -1990,7 +1917,7 @@ async def build_type_registry_with_children( 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 ParameterType.resolve_name() can find them. + that MethodParamType.resolve_name() can find them. Args: address: Parent object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). diff --git a/pylabrobot/hamilton/tcp/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py index 7d94fb784de..ee32288a0e8 100644 --- a/pylabrobot/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -18,7 +18,7 @@ import pylabrobot.hamilton.tcp.introspection as introspection_mod from pylabrobot.capabilities.liquid_handling.errors import ChannelizedError -from pylabrobot.hamilton.tcp.client import HamiltonTCPClient, _HcResultDescriptionHelper +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 ( @@ -36,7 +36,6 @@ MethodInfo, ObjectInfo, ObjectRegistry, - ParameterType, StructInfo, TypeRegistry, flatten_firmware_tree, @@ -346,44 +345,18 @@ async def _read_one_message(self, timeout=None): # type: ignore[override] self.assertEqual(raw[0], HoiParams().add(123, I32).build()) def test_get_firmware_tree_uses_cache_and_refresh(self): - class Backend: - def __init__(self): - self._registry = ObjectRegistry() - self._registry.set_root_address(Address(1, 1, 100)) - self._fw_cache = None - self._global_object_addresses = () + registry = ObjectRegistry() + registry.set_root_address(Address(1, 1, 100)) - @property - def registry(self): - return self._registry + async def _unused(*a, **k): + raise RuntimeError("unused in this test") - @property - def global_object_addresses(self): - return self._global_object_addresses - - def get_firmware_tree_cache(self): - return self._fw_cache - - def set_firmware_tree_cache(self, tree): - self._fw_cache = tree - - async def send_command(self, *a, **k): - raise RuntimeError("unused in this test") - - async def send_query(self, *a, **k): - raise RuntimeError("unused in this test") - - async def send_discovery_command(self, *a, **k): - raise RuntimeError("unused in this test") - - async def resolve_path(self, path: str): - raise RuntimeError("unused") - - async def get_supported_interface0_method_ids(self, address: Address): - raise RuntimeError("unused") - - backend = Backend() - intro = HamiltonIntrospection(backend) + 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) @@ -460,7 +433,7 @@ async def test_describe_entry_routes_to_introspection(self): return_value="Simulated" ) - iface_name, desc = await client._hc_result_text.describe_entry(entry) + iface_name, desc = await client._describe_entry(entry) self.assertEqual(iface_name, "ITest") self.assertEqual(desc, "Simulated") @@ -475,7 +448,7 @@ async def test_format_entry_context_uses_method_lookup_from_introspection(self): 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._hc_result_text.format_entry_context(entry) + context = await client._format_entry_context(entry) assert context is not None self.assertIn("path=Root.Channel", context) self.assertIn("DoThing(void) -> void", context) @@ -692,12 +665,11 @@ class _NimbusClient(HamiltonTCPClient): 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] - helper = _HcResultDescriptionHelper(client) entry = HcResultEntry(0x0001, 0x0001, 0x0110, 1, 6, 0x0F4E) - _iface, desc = await helper.describe_entry(entry) + _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 helper.describe_entry(entry_b) + _iface_b, desc_b = await client._describe_entry(entry_b) self.assertIn("No Tip Picked Up", desc_b) @@ -744,34 +716,26 @@ def test_nonzero_ids_are_unique(self): all_nonzero = [tid for row in introspection_mod._HOI_TYPE_ROWS for tid in row.ids if tid != 0] self.assertEqual(len(all_nonzero), len(set(all_nonzero))) - def test_special_name_and_category_overrides(self): - self.assertEqual(introspection_mod.resolve_introspection_type_name(0), "void") - self.assertEqual( - introspection_mod.resolve_introspection_type_name(113), - "List[f32] [In] (empirical)", - ) - self.assertEqual(introspection_mod.get_introspection_type_category(113), "Argument") + def test_empirical_id_113_overridden_to_direction_in(self): + wire_type, direction = introspection_mod._HOI_ID_TO_WIRE[113] + self.assertEqual(wire_type, HamiltonDataType.F32_ARRAY) + self.assertEqual(direction, introspection_mod.Direction.In) - def test_grid_categories_match_directions_with_empirical_exception(self): - empirical_argument_ids = {113} + def test_grid_directions_match_column_order(self): for row in introspection_mod._HOI_TYPE_ROWS: - in_id, out_id, inout_id, retval_id = row.ids - if row.ids == (0, 0, 0, 0): # padding row + if row.ids == (0, 0, 0, 0): continue - for tid in (in_id, out_id, inout_id, retval_id): - if tid == 0: - continue - self.assertNotEqual(introspection_mod.get_introspection_type_category(tid), "Unknown") - - self.assertEqual(introspection_mod.get_introspection_type_category(in_id), "Argument") - self.assertEqual(introspection_mod.get_introspection_type_category(inout_id), "Argument") - self.assertEqual(introspection_mod.get_introspection_type_category(out_id), "ReturnElement") - if retval_id in empirical_argument_ids: - self.assertEqual(introspection_mod.get_introspection_type_category(retval_id), "Argument") - else: - self.assertEqual( - introspection_mod.get_introspection_type_category(retval_id), "ReturnValue" - ) + in_id, out_id, inout_id, retval_id = row.ids + # empirical override for 113 — skip the RetVal column check for that row + if 113 not in row.ids: + _, d = introspection_mod._HOI_ID_TO_WIRE[retval_id] + self.assertEqual(d, introspection_mod.Direction.RetVal) + _, d_in = introspection_mod._HOI_ID_TO_WIRE[in_id] + _, d_out = introspection_mod._HOI_ID_TO_WIRE[out_id] + _, d_inout = introspection_mod._HOI_ID_TO_WIRE[inout_id] + self.assertEqual(d_in, introspection_mod.Direction.In) + self.assertEqual(d_out, introspection_mod.Direction.Out) + self.assertEqual(d_inout, introspection_mod.Direction.InOut) class TestIntrospectionTypeSetsAndClassification(unittest.TestCase): @@ -791,53 +755,64 @@ def test_complex_method_and_struct_sets_are_disjoint(self): ) ) - def test_all_complex_set_is_union_of_method_and_struct_sets(self): - self.assertEqual( - introspection_mod._ALL_COMPLEX_TYPE_IDS, - introspection_mod._COMPLEX_METHOD_TYPE_IDS | introspection_mod._COMPLEX_STRUCT_TYPE_IDS, - ) - - def test_struct_and_enum_reference_sets_are_disjoint(self): - self.assertTrue( - introspection_mod._STRUCT_REF_TYPE_IDS.isdisjoint(introspection_mod._ENUM_REF_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_parameter_type_struct_refs_cover_all_directions_and_struct_sentinels(self): - struct_ids = self._ids_for_flag("is_struct_kind") - for tid in struct_ids + (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY): - pt = ParameterType(tid, source_id=2, ref_id=1) - self.assertTrue(pt.is_complex) - self.assertTrue(pt.is_struct_ref) - self.assertFalse(pt.is_enum_ref) - - def test_parameter_type_enum_refs_cover_all_directions_and_struct_sentinels(self): - enum_ids = self._ids_for_flag("is_enum_kind") - for tid in enum_ids + (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY): - pt = ParameterType(tid, source_id=2, ref_id=1) - self.assertTrue(pt.is_complex) - self.assertTrue(pt.is_enum_ref) - self.assertFalse(pt.is_struct_ref) - - def test_scalar_parameter_type_is_not_complex_or_reference(self): - # i32 In ID — first entry from a non-complex row - scalar_id = next( - row.ids[0] - for row in introspection_mod._HOI_TYPE_ROWS - if not row.is_complex and row.ids[0] != 0 and row.display_name == "i32" - ) - pt = ParameterType(scalar_id) - self.assertFalse(pt.is_complex) + 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 simple] + [struct ref source=2 id=1] + [struct ref source=4 id=9 "01" ] + # [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.type_id for pt in parsed], [1, 57, 57]) + 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)) @@ -847,7 +822,7 @@ def test_parse_struct_field_types_supports_simple_ref_and_node_global(self): 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], [40, 30, 30]) + 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)) @@ -859,51 +834,19 @@ def test_struct_parser_byte_width_sum_matches_cursor_advance(self): self.assertEqual(bytes_used, len(raw)) -class _MinimalIntroBackend: - """Protocol-shaped stub for :class:`HamiltonIntrospection` unit tests.""" - - def __init__(self) -> None: - self._registry = ObjectRegistry() - - @property - def registry(self): - return self._registry - - @property - def global_object_addresses(self): - return () - - def get_firmware_tree_cache(self): - return None - - def set_firmware_tree_cache(self, tree): - del tree - - async def send_command(self, *a, **k): - raise AssertionError("send_command should be patched out in introspection cache tests") - - async def send_query(self, *a, **k): - raise AssertionError("send_query should be patched out in introspection cache tests") - - async def send_discovery_command(self, *a, **k): - raise AssertionError( - "send_discovery_command should be patched out in introspection cache tests" - ) - - async def resolve_path(self, path: str): - del path - raise AssertionError("unused") - - async def get_supported_interface0_method_ids(self, address): - del address - return set() - - class TestHamiltonIntrospectionLazyCaches(unittest.IsolatedAsyncioTestCase): def setUp(self): self.addr = Address(1, 1, 99) - self.backend = _MinimalIntroBackend() - self.intro = HamiltonIntrospection(self.backend) + + 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) @@ -929,7 +872,7 @@ async def test_second_ensure_method_table_skips_get_method(self): async def test_lazy_signature_loads_only_referenced_iface(self): st = StructInfo(struct_id=0, name="TipParams", fields={}, interface_id=1) - pt = ParameterType(57, source_id=2, ref_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] @@ -955,7 +898,7 @@ async def fake_ensure(addr, iface_id): async def test_lazy_signature_matches_full_registry_for_local_struct(self): st = StructInfo(struct_id=0, name="TipParams", fields={}, interface_id=1) - pt = ParameterType(57, source_id=2, ref_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] 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 index 17172835830..2bb6445ea08 100644 --- a/pylabrobot/hamilton/tcp/wire_types.py +++ b/pylabrobot/hamilton/tcp/wire_types.py @@ -57,6 +57,9 @@ class HamiltonDataType(IntEnum): 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 From 72367a211dfbbd5f19e5dad0df8d2b3fca701ee9 Mon Sep 17 00:00:00 2001 From: Cody Moore <46687103+cmoscy@users.noreply.github.com> Date: Fri, 1 May 2026 23:24:18 -0700 Subject: [PATCH 14/14] Introspection: Collapse firmwaretree into firmwarenode. In-progress Nimbus alignment with Prep --- .../liquid_handlers/nimbus/__init__.py | 27 +- .../liquid_handlers/nimbus/chatterbox.py | 76 ++-- .../liquid_handlers/nimbus/commands.py | 376 +++++++++++++++++- .../hamilton/liquid_handlers/nimbus/driver.py | 185 ++------- .../hamilton/liquid_handlers/nimbus/info.py | 6 + .../hamilton/liquid_handlers/nimbus/nimbus.py | 308 ++++++++++++-- .../liquid_handlers/nimbus/pip_backend.py | 28 +- .../nimbus/tests/driver_tests.py | 116 +----- pylabrobot/hamilton/tcp/__init__.py | 4 +- pylabrobot/hamilton/tcp/introspection.py | 39 +- pylabrobot/hamilton/tcp/tcp_tests.py | 11 +- 11 files changed, 780 insertions(+), 396 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py index 2a91a3d4223..dbf60b9b7cb 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/__init__.py @@ -1,10 +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, - NimbusResolvedInterfaces, - NimbusSetupParams, - nimbus_interface_specs_for_root, -) +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/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py index 959084b8b9f..3101ab9733b 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/chatterbox.py @@ -11,11 +11,20 @@ from pylabrobot.hamilton.tcp.packets import Address from .commands import NimbusCommand, _UNRESOLVED -from .door import NimbusDoor -from .driver import NimbusDriver, NimbusResolvedInterfaces, NimbusSetupParams +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. @@ -25,13 +34,10 @@ class NimbusChatterboxDriver(NimbusDriver): """ def __init__(self, num_channels: int = 8): - # Pass dummy host — Socket is created but never opened super().__init__(host="chatterbox", port=2000) self._num_channels = num_channels async def setup(self, backend_params: Optional[BackendParams] = None): - from .pip_backend import NimbusPIPBackend - if backend_params is None: params = NimbusSetupParams() elif isinstance(backend_params, NimbusSetupParams): @@ -41,45 +47,20 @@ async def setup(self, backend_params: Optional[BackendParams] = None): "NimbusChatterboxDriver.setup expected NimbusSetupParams | None for backend_params, " f"got {type(backend_params).__name__}" ) + del params - # Use canned addresses (skip TCP connection entirely) - pipette_address = Address(1, 1, 257) - nimbus_core_address = Address(1, 1, 48896) - self._nimbus_core_address = nimbus_core_address - door_address = Address(1, 1, 268) - self._resolved_interfaces = { - "nimbus_core": nimbus_core_address, - "pipette": pipette_address, - "door_lock": door_address, - } - self._nimbus_resolved = NimbusResolvedInterfaces.from_resolution_map(self._resolved_interfaces) - path_to_addr = { - "NimbusCORE": nimbus_core_address, - "NimbusCORE.Pipette": pipette_address, - "NimbusCORE.DoorLock": door_address, - } - seed_paths = sorted(NimbusCommand._ALL_PATHS | set(path_to_addr)) + # 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 = path_to_addr.get(path, Address(1, 1, 1024 + idx)) + 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), ) - self.pip = NimbusPIPBackend( - driver=self, deck=params.deck, address=pipette_address, num_channels=self._num_channels - ) - self.door = NimbusDoor(driver=self) - if params.require_door_lock and self.door is None: - raise RuntimeError("DoorLock is required but not available on this instrument.") - async def stop(self): - if self.door is not None: - await self.door._on_stop() - self.door = None - self._resolved_interfaces = {} - self._nimbus_resolved = None self._nimbus_core_address = None self._invalidate_introspection_session() @@ -112,17 +93,30 @@ async def send_command( command.dest = addr command.dest_address = addr - # Return canned responses for commands that need them from .commands import ( + ChannelConfiguration, GetChannelConfiguration, - GetChannelConfiguration_1, + IsCoreGripperPlateGripped, + IsCoreGripperToolHeld, IsDoorLocked, IsInitialized, IsTipPresent, + NimbusChannelConfigWire, ) - if isinstance(command, GetChannelConfiguration_1): - return GetChannelConfiguration_1.Response(channels=self._num_channels, channel_types=[]) + 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 IsInitialized.Response(initialized=True) if isinstance(command, IsTipPresent): @@ -131,6 +125,10 @@ async def send_command( return IsDoorLocked.Response(locked=True) if isinstance(command, GetChannelConfiguration): 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 7568884c0f9..812c3e4d24e 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/commands.py @@ -9,7 +9,7 @@ import enum import logging from dataclasses import dataclass, field -from typing import ClassVar, Optional, Set +from typing import Annotated, ClassVar, List, Optional, Set from pylabrobot.hamilton.tcp.commands import TCPCommand @@ -17,11 +17,15 @@ 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, ) @@ -242,17 +246,39 @@ class Response: @dataclass -class GetChannelConfiguration_1(NimbusCommand): - """Get channel configuration (NimbusCore root, interface_id=1, command_id=15).""" +class NimbusChannelConfigWire: + """Wire-format struct for one entry in the ChannelConfiguration[] array (cmd 30). + + 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 = 15 + command_id = 30 firmware_path = "NimbusCORE" action_code = 0 @dataclass class Response: - channels: U16 - channel_types: I16Array + configurations: Annotated[List[NimbusChannelConfigWire], StructArray()] @dataclass @@ -294,6 +320,344 @@ 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).""" diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py index 5a5810533f9..c7494428ba2 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/driver.py @@ -1,97 +1,51 @@ -"""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 dataclasses import dataclass -from typing import Any, Dict, Mapping, Optional, Set +from typing import Any, Optional 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.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.packets import Address from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck -from .commands import ( - GetChannelConfiguration_1, - NimbusCommand, - Park, - _UNRESOLVED, -) -from .door import NimbusDoor -from .pip_backend import NimbusPIPBackend +from .commands import NimbusCommand, _UNRESOLVED logger = logging.getLogger(__name__) _EXPECTED_ROOT = "NimbusCORE" -def nimbus_interface_specs_for_root(root_name: str) -> Dict[str, InterfacePathSpec]: - """Dot-paths under the instrument root (same mechanism as :class:`PrepDriver`).""" - return { - "nimbus_core": InterfacePathSpec(root_name, True, True), - "pipette": InterfacePathSpec(f"{root_name}.Pipette", True, True), - "door_lock": InterfacePathSpec(f"{root_name}.DoorLock", False, False), - } - - -@dataclass(frozen=True) -class NimbusResolvedInterfaces: - """Concrete Nimbus firmware handles after :meth:`NimbusDriver.setup`.""" - - nimbus_core: Address - pipette: Address - door_lock: Optional[Address] - - @staticmethod - def from_resolution_map(m: Mapping[str, Optional[Address]]) -> NimbusResolvedInterfaces: - nc = m.get("nimbus_core") - pip = m.get("pipette") - if nc is None or pip is None: - raise RuntimeError("internal: missing required Nimbus interfaces") - return NimbusResolvedInterfaces( - nimbus_core=nc, - pipette=pip, - door_lock=m.get("door_lock"), - ) - - @dataclass class NimbusSetupParams(BackendParams): deck: Optional[NimbusDeck] = None require_door_lock: bool = False + force_initialize: bool = False 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 - _REQUIRED_METHODS_CORE: Set[int] = { - 3, - 14, - 15, - 29, - } # Park, IsInitialized, GetChannelConfig_1, InitializeSmartRoll - _REQUIRED_METHODS_PIPETTE: Set[int] = { - 4, # PickupTips - 5, # DropTips - 6, # Aspirate - 7, # Dispense - 16, # IsTipPresent - 43, # EnableADC - 44, # DisableADC - 66, # GetChannelConfiguration - 67, # SetChannelConfiguration - 82, # DropTipsRoll - } - def __init__( self, host: str, @@ -111,19 +65,7 @@ def __init__( max_reconnect_attempts=max_reconnect_attempts, connection_timeout=connection_timeout, ) - self._nimbus_core_address: Optional[Address] = None - self._resolved_interfaces: Dict[str, Optional[Address]] = {} - self._nimbus_resolved: Optional[NimbusResolvedInterfaces] = None - - self.pip: NimbusPIPBackend # set in setup() - self.door: Optional[NimbusDoor] = None # set in setup() if available - - @property - def nimbus_interfaces(self) -> NimbusResolvedInterfaces: - if self._nimbus_resolved is None: - raise RuntimeError("Nimbus interfaces not resolved. Call setup() first.") - return self._nimbus_resolved @property def nimbus_core_address(self) -> Address: @@ -132,11 +74,7 @@ def nimbus_core_address(self) -> Address: return self._nimbus_core_address async def setup(self, backend_params: Optional[BackendParams] = None): - """Initialize connection, discover hardware, and create backends. - - Args: - backend_params: Optional :class:`NimbusSetupParams`. - """ + """Open TCP connection, verify firmware root is NimbusCORE, resolve bootstrap handle.""" if backend_params is None: params = NimbusSetupParams() elif isinstance(backend_params, NimbusSetupParams): @@ -146,92 +84,29 @@ async def setup(self, backend_params: Optional[BackendParams] = None): "NimbusDriver.setup expected NimbusSetupParams | None for backend_params, " f"got {type(backend_params).__name__}" ) + del params # consumed by Nimbus / peers, not the transport - # TCP connection + Protocol 7 + Protocol 3 + root discovery await super().setup() - root_objects = self.get_root_object_addresses() - if not root_objects: - raise RuntimeError("No root objects discovered during setup.") - - root_info = await self.introspection.get_object(root_objects[0]) - if root_info.name != _EXPECTED_ROOT: + root = await self._discovered_root_name() + if root != _EXPECTED_ROOT: raise RuntimeError( - f"Expected root '{_EXPECTED_ROOT}' (Nimbus), but discovered '{root_info.name}'. Wrong instrument?" + f"Expected root '{_EXPECTED_ROOT}' (Nimbus), but discovered '{root}'. Wrong instrument?" ) - specs = nimbus_interface_specs_for_root(root_info.name) - self._resolved_interfaces = await resolve_interface_path_specs( - self, specs, instrument_label="Nimbus" - ) - self._nimbus_resolved = NimbusResolvedInterfaces.from_resolution_map(self._resolved_interfaces) - self._nimbus_core_address = self._nimbus_resolved.nimbus_core - - nimbus_core_address = self._nimbus_resolved.nimbus_core - pipette_address = self._nimbus_resolved.pipette - door_address = self._nimbus_resolved.door_lock - - await self._assert_required_methods( - nimbus_core_address, - object_name=root_info.name, - required_method_ids=self._REQUIRED_METHODS_CORE, - ) - await self._assert_required_methods( - pipette_address, - object_name="Pipette", - required_method_ids=self._REQUIRED_METHODS_PIPETTE, - ) - - # Query channel configuration - config = await self.send_command(GetChannelConfiguration_1()) - assert config is not None, "GetChannelConfiguration_1 command returned None" - num_channels = config.channels - logger.info(f"Channel configuration: {num_channels} channels") - - # Create backends — each object stores its own address and state - self.pip = NimbusPIPBackend( - driver=self, deck=params.deck, address=pipette_address, num_channels=num_channels - ) + self._nimbus_core_address = await self.resolve_path("NimbusCORE") - if door_address is not None: - self.door = NimbusDoor(driver=self) - elif params.require_door_lock: - raise RuntimeError("DoorLock is required but not available on this instrument.") - - # Initialize subsystems - if self.door is not None: - await self.door._on_setup() - - async def stop(self): - """Stop driver and close connection.""" - if self.door is not None: - await self.door._on_stop() + async def stop(self) -> None: + """Close connection and clear cached addresses.""" await super().stop() - self.door = None - self._resolved_interfaces = {} - self._nimbus_resolved = None self._nimbus_core_address = None - async def _assert_required_methods( - self, - address: Address, - *, - object_name: str, - required_method_ids: Set[int], - ) -> None: - methods = await self.introspection.methods_for_interface(address, interface_id=1) - available = {m.method_id for m in methods} - missing = sorted(required_method_ids - available) - if missing: - raise RuntimeError( - f"{object_name} is missing required interface-1 methods: {missing}. " - "Firmware is incompatible with Nimbus v1 backend requirements." - ) - - async def park(self): - """Park the instrument.""" - await self.send_command(Park()) - logger.info("Instrument parked successfully") + 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 async def _send_raw( self, diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/info.py b/pylabrobot/hamilton/liquid_handlers/nimbus/info.py index bdb6c93bc6e..f53f16c9e45 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/info.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/info.py @@ -10,6 +10,8 @@ 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: @@ -52,3 +54,7 @@ async def is_initialized(self) -> bool: 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 c03ee1901f8..41329f9ec80 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/nimbus.py @@ -1,21 +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 .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__( @@ -34,71 +46,285 @@ def __init__( 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, backend_params: Optional[BackendParams] = None): - """Initialize the Nimbus instrument. - - Establishes the TCP connection, discovers hardware objects, queries channel - configuration and tip presence, locks the door (if available), conditionally - runs InitializeSmartRoll, and wires the PIP capability frontend to the driver's - PIP backend. - """ + def _normalize_setup_params(self, backend_params: Optional[BackendParams]) -> NimbusSetupParams: if backend_params is None: - params = NimbusSetupParams(deck=self.deck) - elif isinstance(backend_params, NimbusSetupParams): - params = backend_params - if params.deck is None: - params = NimbusSetupParams(deck=self.deck, require_door_lock=params.require_door_lock) - else: - raise TypeError( - "Nimbus.setup expected NimbusSetupParams | None for backend_params, " - f"got {type(backend_params).__name__}" - ) + 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__}" + ) + 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(backend_params=params) + await self.info._on_setup() + await self._initialize_instrument(params) - self.pip = PIP(backend=self.driver.pip) + 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 + + 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") - Stops all capabilities in reverse order and closes the driver connection. - """ + 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 c8a5771250c..ff5ddbbadc3 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/pip_backend.py @@ -20,6 +20,7 @@ from pylabrobot.resources.hamilton import HamiltonTip, TipSize from pylabrobot.resources.trash import Trash +from .channels import ChannelType, NimbusChannelMap from .commands import ( Aspirate, DisableADC, @@ -29,7 +30,6 @@ EnableADC, GetChannelConfiguration, InitializeSmartRoll, - IsInitialized, IsTipPresent, PickupTips, SetChannelConfiguration, @@ -42,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") @@ -179,12 +186,14 @@ def __init__( 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 @@ -204,17 +213,7 @@ def _ensure_deck(self) -> "NimbusDeck": return self.deck async def _on_setup(self, backend_params: Optional[BackendParams] = None): - """Initialize SmartRoll if not already initialized.""" - del backend_params - # Query initialization status - init_status = await self.driver.send_command(IsInitialized()) - assert init_status is not None - is_initialized = init_status.initialized - - if not is_initialized: - await self._initialize_smart_roll() - else: - logger.info("Instrument already initialized, skipping SmartRoll init") + pass async def _on_stop(self): pass @@ -426,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( diff --git a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py index f785cf15977..ce3d6457980 100644 --- a/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/nimbus/tests/driver_tests.py @@ -1,22 +1,17 @@ import asyncio from dataclasses import dataclass -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest from pylabrobot.hamilton.liquid_handlers.nimbus.chatterbox import NimbusChatterboxDriver from pylabrobot.hamilton.liquid_handlers.nimbus.commands import ( - GetChannelConfiguration_1, + ChannelConfiguration, NimbusCommand, Park, _UNRESOLVED, ) -from pylabrobot.hamilton.liquid_handlers.nimbus.driver import ( - NimbusDriver, - NimbusResolvedInterfaces, - nimbus_interface_specs_for_root, -) -from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs +from pylabrobot.hamilton.liquid_handlers.nimbus.driver import NimbusDriver from pylabrobot.hamilton.tcp.packets import Address @@ -26,13 +21,10 @@ async def _run() -> None: await driver.setup() assert driver.nimbus_core_address == Address(1, 1, 48896) - assert driver.door is not None - response = await driver.send_command( - GetChannelConfiguration_1(), - read_timeout=0.1, - ) - assert response.channels == 8 + 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() @@ -43,7 +35,7 @@ def test_chatterbox_jit_resolves_dest_after_send(): async def _run() -> None: driver = NimbusChatterboxDriver(num_channels=8) await driver.setup() - cmd = GetChannelConfiguration_1() + cmd = ChannelConfiguration() assert cmd.dest_address == _UNRESOLVED await driver.send_command(cmd) assert cmd.dest == driver.nimbus_core_address @@ -81,33 +73,6 @@ async def _run() -> None: asyncio.run(_run()) -def test_assert_required_methods_missing_raises(): - async def _run() -> None: - driver = NimbusDriver(host="127.0.0.1") - - class _Method: - def __init__(self, method_id: int): - self.method_id = method_id - - class _StubIntro: - async def methods_for_interface(self, address, interface_id): # noqa: ARG002 - return [_Method(3)] - - with patch.object( - driver.introspection, - "methods_for_interface", - AsyncMock(return_value=[_Method(3)]), - ): - with pytest.raises(RuntimeError, match="missing required interface-1 methods"): - await driver._assert_required_methods( - Address(1, 1, 48896), - object_name="NimbusCore", - required_method_ids={3, 15}, - ) - - 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") @@ -115,64 +80,15 @@ def test_nimbus_core_address_raises_before_setup(): _ = driver.nimbus_core_address -def test_nimbus_interface_specs_for_root_paths(): - """Root-relative dot-paths match firmware tree naming (e.g. NimbusCORE.Pipette).""" - s = nimbus_interface_specs_for_root("NimbusCORE") - assert s["nimbus_core"].path == "NimbusCORE" - assert s["pipette"].path == "NimbusCORE.Pipette" - assert s["door_lock"].path == "NimbusCORE.DoorLock" - assert s["door_lock"].required is False - - -def test_nimbus_resolved_interfaces_from_map_optional_door(): - core = Address(1, 1, 100) - pip = Address(1, 1, 200) - r = NimbusResolvedInterfaces.from_resolution_map( - {"nimbus_core": core, "pipette": pip, "door_lock": None} - ) - assert r.nimbus_core == core - assert r.pipette == pip - assert r.door_lock is None - - -def test_resolve_interface_path_specs_required_missing_raises(): +def test_chatterbox_channel_configuration_returns_correct_types(): async def _run() -> None: - from unittest.mock import AsyncMock - - from pylabrobot.hamilton.tcp.client import HamiltonTCPClient - - tcp = HamiltonTCPClient(host="127.0.0.1", port=2000) - tcp.resolve_path = AsyncMock(side_effect=KeyError) # type: ignore[method-assign] - with pytest.raises(RuntimeError, match="nimbus_core"): - await resolve_interface_path_specs( - tcp, - {"nimbus_core": InterfacePathSpec("NimbusCORE", True)}, - instrument_label="Nimbus", - ) - - asyncio.run(_run()) - - -def test_assert_required_methods_succeeds_when_all_present(): - """Complements test_assert_required_methods_missing_raises: no false positive when the set is satisfied.""" - - async def _run() -> None: - driver = NimbusDriver(host="127.0.0.1") - - class _Method: - def __init__(self, method_id: int): - self.method_id = method_id - - methods = [_Method(3), _Method(15)] - with patch.object( - driver.introspection, - "methods_for_interface", - AsyncMock(return_value=methods), - ): - await driver._assert_required_methods( - Address(1, 1, 48896), - object_name="NimbusCore", - required_method_ids={3, 15}, - ) + 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/tcp/__init__.py b/pylabrobot/hamilton/tcp/__init__.py index 0ed2f0d703c..6b04b576391 100644 --- a/pylabrobot/hamilton/tcp/__init__.py +++ b/pylabrobot/hamilton/tcp/__init__.py @@ -11,7 +11,7 @@ from pylabrobot.hamilton.tcp.interface_bundle import InterfacePathSpec, resolve_interface_path_specs from pylabrobot.hamilton.tcp.introspection import ( Direction, - FirmwareTree, + FirmwareTreeNode, MethodInfo, MethodParamType, ObjectInfo, @@ -56,7 +56,7 @@ "CommandResponse", "ConnectionPacket", "Direction", - "FirmwareTree", + "FirmwareTreeNode", "MethodParamType", "StructFieldType", "flatten_firmware_tree", diff --git a/pylabrobot/hamilton/tcp/introspection.py b/pylabrobot/hamilton/tcp/introspection.py index e2208b20b9c..f4fcc8865d8 100644 --- a/pylabrobot/hamilton/tcp/introspection.py +++ b/pylabrobot/hamilton/tcp/introspection.py @@ -309,39 +309,22 @@ def format_lines( return lines def __str__(self) -> str: - return "\n".join(self.format_lines()) + return "\n".join(self.format_lines(is_root=True)) -@dataclass -class FirmwareTree: - """Structured firmware tree produced by introspection traversal. - - Both Prep and Nimbus expose exactly one root object; the single-root - invariant is enforced at discovery time in the TCP client. - """ - - root: FirmwareTreeNode - - def format(self) -> str: - return "\n".join(self.root.format_lines(prefix="", is_last=True, is_root=True)) - - def __str__(self) -> str: - return self.format() - - -def flatten_firmware_tree(tree: FirmwareTree) -> List[Tuple[str, Address, ObjectInfo]]: - """Preorder flattening of :class:`FirmwareTree` for path-keyed lookups. +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(node: FirmwareTreeNode) -> None: - out.append((node.path, node.address, node.object_info)) - for child in node.children: + def walk(n: FirmwareTreeNode) -> None: + out.append((n.path, n.address, n.object_info)) + for child in n.children: walk(child) - walk(tree.root) + walk(node) return out @@ -1241,7 +1224,7 @@ def __init__( 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[FirmwareTree] = 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.""" @@ -1592,7 +1575,7 @@ async def resolve_path(self, path: str) -> Address: return current_addr - async def _build_firmware_tree(self) -> FirmwareTree: + 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: @@ -1602,9 +1585,9 @@ async def _build_firmware_tree(self) -> FirmwareTree: 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 FirmwareTree(root=node) + return node - async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTree: + 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 diff --git a/pylabrobot/hamilton/tcp/tcp_tests.py b/pylabrobot/hamilton/tcp/tcp_tests.py index ee32288a0e8..38cb0b7a628 100644 --- a/pylabrobot/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/hamilton/tcp/tcp_tests.py @@ -28,7 +28,6 @@ ) from pylabrobot.hamilton.tcp.introspection import ( EnumInfo, - FirmwareTree, FirmwareTreeNode, GlobalTypePool, HamiltonIntrospection, @@ -385,8 +384,8 @@ async def fake_get_subobject_address(_addr: Address, idx: int) -> Address: self.assertIs(t1, t2) self.assertIsNot(t1, t3) - self.assertEqual(t1.root.path, "Root") - self.assertEqual(len(t1.root.children), 1) + 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) @@ -401,8 +400,7 @@ def test_flatten_firmware_tree_preorder(self): 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]) - tree = FirmwareTree(root=root) - flat = flatten_firmware_tree(tree) + 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): @@ -410,11 +408,10 @@ def test_get_firmware_tree_flat_delegates_to_flatten(self): 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=[]) - tree = FirmwareTree(root=root) async def fake_get_firmware_tree(refresh: bool = False): del refresh - return tree + return root client.introspection.get_firmware_tree = fake_get_firmware_tree # type: ignore[method-assign] got = asyncio.run(client.introspection.get_firmware_tree_flat())