From fe3c00abd069d8f77462e9e9360e93a4bfe675f6 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:43:56 -0700 Subject: [PATCH 1/2] feat(hamilton): port full TCP transport client onto transport/tcp Replace the thin post-#1000 stub with the HOI/HARP session client, command layer, wire types, and introspection stack so Prep/Nimbus can build on it. --- pylabrobot/hamilton/transport/tcp/__init__.py | 33 +- pylabrobot/hamilton/transport/tcp/commands.py | 158 +- .../hamilton/transport/tcp/error_tables.py | 2401 +++++++++++++++++ .../hamilton/transport/tcp/hoi_error.py | 208 ++ .../transport/tcp/interface_bundle.py | 70 + .../hamilton/transport/tcp/introspection.py | 2113 ++++++++++++--- pylabrobot/hamilton/transport/tcp/messages.py | 717 ++--- pylabrobot/hamilton/transport/tcp/packets.py | 18 +- pylabrobot/hamilton/transport/tcp/protocol.py | 50 +- pylabrobot/hamilton/transport/tcp/tcp.py | 783 +++--- .../hamilton/transport/tcp/tests/tcp_tests.py | 966 +++++++ .../hamilton/transport/tcp/wire_types.py | 393 +++ 12 files changed, 6692 insertions(+), 1218 deletions(-) create mode 100644 pylabrobot/hamilton/transport/tcp/error_tables.py create mode 100644 pylabrobot/hamilton/transport/tcp/hoi_error.py create mode 100644 pylabrobot/hamilton/transport/tcp/interface_bundle.py create mode 100644 pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py create mode 100644 pylabrobot/hamilton/transport/tcp/wire_types.py diff --git a/pylabrobot/hamilton/transport/tcp/__init__.py b/pylabrobot/hamilton/transport/tcp/__init__.py index 78f60160c79..ad99ec6a6c7 100644 --- a/pylabrobot/hamilton/transport/tcp/__init__.py +++ b/pylabrobot/hamilton/transport/tcp/__init__.py @@ -1,24 +1,13 @@ """Shared Hamilton TCP protocol layer for TCP-based instruments (Nimbus, Prep, etc.).""" -from pylabrobot.hamilton.transport.tcp.commands import HamiltonCommand -from pylabrobot.hamilton.transport.tcp.introspection import HamiltonIntrospection -from pylabrobot.hamilton.transport.tcp.messages import ( - CommandMessage, - CommandResponse, - HoiParams, - HoiParamsParser, - InitMessage, - InitResponse, - RegistrationMessage, - RegistrationResponse, -) -from pylabrobot.hamilton.transport.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket -from pylabrobot.hamilton.transport.tcp.protocol import ( - HamiltonDataType, - HamiltonProtocol, - HarpTransportableProtocol, - Hoi2Action, - HoiRequestId, - RegistrationActionCode, - RegistrationOptionType, -) +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.hoi_error import HoiError +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.tcp import HamiltonTCPClient + +__all__ = [ + "Address", + "HamiltonTCPClient", + "HoiError", + "TCPCommand", +] diff --git a/pylabrobot/hamilton/transport/tcp/commands.py b/pylabrobot/hamilton/transport/tcp/commands.py index 0b8cd8c025c..953659120ff 100644 --- a/pylabrobot/hamilton/transport/tcp/commands.py +++ b/pylabrobot/hamilton/transport/tcp/commands.py @@ -1,50 +1,50 @@ -"""Hamilton command architecture using new simplified TCP stack. +"""Command layer for Hamilton TCP. -This module provides the HamiltonCommand base class that uses the new refactored -architecture: Wire -> HoiParams -> Packets -> Messages -> Commands. +TCPCommand base: build_parameters() returns HoiParams; interpret_response() +auto-decodes success responses via nested Response dataclasses (wire-type +annotations and parse_into_struct). Wire → HoiParams → Packets → Messages → Commands. """ from __future__ import annotations import inspect -from typing import Optional +from dataclasses import fields, is_dataclass +from typing import Any, Optional from pylabrobot.hamilton.transport.tcp.messages import ( CommandMessage, CommandResponse, HoiParams, + interpret_hoi_success_payload, + log_hoi_result_entries, + split_hoi_params_after_warning_prefix, ) from pylabrobot.hamilton.transport.tcp.packets import Address from pylabrobot.hamilton.transport.tcp.protocol import HamiltonProtocol +from pylabrobot.hamilton.transport.tcp.wire_types import HcResultEntry -class HamiltonCommand: - """Base class for Hamilton commands using new simplified architecture. +class TCPCommand: + """Base class for Hamilton TCP commands. - This replaces the old HamiltonCommand from tcp_codec.py with a cleaner design: - - Explicitly uses CommandMessage for building packets - - build_parameters() returns HoiParams object (not bytes) - - Uses Address instead of ObjectAddress - - Cleaner separation of concerns + Preferred usage: define commands as ``@dataclass`` subclasses with + ``Annotated`` wire-type fields. ``build_parameters()`` and + ``interpret_response()`` are handled automatically by the base class. - Example: - class MyCommand(HamiltonCommand): + Example:: + + @dataclass + class MyCommand(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 command_id = 42 - def __init__(self, dest: Address, value: int): - super().__init__(dest) - self.value = value - - def build_parameters(self) -> HoiParams: - return HoiParams().i32(self.value) + dest: Address # infrastructure field — not serialised + value: Annotated[int, I32] # wire field — serialised in order - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - parser = HoiParamsParser(data) - _, result = parser.parse_next() - return {'result': result} + @dataclass + class Response: + result: Annotated[int, U32] """ # Class-level attributes that subclasses must override @@ -58,7 +58,7 @@ def parse_response_parameters(cls, data: bytes) -> dict: ip_protocol: int = 6 # Default: OBJECT_DISCOVERY def __init__(self, dest: Address): - """Initialize Hamilton command. + """Initialize TCP command. Args: dest: Destination address for this command @@ -78,12 +78,17 @@ def __init__(self, dest: Address): def build_parameters(self) -> HoiParams: """Build HOI parameters for this command. - Override this method in subclasses to provide command-specific parameters. - Return a HoiParams object (not bytes!). + Default: serializes all ``Annotated`` wire-type fields on ``self`` via + ``HoiParams.from_struct``. On non-dataclass subclasses ``from_struct`` + finds no fields and returns an empty ``HoiParams``, preserving the old + behaviour. Override only when the wire layout cannot be expressed with + ``Annotated`` field declarations. Returns: HoiParams object with command parameters """ + if is_dataclass(self): + return HoiParams.from_struct(self) return HoiParams() def get_log_params(self) -> dict: @@ -150,19 +155,84 @@ def build( # Build final packet return msg.build(source, sequence, harp_response_required=response_required) - def interpret_response(self, response: CommandResponse) -> Optional[dict]: - """Interpret success response. + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map a ``HcResultEntry`` to a 0-indexed PLR channel, or ``None`` to skip. - This is the new interface used by the backend. Default implementation - directly calls parse_response_parameters for efficiency. + Default: the entry's position in the HoiResult — firmware populates arrays + in active-channel order. ``NimbusCommand`` / ``PrepCommand`` override this + to translate the active-channel ordinal into the caller's 0-indexed channel + via ``channels_involved`` bitmask or per-channel struct-array reflection. + """ + return entry_index - Args: - response: CommandResponse from network + def error_entries_use_physical_channels(self) -> bool: + """Whether ``STATUS_EXCEPTION`` entries should be mapped to PLR channel indices. - Returns: - Dictionary with parsed response data, or None if no data to extract + Returns ``True`` when the command carries per-channel wire parameters: + Prep ``StructArray`` elements with a ``channel`` field, or Nimbus + ``channels_involved`` parallel arrays. Void MLPrep / status queries return + ``False`` so the client raises :class:`~pylabrobot.hamilton.transport.tcp.hoi_error.HoiError` + instead of attributing errors to synthetic ``ch0``. """ - return self.parse_response_parameters(response.hoi.params) + if not is_dataclass(self): + return False + for f in fields(self): + if f.name == "channels_involved": + return True + value = getattr(self, f.name, None) + if isinstance(value, list) and value: + if getattr(value[0], "channel", None) is not None: + return True + return False + + def interpret_response(self, response: CommandResponse) -> Any: + """Pure decoder for a success response — never raises on channel errors. + + For ``STATUS_WARNING`` / ``COMMAND_WARNING`` frames, strips the leading + summary + formatted-string prefix (per ``SystemController.SendAndReceive``) + and logs entries parsed via ``HoiDecoder2.GetHcResults``. For plain + ``STATUS_RESPONSE`` / ``COMMAND_RESPONSE`` frames, decodes the Response + dataclass directly — the firmware emits exactly the fields declared in + the interface yaml, with no HoiResult trailer. HoiResult only rides on + warning (prefix) or exception (separate payload, handled in + ``send_command``) frames. + + Fatal (non-success, non-warning) entries from a warning frame surface + through ``fatal_entries_by_channel`` and are lifted into a + ``ChannelizedError`` by the backend — this decoder stays pure. + """ + eff, _prefix = self._strip_warning_prefix(response) + return interpret_hoi_success_payload(self, eff) + + def fatal_entries_by_channel(self, response: CommandResponse) -> dict[int, HcResultEntry]: + """Return fatal entries keyed by 0-indexed PLR channel. + + Only non-success, non-warning entries from a warning-frame prefix are + included; warnings remain log-only. Exception frames are handled + separately in ``send_command`` via :func:`~pylabrobot.hamilton.transport.tcp.hoi_error.parse_hamilton_error_entry`. + + ``entry_index`` passed to ``_channel_index_for_entry`` is the position of + the entry in the *original* entries list (i.e. active-channel ordinal), + not among fatal entries only — so bitmask / struct-array overrides can + map ordinal → channel correctly even when earlier channels warned. + """ + _eff, prefix_entries = self._strip_warning_prefix(response) + per_channel: dict[int, HcResultEntry] = {} + for i, entry in enumerate(prefix_entries): + if entry.is_success: + continue + ch = self._channel_index_for_entry(i, entry) + if ch is None: + continue + per_channel[ch] = entry + return per_channel + + def _strip_warning_prefix(self, response: CommandResponse) -> tuple[bytes, list[HcResultEntry]]: + """Strip the warning-frame HoiResult prefix, if present. Logs entries.""" + raw = response.hoi.params + eff, prefix_entries = split_hoi_params_after_warning_prefix(response.hoi.action_code, raw) + log_hoi_result_entries(type(self).__name__, prefix_entries, source="HOI prefix") + return eff, prefix_entries @classmethod def parse_response_parameters(cls, data: bytes) -> Optional[dict]: @@ -177,3 +247,19 @@ def parse_response_parameters(cls, data: bytes) -> Optional[dict]: Dictionary with parsed response data, or None if no data to extract """ return None + + +def hamilton_error_for_entry(entry: HcResultEntry, description: str) -> Exception: + """Wrap an ``HcResultEntry`` in a ``RuntimeError`` using a pre-resolved description. + + ``description`` is sourced from the device itself via Interface 0 method 5 + (``EnumInfo``) — see ``HamiltonTCPClient._describe_entry``. The returned + exception has ``.entry`` attached so callers can dispatch on + ``entry.result`` / ``entry.interface_id`` / ``entry.address``. + """ + err = RuntimeError( + f"{description} (HcResult=0x{entry.result:04X}) " + f"at {entry.address} iface={entry.interface_id} action={entry.action_id}" + ) + err.entry = entry # type: ignore[attr-defined] + return err diff --git a/pylabrobot/hamilton/transport/tcp/error_tables.py b/pylabrobot/hamilton/transport/tcp/error_tables.py new file mode 100644 index 00000000000..f7a50da856d --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/error_tables.py @@ -0,0 +1,2401 @@ +"""Hamilton error-code tables. + +Generated by ``_generate_error_tables.py`` from firmware reference exports. +Do not edit by hand — regenerate with:: + + python -m pylabrobot.hamilton.transport.tcp._generate_error_tables + +Tables +------ +- ``HC_RESULT_PROTOCOL`` : ``{code: enum_name}``. Protocol-level universal + result codes that apply to any module. ~200 entries in the range 0–1069. +- ``NIMBUS_ERROR_CODES`` : ``{(module_id, node_id, object_id, interface_id, code): + text}``. Module-scoped text registered by ``NimbusCORESystem`` and + ``GripperControllerSystem`` ``AddErrorData`` calls at runtime. Codes in this + table start at 0x0F01 (3841) — the module-specific range. +- ``PREP_ERROR_CODES`` : Prep-specific codes (pipettor and MPH). Module-specific + range starting at 0x0F01. +""" + +from __future__ import annotations + +from typing import Dict, Tuple + +HC_RESULT_PROTOCOL: Dict[int, str] = { + 0: "Success", + 1: "GenericError", + 2: "GenericNotReady", + 3: "GenericNullParameter", + 4: "GenericCalledByInitHandler", + 5: "GenericInvalidData", + 6: "GenericOutOfMemory", + 7: "GenericWriteFault", + 8: "GenericReadFault", + 9: "GenericBufferOverflow", + 10: "GenericNotInitialized", + 11: "GenericAlreadyInitialized", + 12: "GenericWaitAborted", + 13: "GenericTimeOut", + 14: "GenericMissingCallBack", + 15: "GenericInvalidHandle", + 16: "GenericNotSupported", + 17: "GenericInvalidParameter", + 18: "GenericNotImplemented", + 19: "GenericBadCrc", + 20: "GenericFlashNotBlank", + 21: "GenericMultipleErrorsReported", + 22: "GenericCoordinatedCommandTimeout", + 23: "GenericAccessDenied", + 25: "GenericBusy", + 26: "GenericMethodObsolete", + 27: "GenericNotConfigured", + 257: "KernelMutexTimeout", + 258: "KernelSemaphoreTimeout", + 259: "KernelEventTimeout", + 260: "KernelNoMutex", + 261: "KernelMutexNotOwned", + 262: "KernelNoWaitingTask", + 263: "KernelInvalidTask", + 264: "KernelNoTaskControlBlock", + 513: "NetworkUndefinedProtocol", + 514: "NetworkNoDestination", + 515: "NetworkRegistrationError", + 516: "NetworkNotRegistered", + 517: "NetworkBusy", + 518: "NetworkInvalidDispatchID", + 519: "NetworkInvalidMessage", + 520: "NetworkUnsupportedParameter", + 521: "NetworkCommandCompleteNotValid", + 522: "NetworkInvalidMessageParameter", + 523: "NetworkIncompatibleProtocolVersion", + 524: "NetworkInvalidNodeId", + 525: "NetworkInvalidModuleId", + 526: "NetworkInvalidInterfaceId", + 527: "NetworkInvalidAction", + 528: "NetworkProxySendAttemptFailed", + 529: "NetworkRegistrationFailedDuplicateAddress", + 530: "NetworkUnableToProperlyFillOutResults", + 531: "NetworkDuplicateEventRegistration", + 532: "NetworkEventRegistrationExceedsMaximumAllowedSubscribers", + 533: "NetworkMaximumNodeToNodeEventRegistrationsExceeded", + 534: "NetworkMaximumNodeToNodeEventHandlerRegistrationsExceeded", + 535: "NetworkUnsupportedHarpPayloadProtocol", + 769: "XPortSlOsPortNotInstalled", + 770: "XPortSlIpTaskPriorityNotSet", + 771: "XPortSlTimerTaskPriorityNotSet", + 772: "XPortSlDriverNotSet", + 773: "XPortSlIpAddressNotSet", + 774: "XPortSlNetMaskNotSet", + 775: "XPortSlCmxInitFailure", + 776: "XPortSlMacAddressNotSet", + 777: "XPortSlHostNameTooShort", + 778: "XPortSlNostNameTooLong", + 779: "XPortSlHostNameInvalidChars", + 800: "XPortNxpLpc2xxxCanInvalidChannel", + 801: "XPortNxpLpc2xxxCanInvalidGroup", + 802: "XPortNxpLpc2xxxCanBitRate", + 803: "XPortNxpLpc2xxxCanRxInterruptInstall", + 804: "XPortNxpLpc2xxxCanRxInterruptRemove", + 805: "XPortNxpLpc2xxxCanTxInterruptInstall", + 806: "XPortNxpLpc2xxxCanTxInterruptRemove", + 807: "XPortNxpLpc2xxxCanTxInvalidLength", + 808: "XPortNxpLpc2xxxCanTxBusy", + 809: "XPortArcNetAlreadyConfigured", + 810: "XPortArcNetNotConfigured", + 811: "XPortArcNetInterruptInstallFailed", + 812: "XPortArcNetTxNoAck", + 813: "XPortArcNetDiagnosticTestFailed", + 814: "XPortArcNetNodeIdTestFailed", + 815: "XPortArcNetInvalidNodeId", + 816: "XPortArcNetTxNotAvailable", + 817: "XPortArcNetInvalidDataRate", + 818: "XPortArcNetInvalidPacketLength", + 819: "XPortArcNetSingleNodeNetwork", + 820: "XPortArcNetNoResponseToFbe", + 833: "XPortProtocolMismatch", + 834: "XPortPacketRouterNotRegistered", + 835: "XPortCouldNotStartPacketRouterRxThread", + 836: "XPortPacketRouterAlreadyRegistered", + 837: "XPortNoPacketToProcess", + 838: "XPortWireProtocolNotRegistered", + 839: "XPortWireProtocolAlreadyRegistered", + 840: "XPortWireProtocolRegistrationSpaceFull", + 841: "XPortPayloadProtocolNotRegistered", + 842: "XPortPayloadProtocolAlreadyRegistered", + 843: "XPortPayloadRegistrationSpaceFull", + 844: "XPortAddressNotSet", + 845: "XPortAttemptToSendToSelf", + 846: "XPortTxTimeout", + 847: "XPortRxDuplicateFrame", + 864: "XPortCanWp0VersionConflict", + 865: "XPortCanExcessivePacketSize", + 866: "XPortCanWp0AckHasNoMatchingPacket", + 867: "XPortCanWp0WrapperOnlyOneAddressSupported", + 868: "XPortCanWp0ErrorStartRefused", + 869: "XPortCanWp0ErrorBufferOverrun", + 870: "XPortCanWp0InvalidFrame", + 871: "XPortCanWp0StrayDataFrame", + 872: "XPortCanWp0ShortMessage", + 873: "XPortCanWp0LongMessage", + 874: "XPortCanWp0UnknownError", + 875: "XPortCanWp0NoResponseFromDestination", + 876: "XPortCanWp0SendError", + 877: "XPortCanWbzUnknownFrame", + 878: "XPortCanWbzUnsolicitedRemoteFrame", + 879: "XPortCanWbzUnsolicitedDataFrame", + 880: "XPortCanWbzWrapperOnlyOneAddressSupported", + 881: "XPortCanWp0LastMessageFailed", + 896: "XPortIpStackConfigurationFailure", + 897: "XPortIpStackNotConfigured", + 898: "XPortSocketCreationFailure", + 899: "XPortSocketConfigFailure", + 900: "XPortSocketBindFailure", + 901: "XPortIpTaskAlreadyStarted", + 902: "XPortIpTaskNotStarted", + 903: "XPortTcpListenFailure", + 904: "XPortTcpClientAlreadyConnected", + 905: "XPortTcpClientNotConnected", + 906: "XPortTcpConnectionFailure", + 907: "XPortTcpCloseFailure", + 908: "XPortTcpSendError", + 909: "XPortUdpSendError", + 910: "XPortMalformedDiscoveryRequest", + 911: "XPortIpDhcpFailed", + 912: "XPortIpStaticAddressConfigFailed", + 928: "XPortArcNetBufferOverrun", + 929: "XPortArcNetVersionConflict", + 930: "XPortArcNetInvalidFrameType", + 931: "XPortArcNetInvalidFrame", + 932: "XPortArcNetUnknownError", + 933: "XPortArcNetAckHasNoMatchingPacket", + 934: "XPortArcNetInvalidMessageSize", + 935: "XPortArcNetLastMessageFailed", + 936: "XPortArcNetWp0RefusedSyn", + 937: "XPortArcNetWp0MessageTooShort", + 938: "XPortArcNetWp0MessageTooLong", + 939: "XPortArcNetWp0InvalidSequenceNumber", + 940: "XPortArcNetWp0NoResponseFromDestination", + 1024: "ComLinkReferToInnerException", + 1025: "ComLinkNotConnected", + 1026: "ComLinkTcpConnectionFailed", + 1027: "ComLinkFailedToCloseConnectionProperly", + 1028: "ComLinkInvalidProtocolVersion", + 1029: "ComLinkUnsupportedOptionsDetectedByServer", + 1030: "ComLinkNodeIdNegotiationFailure", + 1031: "ComLinkConnectionIntentError", + 1032: "ComLinkUnableToConfigureKeepAlive", + 1033: "ComLinkFailedToSendConnectionPacket", + 1034: "ComLinkInvalidRegistrationAction", + 1035: "ComLinkUnexpectedRequestedHarpAddressReturned", + 1036: "ComLinkHarpAddressRegistrationFailed", + 1037: "ComLinkHarpAddressDeregistrationFailed", + 1038: "ComLinkIdentificationNotImplemented", + 1039: "ComLinkIdentificationNotSupported", + 1040: "ComLinkFailedToSendIdentificationRequest", + 1041: "ComLinkNoResponseFromInstrumentRegistrationServer", + 1042: "ComLinkNoRootObjectFound", + 1043: "ComLinkEthernetObjectNotFound", + 1044: "ComLinkMethodNotFound", + 1045: "ComLinkProtocolActionConversionFailed", + 1046: "ComLinkTimeout", + 1047: "ComLinkUnableToSendOrReceive", + 1048: "ComLinkTransportTransportableIntroductionFailure", + 1049: "ComLinkHarpHarpableIntroductionFailure", + 1050: "ComLinkDownloadException", + 1051: "ComLinkSizeOfReturnParametersNotValid", + 1052: "ComLinkRestrictedMethod", + 1053: "ComLinkInvalidNumberOfStructureParametersFromNetworkLayer", + 1054: "ComLinkInvalidTypeInStructureFromNetworkLayer", + 1055: "ComLinkRs232ConnectionFailed", + 1056: "ComLinkRs232InvalidPort", + 1057: "ComLinkLoggingCannotBeConfiguredWhileConnectedOrConnecting", + 1058: "ComLinkThreadAbortExceptionDetected", + 1059: "ComLinkUnableToSend", + 1060: "ComLinkUnableToReceive", + 1061: "ComLinkConnectionRequiredToProceed", + 1062: "ComLinkTooMuchDataToSend", + 1063: "ComLinkCanConfigurationFailure", + 1064: "ComLinkUnableToRetrieveListOfModules", + 1065: "ComLinkTcpConnectionFailedConnectionRefused", + 1066: "ComLinkTcpConnectionFailedHostUnreachable", + 1067: "ComLinkTcpConnectionFailedHostNotFound", + 1068: "ComLinkTcpConnectionFailedTimedOut", + 1069: "ComLinkTcpConnectionFailedIsConnected", + 32792: "GenericMultipleWarningsReported", +} + +NIMBUS_ERROR_CODES: Dict[Tuple[int, int, int, int, int], str] = { + (0x0001, 0x0001, 0x0101, 1, 0x0F01): "Invalid tips specified.", + (0x0001, 0x0001, 0x0101, 1, 0x0F02): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x0101, 1, 0x0F03): "Gripper tool not installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F04): "Plate is in the gripper.", + (0x0001, 0x0001, 0x0101, 1, 0x0F05): "No plate in the gripper.", + (0x0001, 0x0001, 0x0101, 1, 0x0F06): "Invalid tip type.", + (0x0001, 0x0001, 0x0101, 1, 0x0F07): "Tip not installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F08): "Tip already installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F09): "Gripper tool not installed.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0A): "Tip type is not a Gripper tool.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0B): "Invalid aspirate type.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0C): "Invalid dispense type.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0D): "Invalid LLD mode.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0E): "Sequential aspirate with pressure LLD.", + (0x0001, 0x0001, 0x0101, 1, 0x0F0F): "Jet dispense with LLD.", + (0x0001, 0x0001, 0x0101, 1, 0x0F10): "Surface dispense with pressure LLD.", + (0x0001, 0x0001, 0x0101, 1, 0x0F11): "Invalid aspirate dispense pattern.", + (0x0001, 0x0001, 0x0101, 1, 0x0F12): "Pressure differential not achieved.", + (0x0001, 0x0001, 0x0101, 1, 0x0F13): "Not enough array items for the specified tips.", + (0x0001, 0x0001, 0x0101, 1, 0x0F14): "All pressure differentials not achieved.", + (0x0001, 0x0001, 0x0101, 1, 0x0F15): "Y position limit exceeded for channel 1.", + (0x0001, 0x0001, 0x0101, 1, 0x0F16): "Y position limit exceeded for channel 2.", + (0x0001, 0x0001, 0x0101, 1, 0x0F17): "Y position limit exceeded for channels 1 and 2.", + (0x0001, 0x0001, 0x0101, 1, 0x0F18): "Y position limit exceeded for channel 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F19): "Y position limit exceeded for channels 1 and 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1A): "Y position limit exceeded for channels 2 and 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1B): "Y position limit exceeded for channels 1, 2 and 3.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1C): "Y position limit exceeded for channel 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1D): "Y position limit exceeded for channels 1 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1E): "Y position limit exceeded for channels 2 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F1F): "Y position limit exceeded for channels 1, 2 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F20): "Y position limit exceeded for channels 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F21): "Y position limit exceeded for channels 1, 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F22): "Y position limit exceeded for channels 2, 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F23): "Y position limit exceeded for channels 1, 2, 3 and 4.", + (0x0001, 0x0001, 0x0101, 1, 0x0F24): "Z position limit exceeded for one or more channels.", + (0x0001, 0x0001, 0x0101, 1, 0x0F25): "Unexpected Vacuum Detected.", + (0x0001, 0x0001, 0x0102, 1, 0x0F01): "LLD not detected.", + (0x0001, 0x0001, 0x0102, 1, 0x0F02): "Invalid channel specified.", + (0x0001, 0x0001, 0x0102, 1, 0x0F03): "Gripper not detected.", + (0x0001, 0x0001, 0x0102, 1, 0x0F04): "LLD unexpectedly detected during Z movement.", + (0x0001, 0x0001, 0x0102, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0102, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0102, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0102, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0102, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0102, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0102, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0102, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0102, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0102, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0102, 1, 0x0F14): "Reserved Error 20.", + (0x0001, 0x0001, 0x0102, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0102, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0102, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0102, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0102, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1E): "Reserved Error 30.", + (0x0001, 0x0001, 0x0102, 1, 0x0F1F): "Reserved Error 31.", + (0x0001, 0x0001, 0x0102, 1, 0x0F20): "Reserved Error 32.", + (0x0001, 0x0001, 0x0102, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0102, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0102, 1, 0x0F23): "Reserved Error 35.", + (0x0001, 0x0001, 0x0102, 1, 0x0F24): "Invalid tips specified.", + (0x0001, 0x0001, 0x0102, 1, 0x0F25): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x0102, 1, 0x0F26): "LLD seeks not within 0.05mm of each other.", + ( + 0x0001, + 0x0001, + 0x0104, + 1, + 0x0F01, + ): "Motion was stopped prematurely via a call to IAxisWrapper.Stop(BOOL hard).", + (0x0001, 0x0001, 0x0105, 1, 0x0F01): "Not enough array items for the specified tips.", + (0x0001, 0x0001, 0x0105, 1, 0x0F02): "One or more Shift N Scan tube racks not installed.", + (0x0001, 0x0001, 0x0105, 1, 0x0F03): "Invalid tips specified.", + (0x0001, 0x0001, 0x0105, 1, 0x0F04): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x0106, 1, 0x0F01): "Unable to sequential aspirate with pressure LLD.", + (0x0001, 0x0001, 0x0106, 1, 0x0F02): "Invalid parameter combination.", + (0x0001, 0x0001, 0x0106, 1, 0x0F03): "Cannot dispense with pressure LLD.", + (0x0001, 0x0001, 0x0106, 1, 0x0F04): "Not enough array items for the specified tips.", + (0x0001, 0x0001, 0x0108, 1, 0x0F01): "Gripper detects force applied.", + (0x0001, 0x0001, 0x0108, 1, 0x0F02): "Wrist may be in an unsafe location for initialization.", + (0x0001, 0x0001, 0x0108, 1, 0x0F03): "Y axis cannot be moved to safe zone.", + (0x0001, 0x0001, 0x0108, 1, 0x0F04): "Park sensor not active when gripper is parked.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F01, + ): "Deck monitoring is not available in the current hardware configuration.", + (0x0001, 0x0001, 0x010A, 1, 0x0F02): "ConfigureTracks only allowed while monitoring.", + (0x0001, 0x0001, 0x010A, 1, 0x0F03): "LoadTrack only allowed while configuring.", + (0x0001, 0x0001, 0x010A, 1, 0x0F04): "Invalid track position specified.", + (0x0001, 0x0001, 0x010A, 1, 0x0F05): "Track plus width created an invalid track position.", + (0x0001, 0x0001, 0x010A, 1, 0x0F06): "CancelTrack only allowed while configuring.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F07, + ): "MonitorDeck(FALSE) only allowed while monitoring and a method is not running.", + (0x0001, 0x0001, 0x010A, 1, 0x0F08): "MonitorDeck(TRUE) only allowed while not monitoring.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F09, + ): "LoadTracks2 tracks and widths array sizes are not identical.", + ( + 0x0001, + 0x0001, + 0x010A, + 1, + 0x0F0A, + ): "LoadTracks2 tracks and widths arrays have overlapping tracks.", + (0x0001, 0x0001, 0x010C, 1, 0x0F01): "Right door lock does not indicate locked.", + (0x0001, 0x0001, 0x010C, 1, 0x0F02): "Left door lock does not indicate locked.", + (0x0001, 0x0001, 0x010C, 1, 0x0F03): "Both door locks do not indicate locked.", + ( + 0x0001, + 0x0001, + 0x010C, + 1, + 0x0F04, + ): "Door cannot be unlocked until the instrument completes the command currently in progress.", + (0x0001, 0x0001, 0x010E, 1, 0x0F01): "Invalid tips specified.", + (0x0001, 0x0001, 0x010E, 1, 0x0F02): "Tip position(s) not valid.", + (0x0001, 0x0001, 0x010E, 1, 0x0F03): "Not enough array items for the specified tips.", + ( + 0x0001, + 0x0001, + 0x010F, + 1, + 0x8F01, + ): "Speed exceeds maximum speed of the z and g axis and will not be applied to these axes.", + (0x0001, 0x0001, 0x0110, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0110, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0110, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0110, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0110, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0110, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0110, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0110, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0110, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0110, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0110, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0110, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0110, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0110, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0110, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0110, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0110, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0110, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0110, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0110, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0110, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0110, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0110, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0110, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0110, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0110, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0110, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0110, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0110, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0110, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0110, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0110, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0110, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0110, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0110, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0110, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0110, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0110, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0110, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0110, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0110, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0110, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0110, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0110, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0110, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0110, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0110, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0110, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0110, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0110, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0110, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0110, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0110, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0110, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0110, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0110, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0110, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0110, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0110, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0110, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0110, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0110, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0110, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0110, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0110, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0110, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0111, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0111, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0111, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0111, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0111, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0111, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0111, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0111, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0111, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0111, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0111, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0111, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0111, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0111, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0111, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0111, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0111, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0111, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0111, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0111, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0111, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0111, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0111, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0111, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0111, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0111, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0111, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0111, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0111, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0111, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0111, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0111, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0111, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0111, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0111, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0111, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0111, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0111, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0111, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0111, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0111, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0111, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0111, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0111, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0111, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0111, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0111, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0111, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0111, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0111, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0111, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0111, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0111, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0111, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0111, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0111, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0111, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0111, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0111, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0111, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0111, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0111, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0111, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0111, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0111, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0111, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0112, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0112, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0112, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0112, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0112, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0112, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0112, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0112, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0112, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0112, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0112, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0112, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0112, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0112, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0112, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0112, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0112, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0112, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0112, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0112, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0112, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0112, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0112, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0112, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0112, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0112, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0112, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0112, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0112, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0112, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0112, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0112, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0112, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0112, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0112, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0112, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0112, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0112, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0112, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0112, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0112, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0112, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0112, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0112, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0112, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0112, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0112, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0112, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0112, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0112, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0112, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0112, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0112, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0112, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0112, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0112, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0112, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0112, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0112, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0112, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0112, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0112, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0112, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0112, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0112, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0112, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0113, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0113, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0113, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0113, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0113, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0113, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0113, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0113, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0113, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0113, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0113, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0113, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0113, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0113, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0113, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0113, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0113, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0113, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0113, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0113, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0113, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0113, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0113, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0113, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0113, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0113, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0113, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0113, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0113, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0113, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0113, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0113, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0113, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0113, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0113, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0113, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0113, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0113, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0113, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0113, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0113, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0113, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0113, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0113, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0113, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0113, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0113, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0113, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0113, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0113, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0113, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0113, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0113, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0113, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0113, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0113, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0113, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0113, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0113, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0113, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0113, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0113, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0113, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0113, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0113, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0113, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0114, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0114, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0114, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0114, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0114, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0114, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0114, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0114, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0114, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0114, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0114, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0114, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0114, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0114, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0114, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0114, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0114, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0114, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0114, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0114, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0114, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0114, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0114, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0114, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0114, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0114, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0114, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0114, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0114, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0114, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0114, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0114, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0114, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0114, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0114, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0114, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0114, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0114, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0114, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0114, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0114, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0114, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0114, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0114, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0114, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0114, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0114, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0114, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0114, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0114, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0114, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0114, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0114, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0114, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0114, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0114, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0114, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0114, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0114, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0114, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0114, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0114, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0114, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0114, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0114, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0114, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0115, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0115, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0115, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0115, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0115, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0115, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0115, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0115, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0115, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0115, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0115, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0115, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0115, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0115, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0115, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0115, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0115, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0115, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0115, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0115, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0115, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0115, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0115, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0115, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0115, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0115, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0115, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0115, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0115, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0115, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0115, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0115, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0115, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0115, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0115, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0115, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0115, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0115, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0115, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0115, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0115, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0115, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0115, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0115, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0115, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0115, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0115, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0115, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0115, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0115, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0115, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0115, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0115, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0115, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0115, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0115, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0115, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0115, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0115, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0115, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0115, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0115, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0115, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0115, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0115, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0115, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0116, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0116, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0116, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0116, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0116, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0116, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0116, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0116, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0116, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0116, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0116, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0116, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0116, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0116, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0116, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0116, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0116, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0116, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0116, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0116, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0116, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0116, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0116, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0116, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0116, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0116, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0116, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0116, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0116, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0116, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0116, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0116, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0116, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0116, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0116, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0116, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0116, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0116, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0116, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0116, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0116, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0116, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0116, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0116, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0116, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0116, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0116, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0116, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0116, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0116, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0116, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0116, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0116, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0116, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0116, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0116, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0116, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0116, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0116, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0116, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0116, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0116, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0116, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0116, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0116, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0116, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0x0117, 1, 0x0F01): "Reserved Error 1.", + (0x0001, 0x0001, 0x0117, 1, 0x0F02): "Reserved Error 2.", + (0x0001, 0x0001, 0x0117, 1, 0x0F03): "Reserved Error 3.", + (0x0001, 0x0001, 0x0117, 1, 0x0F04): "Reserved Error 4.", + (0x0001, 0x0001, 0x0117, 1, 0x0F05): "Reserved Error 5.", + (0x0001, 0x0001, 0x0117, 1, 0x0F06): "Reserved Error 6.", + (0x0001, 0x0001, 0x0117, 1, 0x0F07): "Reserved Error 7.", + (0x0001, 0x0001, 0x0117, 1, 0x0F08): "Reserved Error 8.", + (0x0001, 0x0001, 0x0117, 1, 0x0F09): "Reserved Error 9.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0A): "Reserved Error 10.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0B): "Reserved Error 11.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0C): "Reserved Error 12.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0D): "Reserved Error 13.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0E): "Reserved Error 14.", + (0x0001, 0x0001, 0x0117, 1, 0x0F0F): "Reserved Error 15.", + (0x0001, 0x0001, 0x0117, 1, 0x0F10): "Reserved Error 16.", + (0x0001, 0x0001, 0x0117, 1, 0x0F11): "Reserved Error 17.", + (0x0001, 0x0001, 0x0117, 1, 0x0F12): "Reserved Error 18.", + (0x0001, 0x0001, 0x0117, 1, 0x0F13): "Reserved Error 19.", + (0x0001, 0x0001, 0x0117, 1, 0x0F14): "No Communication With EEPROM.", + (0x0001, 0x0001, 0x0117, 1, 0x0F15): "Reserved Error 21.", + (0x0001, 0x0001, 0x0117, 1, 0x0F16): "Reserved Error 22.", + (0x0001, 0x0001, 0x0117, 1, 0x0F17): "Reserved Error 23.", + (0x0001, 0x0001, 0x0117, 1, 0x0F18): "Reserved Error 24.", + (0x0001, 0x0001, 0x0117, 1, 0x0F19): "Reserved Error 25.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1A): "Reserved Error 26.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1B): "Reserved Error 27.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1C): "Reserved Error 28.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1D): "Reserved Error 29.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1E): "Undefined Command.", + (0x0001, 0x0001, 0x0117, 1, 0x0F1F): "Undefined Parameter.", + (0x0001, 0x0001, 0x0117, 1, 0x0F20): "Parameter Out of Range.", + (0x0001, 0x0001, 0x0117, 1, 0x0F21): "Reserved Error 33.", + (0x0001, 0x0001, 0x0117, 1, 0x0F22): "Reserved Error 34.", + (0x0001, 0x0001, 0x0117, 1, 0x0F23): "Voltages Out of Range.", + (0x0001, 0x0001, 0x0117, 1, 0x0F24): "Stop During Execution of Command.", + (0x0001, 0x0001, 0x0117, 1, 0x0F25): "Second core gripper channel stalled.", + (0x0001, 0x0001, 0x0117, 1, 0x0F26): "Reserved Error 38.", + (0x0001, 0x0001, 0x0117, 1, 0x0F27): "Reserved Error 39.", + (0x0001, 0x0001, 0x0117, 1, 0x0F28): "No Parallel Processes Permitted.", + (0x0001, 0x0001, 0x0117, 1, 0x0F29): "Reserved Error 41.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2A): "Reserved Error 42.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2B): "Reserved Error 43.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2C): "Reserved Error 44.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2D): "Reserved Error 45.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2E): "Reserved Error 46.", + (0x0001, 0x0001, 0x0117, 1, 0x0F2F): "Reserved Error 47.", + (0x0001, 0x0001, 0x0117, 1, 0x0F30): "Reserved Error 48.", + (0x0001, 0x0001, 0x0117, 1, 0x0F31): "Reserved Error 49.", + (0x0001, 0x0001, 0x0117, 1, 0x0F32): "Dispense Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F33): "Dispense Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F34): "Dispense Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F35): "Maximum Volume in Tip Reached.", + (0x0001, 0x0001, 0x0117, 1, 0x0F36): "Dispense Drive Position Out of Permitted Area.", + (0x0001, 0x0001, 0x0117, 1, 0x0F37): "Y-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F38): "Y-Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F39): "Y-Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3A): "Reserved Error 58.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3B): "Reserved Error 59.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3C): "Z-Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3D): "Z-Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3E): "Z-Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F3F): "Z-Drive Limit Stop Not Found.", + (0x0001, 0x0001, 0x0117, 1, 0x0F40): "Reserved Error 64.", + (0x0001, 0x0001, 0x0117, 1, 0x0F41): "Squeeze Drive Initialization Failed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F42): "Squeeze Drive Not Initialized.", + (0x0001, 0x0001, 0x0117, 1, 0x0F43): "Squeeze Drive Movement Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F44): "Squeeze Drive Initialize Position Adjustment Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F45): "Reserved Error 69.", + (0x0001, 0x0001, 0x0117, 1, 0x0F46): "No Liquid Level Found.", + (0x0001, 0x0001, 0x0117, 1, 0x0F47): "Not Enough Liquid Present.", + (0x0001, 0x0001, 0x0117, 1, 0x0F48): "Auto Calibration at Pressure Sensor Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F49): "No Liquid Level Found with Dual LLD.", + ( + 0x0001, + 0x0001, + 0x0117, + 1, + 0x0F4A, + ): "Unexpected CLLD Detected, Liquid Detected above Liquid Seek Height.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4B): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4C): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4D): "Unable to Drop Tip.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4E): "Tip Detected Not Correct Tip.", + (0x0001, 0x0001, 0x0117, 1, 0x0F4F): "Tip not Properly Squeezed.", + (0x0001, 0x0001, 0x0117, 1, 0x0F50): "Liquid Not Correctly Aspirated.", + (0x0001, 0x0001, 0x0117, 1, 0x0F51): "Clot Detected.", + (0x0001, 0x0001, 0x0117, 1, 0x0F52): "TADM Measurement Out of Lower Limit Curve.", + (0x0001, 0x0001, 0x0117, 1, 0x0F53): "TADM Measurement Out of Upper Limit Curve.", + (0x0001, 0x0001, 0x0117, 1, 0x0F54): "Not Enough Memory for TADM Measurement.", + (0x0001, 0x0001, 0x0117, 1, 0x0F55): "Cannot Communicate with Potentiometer.", + (0x0001, 0x0001, 0x0117, 1, 0x0F56): "ADC Algorithm Error.", + (0x0001, 0x0001, 0x0117, 1, 0x0F57): "Reserved Error 87.", + (0x0001, 0x0001, 0x0117, 1, 0x0F58): "Reserved Error 88.", + (0x0001, 0x0001, 0x0117, 1, 0x0F59): "Reserved Error 89.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5A): "Limit Curve Not Resettable.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5B): "Limit Curve Not Programmable.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5C): "Limit Curve Name Not Found.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5D): "Limit Curve Data Invalid.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5E): "Not Enough Memory For Limit Curve.", + (0x0001, 0x0001, 0x0117, 1, 0x0F5F): "Invalid Limit Curve Index.", + (0x0001, 0x0001, 0x0117, 1, 0x0F60): "Limit Curve Already Stored.", + (0x0001, 0x0001, 0x0117, 1, 0x0F61): "Tip Already Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F62): "No Tip Picked Up.", + (0x0001, 0x0001, 0x0117, 1, 0x0F63): "Test Pressure Not Achieved.", + (0x0001, 0x0001, 0x0117, 1, 0x0F64): "Leak Detected.", + (0x0001, 0x0001, 0x0117, 1, 0x0F65): "Y Position Exceeds Limits.", + (0x0001, 0x0001, 0x0117, 1, 0x0F66): "Z Position Exceeds Limits.", + (0x0001, 0x0001, 0x0117, 1, 0x0F67): "Tip Type Not Defined.", + (0x0001, 0x0001, 0x0117, 1, 0x0F68): "Invalid LLD Mode.", + (0x0001, 0x0001, 0x0117, 1, 0x0F69): "Invalid Aspirate Type.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6A): "Sequential Aspirate With PLLD.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6B): "Invalid Dispense Type.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6C): "Jet Dispense With LLD.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6D): "Surface Dispense With LLD.", + (0x0001, 0x0001, 0x0117, 1, 0x0F6E): "Invalid Aspirate Dispense Pattern.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F01): "The park button is currently disabled.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F02): "The gripper is holding a plate.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F03): "Unknown device identifier.", + (0x0001, 0x0001, 0xBF00, 1, 0x0F04): "No shift and scan racks installed.", + (0x0001, 0x0001, 0xC100, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC100, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC100, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC100, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC100, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC101, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC101, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC101, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC101, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC101, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC102, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC102, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC102, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC102, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC102, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC103, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC103, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC103, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC103, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC103, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC104, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC104, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC104, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC104, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC104, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC105, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC105, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC105, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC105, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC105, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC106, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC106, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC106, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC106, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC106, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0001, 0xC107, 1, 0x0F01): "Not in download.", + (0x0001, 0x0001, 0xC107, 1, 0x0F02): "Data too short.", + (0x0001, 0x0001, 0xC107, 1, 0x0F03): "Data too long.", + (0x0001, 0x0001, 0xC107, 1, 0x0F04): "Service not started.", + (0x0001, 0x0001, 0xC107, 1, 0x0F05): "Cannot start download.", + (0x0001, 0x0020, 0x0100, 1, 0x0F01): "Sequencer Exception.", + (0x0001, 0x0020, 0x0100, 1, 0x0F02): "Static Position Error Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F09): "Position Flag Not Found.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0001, 0x0020, 0x0100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0001, 0x0020, 0x0100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0001, 0x0020, 0x0100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0001, 0x0020, 0x0100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0001, 0x0020, 0x0100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0001, 0x0020, 0x0100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0001, 0x0020, 0x0100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0001, 0x0020, 0x0100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0001, 0x0020, 0x0100, 1, 0x0F17): "Servo Loop Overrun.", + (0x0001, 0x0020, 0x0101, 1, 0x0F01): "Sequencer Exception.", + (0x0001, 0x0020, 0x0101, 1, 0x0F02): "Static Position Error Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F04): "Settling Position Error Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F09): "Position Flag Not Found.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0B): "Servo Not Enabled.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0D): "Incomplete Configuration.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0E): "Flash Memory Failure.", + (0x0001, 0x0020, 0x0101, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0001, 0x0020, 0x0101, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0001, 0x0020, 0x0101, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0001, 0x0020, 0x0101, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0001, 0x0020, 0x0101, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0001, 0x0020, 0x0101, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0001, 0x0020, 0x0101, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0001, 0x0020, 0x0101, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0001, 0x0020, 0x0101, 1, 0x0F17): "Servo Loop Overrun.", + (0x0001, 0x0060, 0x0101, 1, 0x0F01): "Bad buddy address.", + (0x0001, 0x0060, 0x0101, 1, 0x0F02): "Barcode read timeout.", + (0x0001, 0x0060, 0x0101, 1, 0x0F03): "Barcode engine communication timeout.", + (0x0001, 0x0060, 0x0101, 1, 0x0F04): "Barcode engine is already scanning.", + (0x0001, 0x0060, 0x0101, 1, 0x0F05): "Barcode queue is empty.", + (0x0001, 0x0060, 0x0101, 1, 0x0F06): "CTS handshake timeout.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F07, + ): "The top field is not a multiple of the correct number. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F08, + ): "The bottom field is not of the correct multiple. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F09, + ): "The left field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0A, + ): "The right field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0B, + ): "The bottom and top fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0C, + ): "The left and right fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0D, + ): "The top field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0E, + ): "The bottom field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F0F, + ): "The left field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x0F10, + ): "The right field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + (0x0001, 0x0060, 0x0101, 1, 0x0F11): "The top field exceeds the top of the current FOV.", + (0x0001, 0x0060, 0x0101, 1, 0x0F12): "The bottom field exceeds the bottom of the current FOV.", + (0x0001, 0x0060, 0x0101, 1, 0x0F13): "The left field exceeds the left of the current FOV.", + (0x0001, 0x0060, 0x0101, 1, 0x0F14): "The right field exceeds the right of the current FOV.", + ( + 0x0001, + 0x0060, + 0x0101, + 1, + 0x8F01, + ): "The maximum number of queued barcodes has been exceeded. Barcodes have been lost.", + (0x0001, 0x0060, 0x0102, 1, 0x0F01): "Bad buddy address.", + (0x0001, 0x0060, 0x0102, 1, 0x0F02): "Barcode read timeout.", + (0x0001, 0x0060, 0x0102, 1, 0x0F03): "Barcode engine communication timeout.", + (0x0001, 0x0060, 0x0102, 1, 0x0F04): "Barcode engine is already scanning.", + (0x0001, 0x0060, 0x0102, 1, 0x0F05): "Barcode queue is empty.", + (0x0001, 0x0060, 0x0102, 1, 0x0F06): "CTS handshake timeout.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F07, + ): "The top field is not a multiple of the correct number. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F08, + ): "The bottom field is not of the correct multiple. Cognex DM60 requires a multiple of 4. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F09, + ): "The left field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0A, + ): "The right field is not of a multiple of the correct number. Cognex DM60 requires a multiple of 8. Other scanners may require different multiples.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0B, + ): "The bottom and top fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0C, + ): "The left and right fields do not specify a region of interest greater than minimum required. Cognex DM60 requires one with a minimum size of 64 pixels. Other scanners may require a different minimum.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0D, + ): "The top field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0E, + ): "The bottom field exceeds the vertical maximum. For the Cognex DM60 this is 480.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F0F, + ): "The left field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x0F10, + ): "The right field exceeds the horizontal maximum. For the Cognex DM60 this is 752.", + (0x0001, 0x0060, 0x0102, 1, 0x0F11): "The top field exceeds the top of the current FOV.", + (0x0001, 0x0060, 0x0102, 1, 0x0F12): "The bottom field exceeds the bottom of the current FOV.", + (0x0001, 0x0060, 0x0102, 1, 0x0F13): "The left field exceeds the left of the current FOV.", + (0x0001, 0x0060, 0x0102, 1, 0x0F14): "The right field exceeds the right of the current FOV.", + ( + 0x0001, + 0x0060, + 0x0102, + 1, + 0x8F01, + ): "The maximum number of queued barcodes has been exceeded. Barcodes have been lost.", + ( + 0x0001, + 0x0080, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0080, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0080, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0080, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0080, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0080, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0080, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0080, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0080, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0080, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + ( + 0x0001, + 0x0081, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0081, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0081, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0081, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0081, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0081, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0081, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0081, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0081, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0081, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + ( + 0x0001, + 0x0082, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0082, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0082, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0082, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0082, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0082, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0082, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0082, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0082, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0082, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + ( + 0x0001, + 0x0083, + 0x0100, + 1, + 0x0F01, + ): "The lock sensor detected the solenoid when it should not have.", + (0x0001, 0x0083, 0x0100, 1, 0x0F02): "The lock did not properly lock.", + (0x0001, 0x0083, 0x0100, 1, 0x0F03): "The lock did not properly unlock.", + (0x0001, 0x0083, 0x0100, 1, 0x0F04): "The sensor check failed.", + (0x0001, 0x0083, 0x0100, 1, 0x0F05): "No heartbeat message has been configured.", + (0x0001, 0x0083, 0x0100, 1, 0x0F06): "Door not closed.", + ( + 0x0001, + 0x0083, + 0x0100, + 1, + 0x0F07, + ): "The type of door lock does not support the requested operation.", + (0x0001, 0x0083, 0xA000, 1, 0x0F01): "The safety feature failed to engage.", + (0x0001, 0x0083, 0xA000, 1, 0x0F02): "The safety feature failed to disengage when deactivated.", + ( + 0x0001, + 0x0083, + 0xA000, + 1, + 0x0F03, + ): "The safety feature was not in a state that allows it to engage its locks. The safety feature was not activated.", + (0x0001, 0xE000, 0xBF00, 1, 0x0F01): "LED Update Timeout.", + (0x0001, 0xE001, 0xBF00, 1, 0x0F01): "LED Update Timeout.", + (0x0001, 0xE020, 0xBF00, 1, 0x0F01): "LED Update Timeout.", + (0x0020, 0x0001, 0x1000, 1, 0x0F01): "The gripper calibration has not yet started.", + (0x0020, 0x0001, 0x1000, 1, 0x0F02): "The flash memory sector contains an invalid magic cookie.", + (0x0020, 0x0001, 0x1000, 1, 0x0F03): "The flash memory sector contains an invalid checksum.", + (0x0020, 0x0001, 0x1000, 1, 0x0F04): "Flash memory sector failed preparation for writing.", + (0x0020, 0x0001, 0x1000, 1, 0x0F05): "Flash memory sectors failed to erase.", + (0x0020, 0x0001, 0x1000, 1, 0x0F06): "Flash memory write failed.", + (0x0020, 0x0001, 0x1000, 1, 0x0F07): "The open width is less than the tool width.", + (0x0020, 0x0001, 0x1000, 1, 0x0F08): "Force is still applied to the object within the gripper.", + (0x0020, 0x0001, 0x1000, 1, 0x0F09): "The calibration tool was not detected.", + (0x0020, 0x0001, 0x1000, 1, 0x0F0A): "The gripper width calibration failed.", + ( + 0x0020, + 0x0001, + 0x1000, + 1, + 0x0F0B, + ): "The gripper travel extent calibration cannot start because the gripper width calibration is in progress.", + (0x0020, 0x0001, 0x1000, 1, 0x0F0C): "The gripper travel extent calibration verification failed.", + (0x0020, 0x0001, 0xBF00, 1, 0x0F01): "Force is still applied to the object within the gripper.", + (0x0020, 0x0001, 0xBF00, 1, 0x0F02): "Not enough force applied to the object within the gripper.", + (0x0020, 0x0001, 0xBF00, 1, 0x0F03): "The park sensor has not been enabled.", + (0x0020, 0x0021, 0x0100, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0021, 0x0100, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0021, 0x0100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0021, 0x0100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0021, 0x0100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0021, 0x0100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0021, 0x0100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0021, 0x0100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0021, 0x0100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0021, 0x0100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0021, 0x0100, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0021, 0x0101, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0021, 0x0101, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0021, 0x0101, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0021, 0x0101, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0021, 0x0101, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0021, 0x0101, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0021, 0x0101, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0021, 0x0101, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0021, 0x0101, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0021, 0x0101, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0021, 0x0101, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0023, 0x0100, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0023, 0x0100, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0023, 0x0100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0023, 0x0100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0023, 0x0100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0023, 0x0100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0023, 0x0100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0023, 0x0100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0023, 0x0100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0023, 0x0100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0023, 0x0100, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0023, 0x0101, 1, 0x0F01): "Sequencer Exception.", + (0x0020, 0x0023, 0x0101, 1, 0x0F02): "Static Position Error Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F04): "Settling Position Error Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F09): "Position Flag Not Found.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0B): "Servo Not Enabled.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0D): "Incomplete Configuration.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0E): "Flash Memory Failure.", + (0x0020, 0x0023, 0x0101, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0020, 0x0023, 0x0101, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0020, 0x0023, 0x0101, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0020, 0x0023, 0x0101, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0020, 0x0023, 0x0101, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0020, 0x0023, 0x0101, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0020, 0x0023, 0x0101, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0020, 0x0023, 0x0101, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0020, 0x0023, 0x0101, 1, 0x0F17): "Servo Loop Overrun.", + (0x0020, 0x0044, 0x0004, 1, 0x0F01): "No ACK from digital potentiometers.", + (0x0020, 0x0044, 0x0004, 1, 0x0F02): "Cannot confirm write to digital potentiometers.", + (0x0020, 0x0044, 0x0004, 1, 0x0F03): "Digital potentiometer write timeout.", + (0x0020, 0x0044, 0x0004, 1, 0x0F04): "Failed to start A/D conversion.", + (0x0020, 0x0044, 0x0004, 1, 0x0F05): "A/D results are invalid.", + (0x0020, 0x0044, 0x0004, 1, 0x0F06): "A/D timeout.", + (0x0020, 0x0044, 0x0004, 1, 0x0F07): "Force monitor is running.", + (0x0020, 0x0044, 0x0004, 1, 0x0F08): "Force calibration factors are not valid.", + (0x0020, 0x0044, 0x0004, 1, 0x0F09): "Force sensor is not calibrated.", + (0x0020, 0x0044, 0x0004, 1, 0x0F0A): "Failed to calibrate force sensor.", + (0x0020, 0x0044, 0x0004, 1, 0x0F0B): "Applied force is out of range.", + (0x0020, 0x0044, 0x0004, 1, 0x0F0C): "Force monitor calibration is running.", + ( + 0x0020, + 0x0044, + 0x0004, + 1, + 0x0F0D, + ): "Cannot perform the requested operation because LLD detection is enabled.", + ( + 0x0020, + 0x0044, + 0x0004, + 1, + 0x0F0E, + ): "Cannot perform the requested operation because LLD detection is not enabled.", + ( + 0x0020, + 0x0044, + 0x0005, + 1, + 0x0F01, + ): "This error is generated when the current operation resulted in a overflow.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F01): "No ACK from digital potentiometers.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F02): "Cannot confirm write to digital potentiometers.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F03): "Digital potentiometer write timeout.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F04): "Failed to start A/D conversion.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F05): "A/D results are invalid.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F06): "A/D timeout.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F07): "Force monitor is running.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F08): "Force calibration factors are not valid.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F09): "Force sensor is not calibrated.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F0A): "Failed to calibrate force sensor.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F0B): "Applied force is out of range.", + (0x0020, 0x0044, 0xBF00, 1, 0x0F0C): "Force monitor calibration is running.", + ( + 0x0020, + 0x0044, + 0xBF00, + 1, + 0x0F0D, + ): "Cannot perform the requested operation because LLD detection is enabled.", + ( + 0x0020, + 0x0044, + 0xBF00, + 1, + 0x0F0E, + ): "Cannot perform the requested operation because LLD detection is not enabled.", +} + +# Generated from Hamilton.Module.MLPrep.Service.dll (MLPrepSystem.RegisterErrors). +# Node IDs: 0x00e8=FrontChannel, 0x00ec=RearChannel, 0x00ee=Pipettor. +# Object IDs: 0x0100=Pipettor, 0x0101=Dispenser, 0x0200-0x0205=drives/calibration, +# 0x0107=TADM, 0x1000=MLPrep, 0x1100=MPH, 0x1300-0x1304=Calibration, +# 0x1500=ChannelPresenter, 0x2000=Arm, 0x2200=ArmMotion, 0x3000/0x3100/0x4000-0x4310=Servo/Motor, +# 0x6000=ParticulateSensor, 0xbef0=MLPrepController. +PREP_ERROR_CODES: Dict[Tuple[int, int, int, int, int], str] = { + # Global / unaddressed (HarpAddress default) + (0x0000, 0x0000, 0x0000, 0, 0x0E01): "The pipettor channels are busy with an operation.", + (0x0000, 0x0000, 0x0000, 0, 0x0E02): "The supplied channel index is invalid, or not present.", + (0x0000, 0x0000, 0x0000, 0, 0x0E03): "The indicated site is not defined.", + ( + 0x0000, + 0x0000, + 0x0000, + 0, + 0x0E04, + ): "The requested operation cannot be performed while the channel power is removed.", + (0x0000, 0x0000, 0x0000, 0, 0x0E05): "The requested channel does not have a head installed.", + ( + 0x0000, + 0x0000, + 0x0000, + 0, + 0x0E06, + ): "Coordinator Proxy Communication Timeout. Address refers to the target, not the source.", + (0x0000, 0x0000, 0x0000, 0, 0x0E07): "A Calibration procedure is in progress.", + # Pipettor node (0x00ee) — pipetting operations + (0x0001, 0x00EE, 0x0100, 1, 0x0F01): "AspirateLld must specify cLld and/or pLld.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F02): "DispenseLld must specify cLld.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F03): "Mix must have at least 1 mix cycle.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F04): "Mix must have a non-zero volume.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F05): "Dispense empty and cLLD cannot be used at the same time.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F06): "Aspirate monitoring must enable cLLD and/or pLLD.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F07): "A tip is held.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F08): "A tip is not held.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F09): "All tips picked up are not held.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0A): "Wrong type of tip detected.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0B): "Tip volume will be exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0C): "Dispenser limit will be exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0D): "Z axis stalled.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0E): "cLLD detected unexpectedly.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F0F): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F10): "pLLD did not detect liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F11): "cLLD did not detect liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F12): "Both cLLD and pLLD did not detect liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F13): "Container does not contain sufficient liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F14): "cLLD and pLLD heights exceed limit.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F15): "pLLD aspirate monitoring exceeded limits.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F16): "pLLD aspirate monitoring detected a clot.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F17): "cLLD aspirate monitoring detected no liquid.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F18): "cLLD detected a clot.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F19): "Insufficient memory to store TADM data.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1A): "Invalid TADM limit curve index.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1B): "TADM lower limit exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1C): "TADM upper limit exceeded.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1D): "Automatic drip control failed.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1E): "Non-volatile memory cannot store data.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F1F): "Unable to achieve the required pressure.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F20): "Unable to achieve the required vacuum.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F21): "Pressure leak detected.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F22): "TADM not supported.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F23): "cLLD aspirate monitoring not supported.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F24): "No tip selected in TipMask.", + (0x0001, 0x00EE, 0x0100, 1, 0x0F25): "Invalid tip selected in TipMask.", + (0x0001, 0x00EE, 0x0101, 1, 0x0F01): "Position exceeds tip volume.", + (0x0001, 0x00EE, 0x0101, 1, 0x0F02): "Position exceeds drive limits.", + (0x0001, 0x00EE, 0x0201, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0201, 1, 0x0F02): "The drive did not stall during initialization.", + (0x0001, 0x00EE, 0x0201, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F05): "Home sensor detected outside of tolerance.", + ( + 0x0001, + 0x00EE, + 0x0202, + 1, + 0x0F06, + ): "Cannot calibrate squeeze position until torque is calibrated.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F07): "Torque calibration failed.", + (0x0001, 0x00EE, 0x0202, 1, 0x0F08): "Squeeze position calibration failed.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EE, 0x0203, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EE, 0x0204, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F01): "Parameter(s) exceed buffer limits.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F02): "Unable to erase the limit curves.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F03): "Limit curve name is too short (1 character minimum).", + (0x0001, 0x00EE, 0x0107, 1, 0x0F04): "Limit curve name is too long (36 characters maximum).", + (0x0001, 0x00EE, 0x0107, 1, 0x0F05): "Limit curve name is invalid (starts with 0xFF).", + (0x0001, 0x00EE, 0x0107, 1, 0x0F06): "A maximum of 2999 lower limit entries are allowed.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F07): "A maximum of 2999 upper limit entries are allowed.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F08): "Lower limit sample values are not strictly increasing.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F09): "Upper limit sample values are not strictly increasing.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F0A): "Unable to create the limit curve.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F0B): "Invalid limit curve index.", + (0x0001, 0x00EE, 0x0107, 1, 0x0F0C): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F01): "Calibration has not been started.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F02): "Unable to read from the pressure potentiometer.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F03): "Unable to write to the pressure potentiometer.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F04): "Calibration was not successful.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F05): "Unable to automatically adjust the pressure sensor.", + (0x0001, 0x00EE, 0x0200, 1, 0x0F06): "A tip is not held.", + ( + 0x0001, + 0x00EE, + 0x0205, + 1, + 0x0F01, + ): "An error occurred communicating to the digital potentiometer.", + (0x0001, 0x00EE, 0x0205, 1, 0x0F02): "Unable to automatically adjust the pressure sensor.", + # RearChannel node (0x00ec) — mirrors Pipettor errors + (0x0001, 0x00EC, 0x0100, 1, 0x0F01): "AspirateLld must specify cLld and/or pLld.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F02): "DispenseLld must specify cLld.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F03): "Mix must have at least 1 mix cycle.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F04): "Mix must have a non-zero volume.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F05): "Dispense empty and cLLD cannot be used at the same time.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F06): "Aspirate monitoring must enable cLLD and/or pLLD.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F07): "A tip is held.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F08): "A tip is not held.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F09): "All tips picked up are not held.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0A): "Wrong type of tip detected.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0B): "Tip volume will be exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0C): "Dispenser limit will be exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0D): "Z axis stalled.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0E): "cLLD detected unexpectedly.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F0F): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F10): "pLLD did not detect liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F11): "cLLD did not detect liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F12): "Both cLLD and pLLD did not detect liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F13): "Container does not contain sufficient liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F14): "cLLD and pLLD heights exceed limit.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F15): "pLLD aspirate monitoring exceeded limits.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F16): "pLLD aspirate monitoring detected a clot.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F17): "cLLD aspirate monitoring detected no liquid.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F18): "cLLD detected a clot.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F19): "Insufficient memory to store TADM data.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1A): "Invalid TADM limit curve index.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1B): "TADM lower limit exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1C): "TADM upper limit exceeded.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1D): "Automatic drip control failed.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1E): "Non-volatile memory cannot store data.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F1F): "Unable to achieve the required pressure.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F20): "Unable to achieve the required vacuum.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F21): "Pressure leak detected.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F22): "TADM not supported.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F23): "cLLD aspirate monitoring not supported.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F24): "No tip selected in TipMask.", + (0x0001, 0x00EC, 0x0100, 1, 0x0F25): "Invalid tip selected in TipMask.", + (0x0001, 0x00EC, 0x0101, 1, 0x0F01): "Position exceeds tip volume.", + (0x0001, 0x00EC, 0x0101, 1, 0x0F02): "Position exceeds drive limits.", + (0x0001, 0x00EC, 0x0201, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0201, 1, 0x0F02): "The drive did not stall during initialization.", + (0x0001, 0x00EC, 0x0201, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F05): "Home sensor detected outside of tolerance.", + ( + 0x0001, + 0x00EC, + 0x0202, + 1, + 0x0F06, + ): "Cannot calibrate squeeze position until torque is calibrated.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F07): "Torque calibration failed.", + (0x0001, 0x00EC, 0x0202, 1, 0x0F08): "Squeeze position calibration failed.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EC, 0x0203, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00EC, 0x0204, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F01): "Parameter(s) exceed buffer limits.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F02): "Unable to erase the limit curves.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F03): "Limit curve name is too short (1 character minimum).", + (0x0001, 0x00EC, 0x0107, 1, 0x0F04): "Limit curve name is too long (36 characters maximum).", + (0x0001, 0x00EC, 0x0107, 1, 0x0F05): "Limit curve name is invalid (starts with 0xFF).", + (0x0001, 0x00EC, 0x0107, 1, 0x0F06): "A maximum of 2999 lower limit entries are allowed.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F07): "A maximum of 2999 upper limit entries are allowed.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F08): "Lower limit sample values are not strictly increasing.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F09): "Upper limit sample values are not strictly increasing.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F0A): "Unable to create the limit curve.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F0B): "Invalid limit curve index.", + (0x0001, 0x00EC, 0x0107, 1, 0x0F0C): "pLLD auto adjustment was not successful.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F01): "Calibration has not been started.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F02): "Unable to read from the pressure potentiometer.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F03): "Unable to write to the pressure potentiometer.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F04): "Calibration was not successful.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F05): "Unable to automatically adjust the pressure sensor.", + (0x0001, 0x00EC, 0x0200, 1, 0x0F06): "A tip is not held.", + ( + 0x0001, + 0x00EC, + 0x0205, + 1, + 0x0F01, + ): "An error occurred communicating to the digital potentiometer.", + (0x0001, 0x00EC, 0x0205, 1, 0x0F02): "Unable to automatically adjust the pressure sensor.", + # FrontChannel node (0x00e8) — mirrors Pipettor errors + (0x0001, 0x00E8, 0x0100, 1, 0x0F01): "AspirateLld must specify cLld and/or pLld.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F02): "DispenseLld must specify cLld.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F03): "Mix must have at least 1 mix cycle.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F04): "Mix must have a non-zero volume.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F05): "Dispense empty and cLLD cannot be used at the same time.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F06): "Aspirate monitoring must enable cLLD and/or pLLD.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F07): "A tip is held.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F08): "A tip is not held.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F09): "All tips picked up are not held.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0A): "Wrong type of tip detected.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0B): "Tip volume will be exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0C): "Dispenser limit will be exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0D): "Z axis stalled.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0E): "cLLD detected unexpectedly.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F0F): "pLLD auto adjustment was not successful.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F10): "pLLD did not detect liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F11): "cLLD did not detect liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F12): "Both cLLD and pLLD did not detect liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F13): "Container does not contain sufficient liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F14): "cLLD and pLLD heights exceed limit.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F15): "pLLD aspirate monitoring exceeded limits.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F16): "pLLD aspirate monitoring detected a clot.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F17): "cLLD aspirate monitoring detected no liquid.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F18): "cLLD detected a clot.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F19): "Insufficient memory to store TADM data.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1A): "Invalid TADM limit curve index.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1B): "TADM lower limit exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1C): "TADM upper limit exceeded.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1D): "Automatic drip control failed.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1E): "Non-volatile memory cannot store data.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F1F): "Unable to achieve the required pressure.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F20): "Unable to achieve the required vacuum.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F21): "Pressure leak detected.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F22): "TADM not supported.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F23): "cLLD aspirate monitoring not supported.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F24): "No tip selected in TipMask.", + (0x0001, 0x00E8, 0x0100, 1, 0x0F25): "Invalid tip selected in TipMask.", + (0x0001, 0x00E8, 0x0101, 1, 0x0F01): "Position exceeds tip volume.", + (0x0001, 0x00E8, 0x0101, 1, 0x0F02): "Position exceeds drive limits.", + (0x0001, 0x00E8, 0x0201, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0201, 1, 0x0F02): "The drive did not stall during initialization.", + (0x0001, 0x00E8, 0x0201, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F05): "Home sensor detected outside of tolerance.", + ( + 0x0001, + 0x00E8, + 0x0202, + 1, + 0x0F06, + ): "Cannot calibrate squeeze position until torque is calibrated.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F07): "Torque calibration failed.", + (0x0001, 0x00E8, 0x0202, 1, 0x0F08): "Squeeze position calibration failed.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00E8, 0x0203, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F01): "Not initialized.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F02): "The home sensor was not found.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F03): "Motor stall detected.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F04): "Home sensor not detected within tolerance.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F05): "Home sensor detected outside of tolerance.", + (0x0001, 0x00E8, 0x0204, 1, 0x0F06): "Motion terminated by paired channel.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F01): "Parameter(s) exceed buffer limits.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F02): "Unable to erase the limit curves.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F03): "Limit curve name is too short (1 character minimum).", + (0x0001, 0x00E8, 0x0107, 1, 0x0F04): "Limit curve name is too long (36 characters maximum).", + (0x0001, 0x00E8, 0x0107, 1, 0x0F05): "Limit curve name is invalid (starts with 0xFF).", + (0x0001, 0x00E8, 0x0107, 1, 0x0F06): "A maximum of 2999 lower limit entries are allowed.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F07): "A maximum of 2999 upper limit entries are allowed.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F08): "Lower limit sample values are not strictly increasing.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F09): "Upper limit sample values are not strictly increasing.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F0A): "Unable to create the limit curve.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F0B): "Invalid limit curve index.", + (0x0001, 0x00E8, 0x0107, 1, 0x0F0C): "pLLD auto adjustment was not successful.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F01): "Calibration has not been started.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F02): "Unable to read from the pressure potentiometer.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F03): "Unable to write to the pressure potentiometer.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F04): "Calibration was not successful.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F05): "Unable to automatically adjust the pressure sensor.", + (0x0001, 0x00E8, 0x0200, 1, 0x0F06): "A tip is not held.", + ( + 0x0001, + 0x00E8, + 0x0205, + 1, + 0x0F01, + ): "An error occurred communicating to the digital potentiometer.", + (0x0001, 0x00E8, 0x0205, 1, 0x0F02): "Unable to automatically adjust the pressure sensor.", + # MLPrep node (0x0001) — instrument-level errors + (0x0001, 0x0001, 0x6000, 1, 0x0F01): "The particulate sensor fan is blocked.", + (0x0001, 0x0001, 0x6000, 1, 0x0F02): "The particulate sensor reported an internal issue.", + (0x0001, 0x0001, 0x6000, 1, 0x0F03): "The particulate sensor reported a laser failure.", + (0x0001, 0x0001, 0x1500, 1, 0x0F01): "Cannot change tip definitions when tips are held.", + (0x0001, 0x0001, 0x1500, 1, 0x0F02): "Already in the Power Down Requested state.", + ( + 0x0001, + 0x0001, + 0x1500, + 1, + 0x0F03, + ): "Must request to Power Down before confirming or canceling the procedure.", + (0x0001, 0x0001, 0x1500, 1, 0x0F04): "The channels already have power applied.", + (0x0001, 0x0001, 0x1500, 1, 0x0F05): "No tips can be held during channel head swap.", + ( + 0x0001, + 0x0001, + 0x1500, + 1, + 0x0F06, + ): "The method may only be invoked while the system is in the Suspended state.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F01): "No pipettors registered with the controller.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F02): "No MPH registered with the controller.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F03): "No HHS registered with the controller.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F04): "Cannot manipulate channel axes with tips held.", + (0x0001, 0x0001, 0xBEF0, 1, 0x0F05): "No Hod registered with the controller.", + ( + 0x0001, + 0x0001, + 0x1300, + 1, + 0x0F01, + ): "The deck configuration entry for the self calibration fixture is not defined.", + ( + 0x0001, + 0x0001, + 0x1300, + 1, + 0x0F02, + ): "BeginCalibration has not been called before invoking calibration commands.", + (0x0001, 0x0001, 0x1300, 1, 0x0F03): "Calibration cannot be performed with tips held.", + (0x0001, 0x0001, 0x1300, 1, 0x0F04): "The calculated skew in X is out of allowed tolerance.", + (0x0001, 0x0001, 0x1300, 1, 0x0F05): "The calculated skew in Z is out of allowed tolerance.", + (0x0001, 0x0001, 0x1300, 1, 0x0F06): "The MPH Width is outside of allowed tolerance.", + (0x0001, 0x0001, 0x1300, 1, 0x0F07): "The operation requires a needle definition.", + (0x0001, 0x0001, 0x1300, 1, 0x0F08): "The Z axis for a channel must be calibrated before X or Y.", + ( + 0x0001, + 0x0001, + 0x1300, + 1, + 0x0F09, + ): "The operation cannot be performed with a calibration in progress.", + (0x0001, 0x0001, 0x1301, 1, 0x0F01): "Calibration needs to have been started first.", + (0x0001, 0x0001, 0x1301, 1, 0x0F02): "Calibration is in progress, please finish it first.", + (0x0001, 0x0001, 0x1301, 1, 0x0F03): "Calibration cannot be performed with tips held.", + (0x0001, 0x0001, 0x1301, 1, 0x0F04): "The LLD seeks were not within 0.05mm of eachother.", + ( + 0x0001, + 0x0001, + 0x1301, + 1, + 0x0F05, + ): "The measured width of the MPH is outside of allowed tolerance.", + (0x0001, 0x0001, 0x1301, 1, 0x0F06): "The calibration tool was not detected.", + ( + 0x0001, + 0x0001, + 0x1301, + 1, + 0x0F07, + ): "The measured skew in Z for the MPH is outside of allowed tolerance.", + (0x0001, 0x0001, 0x1302, 1, 0x0F01): "Calibration needs to have been started first.", + (0x0001, 0x0001, 0x1302, 1, 0x0F02): "Calibration cannot be performed with tips held.", + (0x0001, 0x0001, 0x1302, 1, 0x0F03): "The LLD seeks were not within 0.15mm of eachother.", + (0x0001, 0x0001, 0x1302, 1, 0x0F04): "The calibration tool was not detected.", + (0x0001, 0x0001, 0x1304, 1, 0x0F01): "A Site ID was represented more than once.", + (0x0001, 0x0001, 0x2000, 1, 0x0F01): "The current command cannot be paused.", + (0x0001, 0x0001, 0x2000, 1, 0x0F02): "There is no paused command to resume.", + ( + 0x0001, + 0x0001, + 0x2000, + 1, + 0x0F03, + ): "The system cannot resume the paused operation with the door open.", + (0x0001, 0x0001, 0x2000, 1, 0x0F04): "The provided X position would exceed the travel limits.", + (0x0001, 0x0001, 0x2000, 1, 0x0F05): "The provided Y position would exceed the travel limits.", + (0x0001, 0x0001, 0x2000, 1, 0x0F06): "The provided Z position would exceed the travel limits.", + (0x0001, 0x0001, 0x2000, 1, 0x0F07): "Duplicate Channel Indices were present when not allowed.", + (0x0001, 0x0001, 0x2000, 1, 0x0F08): "All X positions of a multi-axis move must match.", + ( + 0x0001, + 0x0001, + 0x2000, + 1, + 0x0F09, + ): "Y positions for channels are in conflict, i.e. channels would need to move through each other.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0A): "Cannot initialize with a plate gripped.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0B): "The door is present and open, movement not allowed.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0C): "The selected tip ID is not valid.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0D): "The X Axis is not initialized.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0E): "A channel's Y Axis is not initialized.", + (0x0001, 0x0001, 0x2000, 1, 0x0F0F): "A channel's Z Axis is not initialized.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F01, + ): "The passed parameters have conflicting Y positions, refer to options.", + (0x0001, 0x0001, 0x2200, 1, 0x0F02): "The given Z position is not valid.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F03, + ): "An axis move was stopped early. See following errors for additional details.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F04, + ): "A coordinated movement was stopped early. See following errors for additional details.", + (0x0001, 0x0001, 0x2200, 1, 0x0F05): "The provided, or calculated, Y position is not valid.", + ( + 0x0001, + 0x0001, + 0x2200, + 1, + 0x0F06, + ): "The calculated path is not possible with the current deck and tip definitions.", + (0x0001, 0x0001, 0x3000, 2, 0x0F01): "An overcurrent condition was detected.", + (0x0001, 0x0001, 0x3100, 1, 0x0F01): "Sequencer Exception.", + (0x0001, 0x0001, 0x3100, 1, 0x0F02): "Static Position Error Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F03): "Dynamic Position Error Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F04): "Settling Position Error Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F05): "Positive Hard Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F06): "Negative Hard Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F07): "Positive Soft Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F08): "Negative Soft Position Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F09): "Position Flag Not Found.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0A): "Motion Would Exceed Travel Limit.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0B): "Servo Not Enabled.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0C): "Could Not Start Trajectory.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0D): "Incomplete Configuration.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0E): "Flash Memory Failure.", + (0x0001, 0x0001, 0x3100, 1, 0x0F0F): "Could Not Start Due To Servo Loop Overrun.", + (0x0001, 0x0001, 0x3100, 1, 0x0F10): "Could Not Start Due To Servo Not Enabled.", + (0x0001, 0x0001, 0x3100, 1, 0x0F11): "Could Not Start Due To Sequencer Not Idle.", + (0x0001, 0x0001, 0x3100, 1, 0x0F12): "Could Not Start Due To Settling Window Trip.", + (0x0001, 0x0001, 0x3100, 1, 0x0F13): "Could Not Start Due To Dynamic Position Error.", + (0x0001, 0x0001, 0x3100, 1, 0x0F14): "Could Not Start Due To Static Position Error.", + (0x0001, 0x0001, 0x3100, 1, 0x0F15): "Could Not Start Due To Sequencer Exception.", + (0x0001, 0x0001, 0x3100, 1, 0x0F16): "Could Not Start Due To Zero Vel Or Acc.", + (0x0001, 0x0001, 0x3100, 1, 0x0F17): "Servo Loop Overrun.", + ( + 0x0001, + 0x0001, + 0x4000, + 1, + 0x0F01, + ): "Cannot start a trajectory with zero velocity or acceleration.", + (0x0001, 0x0001, 0x4000, 1, 0x0F02): "The requested motion would exceed a Travel Limit.", + (0x0001, 0x0001, 0x4000, 1, 0x0F03): "The Static Position Error Limit was exceeded.", + (0x0001, 0x0001, 0x4000, 1, 0x0F04): "The Dynamic Position Error Limit was exceeded.", + (0x0001, 0x0001, 0x4000, 1, 0x0F05): "The settling time limit was exceeded.", + (0x0001, 0x0001, 0x4000, 1, 0x0F06): "Servo has not been enabled.", + (0x0001, 0x0001, 0x4000, 1, 0x0F07): "No motion profile has been configured.", + ( + 0x0001, + 0x0001, + 0x4000, + 1, + 0x0F08, + ): "The requested seek event cannot be reached from the current state.", + (0x0001, 0x0001, 0x4000, 1, 0x0F09): "The requested seek event was not reached.", + (0x0001, 0x0001, 0x4000, 2, 0x0F01): "An overcurrent condition was detected.", + (0x0001, 0x0001, 0x4200, 1, 0x8F01): "Unread entries were overwritten.", + ( + 0x0001, + 0x0001, + 0x4300, + 1, + 0x0F01, + ): "Cannot start a trajectory with zero velocity or acceleration.", + ( + 0x0001, + 0x0001, + 0x4310, + 1, + 0x0F01, + ): "Cannot start a trajectory with zero velocity or acceleration.", + (0x0001, 0x0001, 0x1000, 1, 0x0F01): "No Pipettor is present at the provided index.", + (0x0001, 0x0001, 0x1000, 1, 0x0F02): "Command not valid when tips are held.", + (0x0001, 0x0001, 0x1000, 1, 0x0F03): "Command not valid when no tips are held.", + (0x0001, 0x0001, 0x1000, 1, 0x0F04): "Command not valid when a plate is gripped.", + (0x0001, 0x0001, 0x1000, 1, 0x0F05): "Command not valid when no plate is gripped.", + (0x0001, 0x0001, 0x1000, 1, 0x0F06): "Command not valid when a tool is held.", + (0x0001, 0x0001, 0x1000, 1, 0x0F07): "Command not valid when no tool is held.", + ( + 0x0001, + 0x0001, + 0x1000, + 1, + 0x0F08, + ): "Unable to command the MPH from this interface, please use the MPH interface.", + (0x0001, 0x0001, 0x1000, 1, 0x0F09): "The held tool is not of a type supported by the operation.", + (0x0001, 0x0001, 0x1000, 1, 0x0F0A): "The indicated channel's head is not installed.", + ( + 0x0001, + 0x0001, + 0x1000, + 1, + 0x0F0B, + ): "A channel was specified more than once in an operation where each channel can only be used once.", + (0x0001, 0x0001, 0x1000, 1, 0x0F0C): "The same tip type must be held in each channel.", + (0x0001, 0x0001, 0x1100, 1, 0x0F01): "No MPH is installed.", + (0x0001, 0x0001, 0x1100, 1, 0x0F02): "Command not valid when tips are held.", + (0x0001, 0x0001, 0x1100, 1, 0x0F03): "Command not valid when no tips are held.", + (0x0001, 0x0001, 0x1100, 1, 0x0F04): "The MPH cannot pick up a tip with tool definitions.", + ( + 0x0001, + 0x0001, + 0x1100, + 1, + 0x0F05, + ): "Unable to command an Individual Channel from this interface, please use the Pipettor interface.", + (0x0001, 0x0001, 0x1100, 1, 0x0F06): "The MPH head is not installed.", +} diff --git a/pylabrobot/hamilton/transport/tcp/hoi_error.py b/pylabrobot/hamilton/transport/tcp/hoi_error.py new file mode 100644 index 00000000000..9a36241aa06 --- /dev/null +++ b/pylabrobot/hamilton/transport/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.transport.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.transport.tcp.messages`. +""" + +from __future__ import annotations + +import re +from typing import Dict, List, Optional + +from pylabrobot.hamilton.transport.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/transport/tcp/interface_bundle.py b/pylabrobot/hamilton/transport/tcp/interface_bundle.py new file mode 100644 index 00000000000..260b37638bc --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/interface_bundle.py @@ -0,0 +1,70 @@ +"""Resolve logical interface roles to firmware :class:`Address` values via dot-paths. + +Drivers supply a mapping of role name → :class:`InterfacePathSpec` (path, required flags). +This module performs the shared ``resolve_path`` loop and logging; product-specific +typed bundles (e.g. ``PrepResolvedInterfaces``) live next to each driver. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Mapping, Optional + +from pylabrobot.hamilton.transport.tcp.packets import Address + +if TYPE_CHECKING: + from pylabrobot.hamilton.transport.tcp.tcp 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/transport/tcp/introspection.py b/pylabrobot/hamilton/transport/tcp/introspection.py index 4a5fff0bbfd..a5149578e89 100644 --- a/pylabrobot/hamilton/transport/tcp/introspection.py +++ b/pylabrobot/hamilton/transport/tcp/introspection.py @@ -1,29 +1,114 @@ """Hamilton TCP Introspection API. -This module provides dynamic discovery of Hamilton instrument capabilities -using Interface 0 introspection methods. It allows discovering available -objects, methods, interfaces, enums, and structs at runtime. +Provides dynamic discovery via Interface 0 methods (GetObject, GetMethod, +GetStructs, GetEnums, GetInterfaces, GetSubobjectAddress). + +:class:`HamiltonIntrospection` receives its transport dependencies (registry, +send_discovery_command, send_query) as explicit callables — no back-reference +to the client. The client constructs it via the lazy +:attr:`~pylabrobot.hamilton.transport.tcp.tcp.HamiltonTCPClient.introspection` property, +which is the **only** supported entry point from application code. + +**Runtime defaults (lazy, cache-friendly):** + +- :meth:`~HamiltonIntrospection.ensure_method_table` / + :meth:`~HamiltonIntrospection.methods_for_interface` — scan GetMethod once per object. +- :meth:`~HamiltonIntrospection.ensure_structs_enums` — fetch GetStructs/GetEnums per + HO interface when needed (e.g. for signature resolution). +- :meth:`~HamiltonIntrospection.ensure_global_type_pool` — build + :class:`GlobalTypePool` once per session for ``source_id=1`` refs. +- :meth:`~HamiltonIntrospection.resolve_signature` — resolves a method string without + a pre-built :class:`TypeRegistry` (unless you pass one). + +**Export / parity / codegen (eager composed dumps):** + +- :meth:`~HamiltonIntrospection.build_type_registry` — full structs/enums per + interface (same wire as composing :meth:`~HamiltonIntrospection.ensure_structs_enums` + for each iface). +- :meth:`~HamiltonIntrospection.build_global_type_pool` — full global walk (does not + use the session singleton; use :meth:`~HamiltonIntrospection.ensure_global_type_pool` + for lazy ``source_id=1`` resolution). + +Example (typical notebook):: + + client = HamiltonTCPClient(host=..., port=...) + await client.setup() + intro = client.introspection + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9) """ from __future__ import annotations import logging from dataclasses import dataclass, field -from typing import Any, Dict, List +from enum import IntEnum +from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Set, Tuple, Union, cast -from pylabrobot.hamilton.transport.tcp.commands import HamiltonCommand +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand from pylabrobot.hamilton.transport.tcp.messages import ( + PADDED_FLAG, HoiParams, HoiParamsParser, + inspect_hoi_params, ) from pylabrobot.hamilton.transport.tcp.packets import Address -from pylabrobot.hamilton.transport.tcp.protocol import ( +from pylabrobot.hamilton.transport.tcp.protocol import HamiltonProtocol +from pylabrobot.hamilton.transport.tcp.wire_types import ( + U16, + U32, HamiltonDataType, - HamiltonProtocol, + I8Array, + I32Array, + Str, + StrArray, + U8Array, + U32Array, ) logger = logging.getLogger(__name__) + +class Direction(IntEnum): + """Direction of a method parameter in the HOI introspection type system. + + Column order matches ``_HOI_TYPE_ROWS`` ids tuple: ``ids[Direction]`` gives the + direction-encoded HOI type ID for that row and direction. + """ + + In = 0 + Out = 1 + InOut = 2 + RetVal = 3 + + +async def _subobject_address_and_info( + intro: "HamiltonIntrospection", parent_addr: Address, index: int +) -> Tuple[Address, ObjectInfo]: + """Resolve one subobject index to ``(address, ObjectInfo)`` (shared resolve/tree path).""" + sub_addr = await intro.get_subobject_address(parent_addr, index) + sub_info = await intro.get_object(sub_addr) + return sub_addr, sub_info + + +# Connection/transport errors that should propagate immediately rather than +# being swallowed by introspection catch blocks. A dead connection would +# otherwise cause N individual timeouts (one per method) before the caller +# sees any error. +_TRANSIENT_ERRORS = ( + TimeoutError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + BrokenPipeError, + OSError, +) + +# Known network/built-in structs (source_id=3). These types are not queryable +# via introspection — their wire format was determined empirically by calling +# methods that return them (e.g. GetDeckCalibration on PipettorCalibration). +# Populated lazily below after StructInfo is defined. +_NETWORK_STRUCTS: Dict[int, "StructInfo"] = {} + # ============================================================================ # TYPE RESOLUTION HELPERS # ============================================================================ @@ -39,143 +124,117 @@ def resolve_type_id(type_id: int) -> str: Human-readable type name """ try: - return HamiltonDataType(type_id).name + return cast(str, HamiltonDataType(type_id).name) except ValueError: return f"UNKNOWN_TYPE_{type_id}" -def resolve_type_ids(type_ids: List[int]) -> List[str]: - """Resolve list of Hamilton type IDs to readable names. - - Args: - type_ids: List of Hamilton data type IDs - - Returns: - List of human-readable type names - """ - return [resolve_type_id(tid) for tid in type_ids] - - # ============================================================================ -# INTROSPECTION TYPE MAPPING +# INTROSPECTION TYPE MAPPING (2D table from HoiObject.mHoiParamTypes) # ============================================================================ -# Introspection type IDs are separate from HamiltonDataType wire encoding types. -# These are used for method signature display/metadata, not binary encoding. - -# Type ID ranges for categorization: -# - Argument types: Method parameters (input) -# - ReturnElement types: Multiple return values (struct fields) -# - ReturnValue types: Single return value - -_INTROSPECTION_TYPE_NAMES: dict[int, str] = { - # Argument types (1-8, 33, 41, 45, 49, 53, 61, 66, 82, 102) - 1: "i8", - 2: "u8", - 3: "i16", - 4: "u16", - 5: "i32", - 6: "u32", - 7: "str", - 8: "bytes", - 33: "bool", - 41: "List[i16]", - 45: "List[u16]", - 49: "List[i32]", - 53: "List[u32]", - 61: "List[struct]", # Complex type, needs source_id + struct_id - 66: "List[bool]", - 82: "List[enum]", # Complex type, needs source_id + enum_id - 102: "f32", - # ReturnElement types (18-24, 35, 43, 47, 51, 55, 68, 76) - 18: "u8", - 19: "i16", - 20: "u16", - 21: "i32", - 22: "u32", - 23: "str", - 24: "bytes", - 35: "bool", - 43: "List[i16]", - 47: "List[u16]", - 51: "List[i32]", - 55: "List[u32]", - 68: "List[bool]", - 76: "List[str]", - # ReturnValue types (25-32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105) - 25: "i8", - 26: "u8", - 27: "i16", - 28: "u16", - 29: "i32", - 30: "u32", - 31: "str", - 32: "bytes", - 36: "bool", - 44: "List[i16]", - 48: "List[u16]", - 52: "List[i32]", - 56: "List[u32]", - 69: "List[bool]", - 81: "enum", # Complex type, needs source_id + enum_id - 85: "enum", # Complex type, needs source_id + enum_id - 104: "f32", - 105: "f32", - # Complex types (60, 64, 78) - these need source_id + id - 60: "struct", # ReturnValue, needs source_id + struct_id - 64: "struct", # ReturnValue, needs source_id + struct_id - 78: "enum", # Argument, needs source_id + enum_id -} - -# Type ID sets for categorization -_ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 61, 66, 82, 102} -_RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 68, 76} -_RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105} -_COMPLEX_TYPE_IDS = {60, 61, 64, 78, 81, 82, 85} # Types that need additional bytes - - -def get_introspection_type_category(type_id: int) -> str: - """Get category for introspection type ID. +# Each row maps one wire kind (HamiltonDataType) × 4 directions (Direction enum) +# to the direction-encoded HOI type IDs the firmware uses in GetMethod responses. +# Source: vendor protocol reference mHoiParamTypes[31,4]. - Args: - type_id: Introspection type ID - - Returns: - Category: "Argument", "ReturnElement", "ReturnValue", or "Unknown" - """ - if type_id in _ARGUMENT_TYPE_IDS: - return "Argument" - elif type_id in _RETURN_ELEMENT_TYPE_IDS: - return "ReturnElement" - elif type_id in _RETURN_VALUE_TYPE_IDS: - return "ReturnValue" - else: - return "Unknown" +@dataclass(frozen=True) +class _HoiTypeRow: + """One row in vendor mHoiParamTypes[31,4] with readable display metadata. -def resolve_introspection_type_name(type_id: int) -> str: - """Resolve introspection type ID to readable name. + ``ids`` always follows the interface-0 type table column order: + ``(In, Out, InOut, RetVal)``. These columns are specific to the firmware's + interface-0 HOI type system, a unique typing scheme separate from the + standard ``HamiltonDataType`` wire type IDs. - Args: - type_id: Introspection type ID + ``wire_type``: the ``HamiltonDataType`` that this HOI kind maps to on the wire. + This is the bridge between the two type systems: HOI introspection IDs are + direction-encoded variants of a ``wire_type`` kind. - Returns: - Human-readable type name + ``is_complex``: type requires additional source_id/ref_id bytes in method param encoding. + ``is_struct_kind``: type references a struct definition (subset of complex). + ``is_enum_kind``: type references an enum definition (subset of complex). """ - return _INTROSPECTION_TYPE_NAMES.get(type_id, f"UNKNOWN_TYPE_{type_id}") + display_name: str + ids: tuple[int, int, int, int] # Interface-0 column order: [In, Out, InOut, RetVal] + wire_type: HamiltonDataType = HamiltonDataType.VOID + is_complex: bool = False + is_struct_kind: bool = False + is_enum_kind: bool = False + + +_HOI_TYPE_ROWS: tuple[_HoiTypeRow, ...] = ( + _HoiTypeRow("i8", (1, 17, 9, 25), HamiltonDataType.I8), + _HoiTypeRow("i16", (3, 19, 11, 27), HamiltonDataType.I16), + _HoiTypeRow("i32", (5, 21, 13, 29), HamiltonDataType.I32), + _HoiTypeRow("u8", (2, 18, 10, 26), HamiltonDataType.U8), + _HoiTypeRow("u16", (4, 20, 12, 28), HamiltonDataType.U16), + _HoiTypeRow("u32", (6, 22, 14, 30), HamiltonDataType.U32), + _HoiTypeRow("str", (7, 23, 15, 31), HamiltonDataType.STRING), + _HoiTypeRow("bool", (33, 35, 34, 36), HamiltonDataType.BOOL), + _HoiTypeRow("List[i8]", (37, 39, 38, 40), HamiltonDataType.I8_ARRAY), + _HoiTypeRow("List[i16]", (41, 43, 42, 44), HamiltonDataType.I16_ARRAY), + _HoiTypeRow("List[i32]", (49, 51, 50, 52), HamiltonDataType.I32_ARRAY), + _HoiTypeRow("bytes", (8, 24, 16, 32), HamiltonDataType.U8_ARRAY), + _HoiTypeRow("List[u16]", (45, 47, 46, 48), HamiltonDataType.U16_ARRAY), + _HoiTypeRow("List[u32]", (53, 55, 54, 56), HamiltonDataType.U32_ARRAY), + _HoiTypeRow("List[bool]", (66, 68, 67, 69), HamiltonDataType.BOOL_ARRAY), + _HoiTypeRow("HcResult", (70, 72, 71, 73), HamiltonDataType.HC_RESULT, is_complex=True), + _HoiTypeRow( + "struct", (57, 59, 58, 60), HamiltonDataType.STRUCTURE, is_complex=True, is_struct_kind=True + ), + _HoiTypeRow( + "List[struct]", + (61, 63, 62, 64), + HamiltonDataType.STRUCTURE_ARRAY, + is_complex=True, + is_struct_kind=True, + ), + _HoiTypeRow("List[str]", (74, 76, 75, 77), HamiltonDataType.STRING_ARRAY, is_complex=True), + _HoiTypeRow("enum", (78, 80, 79, 81), HamiltonDataType.ENUM, is_complex=True, is_enum_kind=True), + _HoiTypeRow( + "List[enum]", (82, 84, 83, 85), HamiltonDataType.ENUM_ARRAY, is_complex=True, is_enum_kind=True + ), + _HoiTypeRow("i64", (86, 88, 87, 89), HamiltonDataType.I64), + _HoiTypeRow("u64", (90, 92, 91, 93), HamiltonDataType.U64), + _HoiTypeRow("f32", (94, 96, 95, 97), HamiltonDataType.F32), + _HoiTypeRow("f64", (98, 100, 99, 101), HamiltonDataType.F64), + _HoiTypeRow("List[i64]", (102, 104, 103, 105), HamiltonDataType.I64_ARRAY), + _HoiTypeRow("List[u64]", (106, 108, 107, 109), HamiltonDataType.U64_ARRAY), + _HoiTypeRow("List[f32]", (110, 112, 111, 113), HamiltonDataType.F32_ARRAY), + _HoiTypeRow("List[f64]", (114, 116, 115, 117), HamiltonDataType.F64_ARRAY), + _HoiTypeRow("HoiResult", (118, 120, 119, 121), HamiltonDataType.HOI_RESULT, is_complex=True), + _HoiTypeRow("padding", (0, 0, 0, 0), HamiltonDataType.VOID), +) -def is_complex_introspection_type(type_id: int) -> bool: - """Check if introspection type is complex (needs additional bytes). - - Complex types require 3 bytes total: type_id, source_id, struct_id/enum_id +# 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 +) - Args: - type_id: Introspection type ID +# GetStructs wire sentinels for complex field types (HamiltonDataType namespace, not HOI). +# Used as a parsing guard in _parse_struct_field_types. +_COMPLEX_STRUCT_TYPE_IDS: frozenset[int] = frozenset( + { + HamiltonDataType.STRUCTURE, + HamiltonDataType.STRUCTURE_ARRAY, + HamiltonDataType.ENUM, + HamiltonDataType.ENUM_ARRAY, + } +) - Returns: - True if type is complex - """ - return type_id in _COMPLEX_TYPE_IDS +# 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) # ============================================================================ @@ -192,6 +251,362 @@ class ObjectInfo: method_count: int subobject_count: int address: Address + children: Dict[str, "ObjectInfo"] = field(default_factory=dict) + + +class ObjectRegistry: + """Pure key-value cache: path ↔ ObjectInfo and address → path. + + No async logic; all traversal lives in :class:`HamiltonIntrospection`. + """ + + def __init__(self): + self._objects: Dict[str, ObjectInfo] = {} + self._address_to_path: Dict[Address, str] = {} + self._root_address: Optional[Address] = None + + def set_root_address(self, address: Address) -> None: + self._root_address = address + + def get_root_address(self) -> Optional[Address]: + return self._root_address + + def register(self, path: str, obj: ObjectInfo) -> None: + self._objects[path] = obj + self._address_to_path[obj.address] = path + + def address_for(self, path: str) -> Optional[Address]: + obj = self._objects.get(path) + return obj.address if obj is not None else None + + def path(self, address: Address) -> Optional[str]: + return self._address_to_path.get(address) + + +@dataclass +class FirmwareTreeNode: + """One node in a discovered firmware object tree.""" + + path: str + address: Address + object_info: ObjectInfo + supported_interface0_methods: Set[int] = field(default_factory=set) + children: List["FirmwareTreeNode"] = field(default_factory=list) + + def format_lines( + self, prefix: str = "", is_last: bool = True, is_root: bool = False + ) -> List[str]: + # Most objects expose the full Interface-0 contract (1..6). Hide it in + # default rendering to keep large trees readable; only show deviations. + full_i0_contract = {1, 2, 3, 4, 5, 6} + show_i0 = self.supported_interface0_methods != full_i0_contract + i0_suffix = "" + if show_i0: + method_ids = ",".join(str(v) for v in sorted(self.supported_interface0_methods)) + i0_suffix = f", i0=[{method_ids}]" + branch = "" if is_root else ("└─ " if is_last else "├─ ") + lines = [ + f"{prefix}{branch}{self.path} @ {self.address} " + f"(methods={self.object_info.method_count}, subobjects={self.object_info.subobject_count}" + f"{i0_suffix})" + ] + child_prefix = prefix + (" " if is_last or is_root else "│ ") + for idx, child in enumerate(self.children): + child_is_last = idx == len(self.children) - 1 + lines.extend(child.format_lines(prefix=child_prefix, is_last=child_is_last, is_root=False)) + return lines + + def __str__(self) -> str: + return "\n".join(self.format_lines(is_root=True)) + + +def flatten_firmware_tree(node: FirmwareTreeNode) -> List[Tuple[str, Address, ObjectInfo]]: + """Preorder flattening of a :class:`FirmwareTreeNode` for path-keyed lookups. + + Returns ``(dot_path, address, object_info)`` for each node (root first, DFS). + """ + out: List[Tuple[str, Address, ObjectInfo]] = [] + + def walk(n: FirmwareTreeNode) -> None: + out.append((n.path, n.address, n.object_info)) + for child in n.children: + walk(child) + + walk(node) + return out + + +@dataclass +class MethodParamType: + """A method parameter or return type from GetMethod, in the HOI introspection namespace. + + ``wire_type`` is the ``HamiltonDataType`` this HOI kind maps to on the wire — + the bridge between the direction-encoded HOI IDs and the wire encoding layer. + ``direction`` records whether this entry is In/Out/InOut/RetVal in the method signature. + + Struct/enum references additionally carry source_id and ref_id: + source_id 1=global, 2=local, 3=network, 4=node-global. + ref_id is the struct/enum index within the pool identified by source_id. + """ + + wire_type: HamiltonDataType + direction: Direction + source_id: Optional[int] = None + ref_id: Optional[int] = None + _byte_width: int = 1 # bytes consumed from the wire blob + + @property + def is_struct_ref(self) -> bool: + return self.wire_type in (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY) + + @property + def is_enum_ref(self) -> bool: + return self.wire_type in (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY) + + @property + def is_argument(self) -> bool: + """True if this is an input parameter (In or InOut).""" + return self.direction in (Direction.In, Direction.InOut) + + @property + def is_return(self) -> bool: + """True if this is a return value (Out or RetVal).""" + return self.direction in (Direction.Out, Direction.RetVal) + + def resolve_name( + self, + registry: Optional["TypeRegistry"] = None, + ho_interface_id: Optional[int] = None, + ) -> str: + """Resolve to a human-readable name, optionally using a TypeRegistry for struct/enum names.""" + base = self.wire_type.name.lower() + if self.source_id is None or self.ref_id is None: + return base + if self.is_struct_ref: + if registry is not None: + s = registry.resolve_struct(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if s: + return s.name + return f"{base}(iface={self.source_id}, id={self.ref_id})" + if self.is_enum_ref: + if registry is not None: + e = registry.resolve_enum(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if e: + return e.name + return f"{base}(iface={self.source_id}, id={self.ref_id})" + return f"{base}(iface={self.source_id}, id={self.ref_id})" + + +def _parse_method_param_types( + data: bytes | list[int], +) -> List[MethodParamType]: + """Parse GetMethod parameterTypes byte stream. + + Source: HoiObject.HandleStruct in HoiObject.cs. + + Encoding per entry: + - Simple type (not in _COMPLEX_METHOD_TYPE_IDS): ``[type_id]`` — 1 byte. + - source_id 1/2/3 (global/local/network): ``[type_id, source_id, ref_id]`` — 3 bytes. + - source_id 4 (node-global): ``[type_id, 4, index, '"', FormatAddress_bytes..., '"', ' ']``. + FormatAddress encodes Module+Node as hex byte pairs, wrapped in ASCII double-quotes. + The index byte is the struct/enum index within the node-global pool. + """ + _NODE_GLOBAL = 4 + _QUOTE = 0x22 + _SPACE = 0x20 + + ints = list(data) if isinstance(data, bytes) else data + result: List[MethodParamType] = [] + i = 0 + while i < len(ints): + tid = ints[i] + wire_type, direction = _HOI_ID_TO_WIRE.get(tid, (HamiltonDataType.VOID, Direction.In)) + if tid in _COMPLEX_METHOD_TYPE_IDS and i + 2 < len(ints): + source_id = ints[i + 1] + ref_id = ints[i + 2] + if source_id == _NODE_GLOBAL: + # [type_id, 4, index, '"', FormatAddress_bytes..., '"', ' '] + end = i + 4 # byte after opening '"' + while end < len(ints) and ints[end] != _QUOTE: + end += 1 + end += 1 # consume closing '"' + if end < len(ints) and ints[end] == _SPACE: + end += 1 # consume trailing ' ' + result.append( + MethodParamType( + wire_type, direction, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=end - i + ) + ) + i = end + else: + result.append( + MethodParamType(wire_type, direction, source_id=source_id, ref_id=ref_id, _byte_width=3) + ) + i += 3 + else: + result.append(MethodParamType(wire_type, direction)) + i += 1 + return result + + +@dataclass +class StructFieldType: + """A struct field type from GetStructs, in the HamiltonDataType wire namespace. + + ``type_id`` is a ``HamiltonDataType`` value — the wire encoding type for this field. + Unlike ``MethodParamType``, struct fields have no direction concept. + + Complex references (STRUCTURE/ENUM) additionally carry source_id and ref_id: + source_id 1=global, 2=local, 3=network, 4=node-global. + ref_id is the struct/enum index within the pool identified by source_id. + """ + + type_id: HamiltonDataType + source_id: Optional[int] = None + ref_id: Optional[int] = None + _byte_width: int = 1 # bytes consumed from the wire blob (1=simple, 3=ref, 7=node-global) + + @property + def is_complex(self) -> bool: + return self.type_id in ( + HamiltonDataType.STRUCTURE, + HamiltonDataType.STRUCTURE_ARRAY, + HamiltonDataType.ENUM, + HamiltonDataType.ENUM_ARRAY, + ) + + @property + def is_struct_ref(self) -> bool: + return self.type_id in (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY) + + @property + def is_enum_ref(self) -> bool: + return self.type_id in (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY) + + def resolve_name( + self, + registry: Optional["TypeRegistry"] = None, + ho_interface_id: Optional[int] = None, + ) -> str: + """Resolve to a human-readable type name, optionally using a TypeRegistry for struct/enum names.""" + if self.is_complex and self.source_id is not None and self.ref_id is not None: + if registry is not None: + if self.is_struct_ref: + s = registry.resolve_struct(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if s: + return f"struct({s.name})" + elif self.is_enum_ref: + e = registry.resolve_enum(self.source_id, self.ref_id, ho_interface_id=ho_interface_id) + if e: + return e.name + return f"ref(iface={self.source_id}, id={self.ref_id})" + return resolve_type_id(self.type_id) + + +def _parse_struct_field_types( + data: bytes | list[int], +) -> List[StructFieldType]: + """Parse GetStructs structureElementTypes byte stream. + + Source: HoiObject.GetStructs in HoiObject.cs. + + Encoding per entry: + - Simple type (not in _COMPLEX_STRUCT_TYPE_IDS): ``[type_id]`` — 1 byte. + - source_id 1/2/3 (global/local/network): ``[type_id, source_id, ref_id]`` — 3 bytes. + - source_id 4 (node-global, scope.mAddress.ModuleID != 0): + ``[type_id, 4, index, ModHi, ModLo, NodeHi, NodeLo]`` — 7 bytes. + The 4 raw address bytes are written when the node-global object has a non-zero + ModuleID, which is always true for real node-global objects on this instrument. + """ + _NODE_GLOBAL = 4 + _NODE_GLOBAL_WIDTH = 7 + + ints = list(data) if isinstance(data, bytes) else data + result: List[StructFieldType] = [] + i = 0 + while i < len(ints): + tid = ints[i] + wire_type = HamiltonDataType(tid) + if tid in _COMPLEX_STRUCT_TYPE_IDS and i + 2 < len(ints): + source_id = ints[i + 1] + ref_id = ints[i + 2] + if source_id == _NODE_GLOBAL: + # [type_id, 4, index, ModHi, ModLo, NodeHi, NodeLo] = 7 bytes + result.append( + StructFieldType( + wire_type, source_id=_NODE_GLOBAL, ref_id=ref_id, _byte_width=_NODE_GLOBAL_WIDTH + ) + ) + i += _NODE_GLOBAL_WIDTH + else: + result.append(StructFieldType(wire_type, source_id=source_id, ref_id=ref_id, _byte_width=3)) + i += 3 + else: + result.append(StructFieldType(wire_type)) + i += 1 + return result + + +def _parse_type_ids(raw: str | bytes | None) -> List[MethodParamType]: + """Parse GetMethod parameterTypes blob. Thin wrapper around _parse_method_param_types. + + Accepts bytes (preferred) or str — the device sends STRING (15) but the + payload is binary, so callers must use parse_next_raw() to avoid UTF-8 errors. + """ + if raw is None: + return [] + data: list[int] = list(raw) if isinstance(raw, bytes) else [ord(c) for c in raw] + return _parse_method_param_types(data) + + +@dataclass +class MethodFieldDescriptor: + """Canonical representation of one method parameter/return field.""" + + name: str + type_name: str + + +@dataclass +class MethodDescriptor: + """Canonical normalized representation of a method signature.""" + + interface_id: int + method_id: int + name: str + params: list[MethodFieldDescriptor] = field(default_factory=list) + returns: list[MethodFieldDescriptor] = field(default_factory=list) + return_shape: Literal["void", "scalar", "record"] = "void" + + @property + def id_string(self) -> str: + return f"[{self.interface_id}:{self.method_id}]" + + def signature_string(self) -> str: + """Render the canonical method descriptor as a signature string.""" + if self.params: + param_str = ", ".join(f"{p.name}: {p.type_name}" for p in self.params) + else: + param_str = "void" + + if self.return_shape == "void" or not self.returns: + return_str = "void" + elif self.return_shape == "scalar" and len(self.returns) == 1: + ret = self.returns[0] + return_str = f"{ret.name}: {ret.type_name}" if ret.name != "ret0" else ret.type_name + else: + return_str = "{ " + ", ".join(f"{r.name}: {r.type_name}" for r in self.returns) + " }" + + return f"{self.id_string} {self.name}({param_str}) -> {return_str}" + + def to_dict(self) -> dict: + return { + "name": self.name, + "id": self.id_string, + "signature": self.signature_string(), + "params": [{"name": p.name, "type": p.type_name} for p in self.params], + "returns": [{"name": r.name, "type": r.type_name} for r in self.returns], + } @dataclass @@ -202,65 +617,196 @@ class MethodInfo: call_type: int method_id: int name: str - parameter_types: list[int] = field( - default_factory=list - ) # Decoded parameter type IDs (Argument category) - parameter_labels: list[str] = field(default_factory=list) # Parameter names (if available) - return_types: list[int] = field( - default_factory=list - ) # Decoded return type IDs (ReturnElement/ReturnValue category) - return_labels: list[str] = field(default_factory=list) # Return names (if available) - - def get_signature_string(self) -> str: - """Get method signature as a readable string.""" - # Decode parameter types to readable names + parameter_types: list[MethodParamType] = field(default_factory=list) + parameter_labels: list[str] = field(default_factory=list) + return_types: list[MethodParamType] = field(default_factory=list) + return_labels: list[str] = field(default_factory=list) + + def describe(self, registry: Optional["TypeRegistry"] = None) -> MethodDescriptor: + """Return the canonical normalized method descriptor used by all serializers.""" + iid = self.interface_id + params: list[MethodFieldDescriptor] = [] if self.parameter_types: - param_type_names = [resolve_introspection_type_name(tid) for tid in self.parameter_types] - - # If we have labels, use them; otherwise just show types - if self.parameter_labels and len(self.parameter_labels) == len(param_type_names): - # Format as "param1: type1, param2: type2" - params = [ - f"{label}: {type_name}" - for label, type_name in zip(self.parameter_labels, param_type_names) - ] - param_str = ", ".join(params) - else: - # Just show types - param_str = ", ".join(param_type_names) - else: - param_str = "void" - - # Decode return types to readable names + param_type_names = [ + pt.resolve_name(registry, ho_interface_id=iid) for pt in self.parameter_types + ] + for i, type_name in enumerate(param_type_names): + label = self.parameter_labels[i] if i < len(self.parameter_labels) else None + params.append(MethodFieldDescriptor(name=label or f"arg{i}", type_name=type_name)) + + returns: list[MethodFieldDescriptor] = [] + return_shape: Literal["void", "scalar", "record"] = "void" if self.return_types: - return_type_names = [resolve_introspection_type_name(tid) for tid in self.return_types] - return_categories = [get_introspection_type_category(tid) for tid in self.return_types] - - # Format return based on category - if any(cat == "ReturnElement" for cat in return_categories): - # Multiple return values -> struct format - if self.return_labels and len(self.return_labels) == len(return_type_names): - # Format as "{ label1: type1, label2: type2 }" - returns = [ - f"{label}: {type_name}" - for label, type_name in zip(self.return_labels, return_type_names) - ] - return_str = f"{{ {', '.join(returns)} }}" - else: - # Just show types - return_str = f"{{ {', '.join(return_type_names)} }}" - elif len(return_type_names) == 1: - # Single return value - if self.return_labels and len(self.return_labels) == 1: - return_str = f"{self.return_labels[0]}: {return_type_names[0]}" - else: - return_str = return_type_names[0] - else: - return_str = "void" - else: - return_str = "void" + return_type_names = [ + rt.resolve_name(registry, ho_interface_id=iid) for rt in self.return_types + ] + for i, type_name in enumerate(return_type_names): + label = self.return_labels[i] if i < len(self.return_labels) else None + returns.append(MethodFieldDescriptor(name=label or f"ret{i}", type_name=type_name)) + if len(returns) == 1 and not any(rt.direction == Direction.Out for rt in self.return_types): + return_shape = "scalar" + elif len(returns) > 0: + # Includes Out/ReturnElement records and explicit multi-return methods. + return_shape = "record" + + return MethodDescriptor( + interface_id=self.interface_id, + method_id=self.method_id, + name=self.name, + params=params, + returns=returns, + return_shape=return_shape, + ) - return f"{self.name}({param_str}) -> {return_str}" + def get_signature_string(self, registry: Optional["TypeRegistry"] = None) -> str: + """Get method signature as a readable string. + + If a TypeRegistry is provided, struct/enum references are resolved to + their names (e.g. PickupTipParameters instead of structure(source=2, ref=1)). + """ + return self.describe(registry).signature_string() + + def to_dict(self, registry: Optional["TypeRegistry"] = None) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return self.describe(registry).to_dict() + + +@dataclass +class TypeRegistry: + """Resolved type information for one object. + + Built once from introspection during setup. Caches structs, enums, and + interface info so method signatures can be fully resolved without additional + device calls. Use build_type_registry() to create. + + Source ID semantics (from piglet — the middle byte of a struct/enum type triple): + source_id=1: Global pool (shared type definitions from global objects); ref_id is + 1-based into that flat list (see GlobalTypePool.resolve_struct). + source_id=2: Local types on this object; ref_id is 1-based into the per-interface + struct/enum maps in self.structs / self.enums (struct_id / enum_id from GetStructs + / GetEnums is 0-based). This is NOT ``HOI interface id 2``; ``2`` means *local* + in the type encoding; tables are keyed by real interface ids (typically ``1`` for + ``[1:*]`` methods alongside introspection on ``0``). + source_id=3: Built-in / network types (e.g. NetworkResult-shaped); resolve_struct + does not decode these yet — validate behavior vs Piglet or device captures. + + source_id=0 is not emitted by firmware; treat any such ref as unresolvable. + + For source_id=2, pass ``ho_interface_id`` on ``resolve_struct`` / ``resolve_enum`` so + lookup is strict to the owning interface's local table. + + Example (full export registry):: + + registry = await intro.build_type_registry(mph_addr) + method = registry.get_method(interface_id=1, method_id=9) + print(method.get_signature_string(registry)) # PickupTips(tipParameters: PickupTipParameters, ...) + + For notebooks and runtime tooling, prefer :meth:`~HamiltonIntrospection.resolve_signature` + (lazy types) instead of building a full registry first. + """ + + address: Optional[Address] = None + interfaces: Dict[int, "InterfaceInfo"] = field(default_factory=dict) + structs: Dict[int, Dict[int, "StructInfo"]] = field(default_factory=dict) + enums: Dict[int, Dict[int, "EnumInfo"]] = field(default_factory=dict) + methods: List[MethodInfo] = field(default_factory=list) + global_pool: Optional["GlobalTypePool"] = None + + def resolve_struct( + self, + source_id: int, + ref_id: int, + *, + ho_interface_id: Optional[int] = None, + ) -> Optional["StructInfo"]: + """Look up a struct by source_id and ref_id. + + source_id=1: Global pool (1-based ref_id; see GlobalTypePool.resolve_struct). + source_id=2: Local structs (1-based ref_id -> 0-based struct_id in + ``self.structs[ho_interface_id]``). ``ho_interface_id`` is required for + deterministic interface-scoped resolution. + """ + if source_id == 1 and self.global_pool is not None: + return self.global_pool.resolve_struct(ref_id) + if source_id == 2: + idx = ref_id - 1 + if idx < 0: + return None + if ho_interface_id is None: + return None + return self.structs.get(ho_interface_id, {}).get(idx) + if source_id == 3: + return _NETWORK_STRUCTS.get(ref_id) + logger.warning("resolve_struct: unhandled source_id=%d ref_id=%d", source_id, ref_id) + return None + + def resolve_enum( + self, + source_id: int, + ref_id: int, + *, + ho_interface_id: Optional[int] = None, + ) -> Optional["EnumInfo"]: + """Look up an enum by source_id and ref_id. + + source_id=1: Global pool (1-based ref_id). + source_id=2: Local enums (same rules as resolve_struct). ``ho_interface_id`` is + required for strict interface-scoped resolution. + """ + if source_id == 1 and self.global_pool is not None: + return self.global_pool.resolve_enum(ref_id) + if source_id == 2: + idx = ref_id - 1 + if idx < 0: + return None + if ho_interface_id is None: + return None + return self.enums.get(ho_interface_id, {}).get(idx) + return self.enums.get(source_id, {}).get(ref_id) + + def get_method(self, interface_id: int, method_id: int) -> Optional[MethodInfo]: + """Find a method by interface_id and method_id.""" + for m in self.methods: + if m.interface_id == interface_id and m.method_id == method_id: + return m + return None + + def get_interface_ids(self) -> Set[int]: + """Return the set of interface IDs this object implements.""" + return set(self.interfaces.keys()) + + def to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + addr = ( + f"{self.address.module}:{self.address.node}:{self.address.object}" if self.address else None + ) + structs_out: Dict[int, List[dict[str, Any]]] = {} + for iid, struct_table in sorted(self.structs.items()): + structs_out[iid] = [s.to_dict(self) for _, s in sorted(struct_table.items())] + enums_out: Dict[int, List[dict[str, Any]]] = {} + for iid, enum_table in sorted(self.enums.items()): + enums_out[iid] = [e.to_dict() for _, e in sorted(enum_table.items())] + return { + "address": addr, + "interfaces": [info.to_dict() for _, info in sorted(self.interfaces.items())], + "methods": [m.to_dict(self) for m in self.methods], + "structs": structs_out, + "enums": enums_out, + } + + def print_summary(self) -> None: + """Print a summary of all interfaces, structs, enums, and methods.""" + print(f"TypeRegistry for {self.address}") + print(f" Interfaces: {sorted(self.interfaces.keys())}") + for iid, iface in sorted(self.interfaces.items()): + n_structs = len(self.structs.get(iid, {})) + n_enums = len(self.enums.get(iid, {})) + n_methods = sum(1 for m in self.methods if m.interface_id == iid) + print(f" [{iid}] {iface.name}: {n_structs} structs, {n_enums} enums, {n_methods} methods") + for sid, s in sorted(self.structs.get(iid, {}).items()): + print(f" struct {sid}: {s.name} ({len(s.fields)} fields)") + for eid, e in sorted(self.enums.get(iid, {}).items()): + print(f" enum {eid}: {e.name} ({len(e.values)} values)") @dataclass @@ -271,6 +817,10 @@ class InterfaceInfo: name: str version: str + def to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return {"interface_id": self.interface_id, "name": self.name, "version": self.version} + @dataclass class EnumInfo: @@ -280,35 +830,143 @@ class EnumInfo: name: str values: Dict[str, int] + def to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return {"name": self.name, "enum_id": self.enum_id, "values": dict(self.values)} + @dataclass class StructInfo: - """Struct definition from introspection.""" + """Struct definition from introspection. + + ``interface_id`` records which interface this struct was defined on, + enabling ``source_id=0`` (same-interface) resolution in the global pool. + + ``fields`` maps field names to ``StructFieldType`` instances, preserving the + full (type_id, source_id, ref_id) triple for fields that are complex + references (STRUCTURE/ENUM). Call ``get_struct_string(registry)`` + to get human-readable names with struct/enum references resolved. + """ struct_id: int name: str - fields: Dict[str, int] # field_name -> type_id + fields: Dict[str, "StructFieldType"] # field_name -> StructFieldType + interface_id: Optional[int] = None # Interface this struct was defined on @property def field_type_names(self) -> Dict[str, str]: - """Get human-readable field type names.""" - return {field_name: resolve_type_id(type_id) for field_name, type_id in self.fields.items()} + """Get human-readable field type names using HamiltonDataType resolver.""" + return {name: sft.resolve_name() for name, sft in self.fields.items()} + + def to_dict(self, registry: Optional["TypeRegistry"] = None) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + ho_iid = self.interface_id + fields = { + name: sft.resolve_name(registry, ho_interface_id=ho_iid) for name, sft in self.fields.items() + } + d: dict = {"name": self.name, "struct_id": self.struct_id, "fields": fields} + if self.interface_id is not None: + d["interface_id"] = self.interface_id + return d + + def get_struct_string(self, registry: Optional["TypeRegistry"] = None) -> str: + """Get struct definition as a readable string. - def get_struct_string(self) -> str: - """Get struct definition as a readable string.""" + If a TypeRegistry is provided, complex references (struct/enum fields) + are resolved to their names. + """ + ho_iid = self.interface_id field_strs = [ - f"{field_name}: {resolve_type_id(type_id)}" for field_name, type_id in self.fields.items() + f"{name}: {sft.resolve_name(registry, ho_interface_id=ho_iid)}" + for name, sft in self.fields.items() ] fields_str = "\n ".join(field_strs) if field_strs else " (empty)" return f"struct {self.name} {{\n {fields_str}\n}}" +# Populate known network structs now that StructInfo is defined. +# ref_id=3: DateTime — 7 fields: year(U16), month(U8), day(U8), hour(U8), +# minute(U8), second(U8), millisecond(U16). Wire format confirmed via +# GetDeckCalibration on PipettorCalibration. +_NETWORK_STRUCTS[3] = StructInfo( + struct_id=3, + name="DateTime", + fields={ + "year": StructFieldType(HamiltonDataType.U16), + "month": StructFieldType(HamiltonDataType.U8), + "day": StructFieldType(HamiltonDataType.U8), + "hour": StructFieldType(HamiltonDataType.U8), + "minute": StructFieldType(HamiltonDataType.U8), + "second": StructFieldType(HamiltonDataType.U8), + "millisecond": StructFieldType(HamiltonDataType.U16), + }, + interface_id=3, +) + + +@dataclass +class GlobalTypePool: + """Flat, sequentially-indexed pool of structs/enums from global objects. + + Piglet builds this by walking ``robot.globals`` objects, iterating each + interface's structs/enums, and inserting them in encounter order. A + ``source_id=1`` reference uses ``ref_id`` as a **1-based** index into this + pool (piglet subtracts 1 for lookup). + """ + + structs: List[StructInfo] = field(default_factory=list) + enums: List[EnumInfo] = field(default_factory=list) + interface_structs: Dict[int, Dict[int, StructInfo]] = field(default_factory=dict) + + def resolve_struct(self, ref_id: int) -> Optional[StructInfo]: + """Look up global struct by 1-based ref_id.""" + idx = ref_id - 1 # 1-based → 0-based + return self.structs[idx] if 0 <= idx < len(self.structs) else None + + def resolve_struct_local(self, interface_id: int, ref_id: int) -> Optional[StructInfo]: + """Resolve a source_id=0 struct ref within a specific interface.""" + return self.interface_structs.get(interface_id, {}).get(ref_id) + + def resolve_enum(self, ref_id: int) -> Optional[EnumInfo]: + """Look up global enum by 1-based ref_id.""" + idx = ref_id - 1 + return self.enums[idx] if 0 <= idx < len(self.enums) else None + + def to_dict(self) -> dict: + """Serialize to a plain dict suitable for YAML/JSON export.""" + return { + "structs": [s.to_dict() for s in self.structs], + "enums": [e.to_dict() for e in self.enums], + } + + def print_summary(self) -> None: + """Print global pool summary.""" + print(f"GlobalTypePool: {len(self.structs)} structs, {len(self.enums)} enums") + for i, s in enumerate(self.structs): + print(f" struct[{i + 1}]: {s.name} ({len(s.fields)} fields)") + for i, e in enumerate(self.enums): + print(f" enum[{i + 1}]: {e.name} ({len(e.values)} values)") + + +# GetStructs wire format (device sends 4 separate array fragments): +# [0] STRING_ARRAY = struct names (one per struct) +# [1] U32_ARRAY = numberStructureElements — field count for each struct +# [2] U8_ARRAY = structureElementTypes — flat field type bytes (variable width) +# [3] STRING_ARRAY = structureElementDescriptions — flat field names +# +# structureElementTypes byte encoding: +# - Simple types: 1 byte using HamiltonDataType values (40=F32, 23=BOOL, etc.) +# - Complex references: 3 bytes [sentinel, source_id, ref_id] +# sentinel=30 for STRUCTURE, sentinel=32 for ENUM (matches piglet) +# The HamiltonDataType namespace is used here, NOT the introspection type namespace. + + # ============================================================================ # INTROSPECTION COMMAND CLASSES # ============================================================================ -class GetObjectCommand(HamiltonCommand): +class GetObjectCommand(TCPCommand): """Get object metadata (command_id=1).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -319,26 +977,15 @@ class GetObjectCommand(HamiltonCommand): def __init__(self, object_address: Address): super().__init__(object_address) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_object response.""" - # Parse HOI2 DataFragments - parser = HoiParamsParser(data) - - _, name = parser.parse_next() - _, version = parser.parse_next() - _, method_count = parser.parse_next() - _, subobject_count = parser.parse_next() - - return { - "name": name, - "version": version, - "method_count": method_count, - "subobject_count": subobject_count, - } + @dataclass(frozen=True) + class Response: + name: Str + version: Str + method_count: U32 + subobject_count: U16 -class GetMethodCommand(HamiltonCommand): +class GetMethodCommand(TCPCommand): """Get method signature (command_id=2).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -364,67 +1011,67 @@ def parse_response_parameters(cls, data: bytes) -> dict: _, method_id = parser.parse_next() _, name = parser.parse_next() - # The remaining fragments are STRING types containing type IDs as bytes - # Hamilton sends ONE combined list where type IDs encode category (Argument/ReturnElement/ReturnValue) - # First STRING after method name is parameter_types (each byte is a type ID - can be Argument or Return) - # Second STRING (if present) is parameter_labels (comma-separated names - includes both params and returns) - parameter_types_str = None + # The remaining fragments are STRING types containing type IDs as bytes. + # Complex types (struct/enum refs): 3 bytes [type_id, source_id, ref_id] for source_id 1–3; + # node-global (source_id=4): variable-length quote-delimited form — see _parse_method_param_types. + # Labels are comma-separated, one per *logical* parameter (matching MethodParamType count). parameter_labels_str = None if parser.has_remaining(): - _, parameter_types_str = parser.parse_next() + # Fragment 4: parameter_types. Wire type is STRING but payload is binary type IDs; + # use parse_next_raw() to avoid UTF-8 decode failure on bytes 0x80-0xFF. + _, flags, _, param_types_payload = parser.parse_next_raw() + if flags & PADDED_FLAG: + param_types_payload = ( + param_types_payload[:-1] if param_types_payload else param_types_payload + ) + param_types_payload = param_types_payload.rstrip(b"\x00") # STRING null terminator + all_types = _parse_type_ids(param_types_payload) + else: + all_types = [] if parser.has_remaining(): _, parameter_labels_str = parser.parse_next() - # Decode string bytes to type IDs (like piglet does: .as_bytes().to_vec()) - all_type_ids: list[int] = [] - if parameter_types_str: - all_type_ids = [ord(c) for c in parameter_types_str] - - # Parse all labels (comma-separated - includes both parameters and returns) all_labels: list[str] = [] if parameter_labels_str: all_labels = [label.strip() for label in parameter_labels_str.split(",") if label.strip()] - # Categorize by type ID ranges (like piglet does) - # Split into arguments vs returns based on type ID category - parameter_types: list[int] = [] + parameter_types: list[MethodParamType] = [] parameter_labels: list[str] = [] - return_types: list[int] = [] + return_types: list[MethodParamType] = [] return_labels: list[str] = [] - for i, type_id in enumerate(all_type_ids): - category = get_introspection_type_category(type_id) + for i, pt in enumerate(all_types): label = all_labels[i] if i < len(all_labels) else None - if category == "Argument": - parameter_types.append(type_id) + if pt.is_argument: + parameter_types.append(pt) if label: parameter_labels.append(label) - elif category in ("ReturnElement", "ReturnValue"): - return_types.append(type_id) + elif pt.is_return: + return_types.append(pt) if label: return_labels.append(label) - # Unknown types - could be parameters or returns, default to parameters else: - parameter_types.append(type_id) - if label: - parameter_labels.append(label) + raise ValueError( + f"Unknown HOI wire_type={pt.wire_type!r} direction={pt.direction!r}; " + "not in _HOI_ID_TO_WIRE — update _HOI_TYPE_ROWS or add an override." + ) return { "interface_id": interface_id, "call_type": call_type, "method_id": method_id, "name": name, - "parameter_types": parameter_types, # Decoded type IDs (Argument category only) - "parameter_labels": parameter_labels, # Parameter names only - "return_types": return_types, # Decoded type IDs (ReturnElement/ReturnValue only) - "return_labels": return_labels, # Return names only + "parameter_types": parameter_types, + "parameter_labels": parameter_labels, + "return_types": return_types, + "return_labels": return_labels, } -class GetSubobjectAddressCommand(HamiltonCommand): +class GetSubobjectAddressCommand(TCPCommand): """Get subobject address (command_id=3).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -438,22 +1085,21 @@ def __init__(self, object_address: Address, subobject_index: int): def build_parameters(self) -> HoiParams: """Build parameters for get_subobject_address command.""" - return HoiParams().u16(self.subobject_index) # Use u16, not u32 - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_subobject_address response.""" - parser = HoiParamsParser(data) + return HoiParams().u16(self.subobject_index) - _, module_id = parser.parse_next() - _, node_id = parser.parse_next() - _, object_id = parser.parse_next() + @dataclass(frozen=True) + class Response: + module_id: U16 + node_id: U16 + object_id: U16 - return {"address": Address(module_id, node_id, object_id)} +class GetInterfacesCommand(TCPCommand): + """Get available interfaces (command_id=4). -class GetInterfacesCommand(HamiltonCommand): - """Get available interfaces (command_id=4).""" + Firmware signature: InterfaceDescriptors(()) -> interfaceIds: I8_ARRAY, interfaceDescriptors: STRING_ARRAY + Returns 2 columnar fragments, not count+rows. + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -463,25 +1109,20 @@ class GetInterfacesCommand(HamiltonCommand): def __init__(self, object_address: Address): super().__init__(object_address) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_interfaces response.""" - parser = HoiParamsParser(data) + @dataclass(frozen=True) + class Response: + interface_ids: I8Array + interface_names: StrArray - interfaces = [] - _, interface_count = parser.parse_next() - for _ in range(interface_count): - _, interface_id = parser.parse_next() - _, name = parser.parse_next() - _, version = parser.parse_next() - interfaces.append({"interface_id": interface_id, "name": name, "version": version}) +class GetEnumsCommand(TCPCommand): + """Get enum definitions (command_id=5). - return {"interfaces": interfaces} - - -class GetEnumsCommand(HamiltonCommand): - """Get enum definitions (command_id=5).""" + Firmware signature: EnumInfo(interfaceId) -> enumerationNames: STRING_ARRAY, + numberEnumerationValues: U32_ARRAY, enumerationValues: I32_ARRAY, + enumerationValueDescriptions: STRING_ARRAY + Returns 4 columnar fragments, not count+rows. + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -496,32 +1137,15 @@ def build_parameters(self) -> HoiParams: """Build parameters for get_enums command.""" return HoiParams().u8(self.target_interface_id) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_enums response.""" - parser = HoiParamsParser(data) - - enums = [] - _, enum_count = parser.parse_next() - - for _ in range(enum_count): - _, enum_id = parser.parse_next() - _, name = parser.parse_next() - - # Parse enum values - _, value_count = parser.parse_next() - values = {} - for _ in range(value_count): - _, value_name = parser.parse_next() - _, value_value = parser.parse_next() - values[value_name] = value_value + @dataclass(frozen=True) + class Response: + enum_names: StrArray + value_counts: U32Array + values: I32Array + value_names: StrArray - enums.append({"enum_id": enum_id, "name": name, "values": values}) - return {"enums": enums} - - -class GetStructsCommand(HamiltonCommand): +class GetStructsCommand(TCPCommand): """Get struct definitions (command_id=6).""" protocol = HamiltonProtocol.OBJECT_DISCOVERY @@ -537,29 +1161,36 @@ def build_parameters(self) -> HoiParams: """Build parameters for get_structs command.""" return HoiParams().u8(self.target_interface_id) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_structs response.""" - parser = HoiParamsParser(data) + @dataclass(frozen=True) + class Response: + """GetStructs returns 4 fragments: struct names, per-struct field counts, flat field type IDs, flat field names. - structs = [] - _, struct_count = parser.parse_next() + Fragment layout (device signature: StructInfo): + [0] STRING_ARRAY = struct names (one per struct) + [1] U32_ARRAY = numberStructureElements: field count for each struct (NOT struct IDs) + [2] U8_ARRAY = structureElementTypes: flat field type IDs across all structs + [3] STRING_ARRAY = structureElementDescriptions: flat field names across all structs + Struct IDs are positional (0-indexed); the device does not send them explicitly. + """ - for _ in range(struct_count): - _, struct_id = parser.parse_next() - _, name = parser.parse_next() + struct_names: StrArray + field_counts: U32Array + field_type_ids: U8Array + field_names: StrArray - # Parse struct fields - _, field_count = parser.parse_next() - fields = {} - for _ in range(field_count): - _, field_name = parser.parse_next() - _, field_type = parser.parse_next() - fields[field_name] = field_type - structs.append({"struct_id": struct_id, "name": name, "fields": fields}) +# ============================================================================ +# INTERFACE 0 METHOD IDS (Object Discovery / Introspection) +# ============================================================================ +# Used to guard calls: only call an Interface 0 method if it is in the set +# returned by get_supported_interface0_method_ids (from the object's method table). - return {"structs": structs} +GET_OBJECT = 1 +GET_METHOD = 2 +GET_SUBOBJECT_ADDRESS = 3 +GET_INTERFACES = 4 +GET_ENUMS = 5 +GET_STRUCTS = 6 # ============================================================================ @@ -568,15 +1199,435 @@ def parse_response_parameters(cls, data: bytes) -> dict: class HamiltonIntrospection: - """High-level API for Hamilton introspection.""" + """High-level API for Hamilton introspection. - def __init__(self, backend): - """Initialize introspection API. + Uses the object's method table (GetMethod) to determine which Interface 0 + methods are supported and only calls those. Interfaces are per-object; + there is no aggregation from children. - Args: - backend: TCPBackend instance + Dependencies are injected as explicit callables rather than a back-reference + to the client, avoiding the circular reference and the need for a Protocol shim. + Prefer :attr:`~pylabrobot.hamilton.transport.tcp.tcp.HamiltonTCPClient.introspection` + over constructing this class directly from application code. + """ + + def __init__( + self, + registry: ObjectRegistry, + global_object_addresses: list[Address], + send_discovery_command: Callable, + send_query: Callable, + ): + self._registry = registry + self._global_object_addresses = global_object_addresses + self._send_discovery_command = send_discovery_command + self._send_query = send_query + # Session caches (invalidated by replacing the HamiltonIntrospection instance, e.g. reconnect). + self._method_table_by_address: Dict[Address, List[MethodInfo]] = {} + self._iface_types: Dict[ + Tuple[Address, int], Tuple[Dict[int, StructInfo], Dict[int, EnumInfo]] + ] = {} + self._interfaces_by_address: Dict[Address, List[InterfaceInfo]] = {} + self._hc_result_text_by_addr_iface: Dict[Tuple[Address, int], Dict[int, str]] = {} + self._supported_i0_by_address: Dict[Address, Set[int]] = {} + self._global_type_pool_singleton: Optional[GlobalTypePool] = None + self._firmware_tree_cache: Optional[FirmwareTreeNode] = None + + def clear_session_caches(self) -> None: + """Drop cached method tables, per-interface structs/enums, and the global type pool.""" + self._method_table_by_address.clear() + self._iface_types.clear() + self._interfaces_by_address.clear() + self._hc_result_text_by_addr_iface.clear() + self._supported_i0_by_address.clear() + self._global_type_pool_singleton = None + self._firmware_tree_cache = None + + def _attach_iface_types_to_registry( + self, registry: TypeRegistry, addr: Address, iface_id: int + ) -> None: + """Copy cached structs/enums for (addr, iface_id) into *registry*.""" + entry = self._iface_types.get((addr, iface_id)) + if entry is not None: + structs_map, enums_map = entry + registry.structs[iface_id] = dict(structs_map) + registry.enums[iface_id] = dict(enums_map) + + async def _ensure_parameter_types_for_signature( + self, + addr: Address, + method: MethodInfo, + registry: TypeRegistry, + ) -> None: + """Load structs/enums needed to resolve *method* signatures (recursive struct walk).""" + seen_structs: Set[Tuple[int, int]] = set() + max_nodes = 256 + + async def walk(types: Sequence[Union[MethodParamType, StructFieldType]], ho_iface: int) -> None: + for pt in types: + if pt.source_id is None or pt.ref_id is None: + continue + if pt.source_id in (1, 3): + continue + if pt.source_id != 2: + continue + if pt.is_enum_ref: + await self.ensure_structs_enums(addr, ho_iface) + self._attach_iface_types_to_registry(registry, addr, ho_iface) + continue + if pt.is_struct_ref: + await self.ensure_structs_enums(addr, ho_iface) + self._attach_iface_types_to_registry(registry, addr, ho_iface) + st = registry.resolve_struct(2, pt.ref_id, ho_interface_id=ho_iface) + if st is None: + continue + field_iface = st.interface_id if st.interface_id is not None else ho_iface + sig = (field_iface, st.struct_id) + if sig in seen_structs: + continue + if len(seen_structs) >= max_nodes: + logger.warning( + "signature struct walk exceeded %d nodes for %s.%s", + max_nodes, + method.name, + st.name, + ) + return + seen_structs.add(sig) + await walk(list(st.fields.values()), field_iface) + + await walk(method.parameter_types, method.interface_id) + await walk(method.return_types, method.interface_id) + + async def _build_minimal_registry_for_signature( + self, addr: Address, method: MethodInfo + ) -> TypeRegistry: + """TypeRegistry with global pool + lazily filled local tables for *method*.""" + pool = await self.ensure_global_type_pool() + registry = TypeRegistry(address=addr, global_pool=pool) + await self._ensure_parameter_types_for_signature(addr, method, registry) + return registry + + async def _build_global_type_pool_impl(self, global_addresses: List[Address]) -> GlobalTypePool: + """Walk global objects and build a :class:`GlobalTypePool` (full firmware-scale pass).""" + pool = GlobalTypePool() + + for addr in global_addresses: + try: + supported = await self.get_supported_interface0_method_ids(addr) + if GET_INTERFACES not in supported: + continue + + interfaces = await self.get_interfaces(addr, _supported=supported) + # source_id=1 refs index into the first non-zero interface's struct/enum list + # (firmware always resolves via interface_id=1; see HoiObject.HandleStruct). + # Populate interface_structs for all interfaces, but only extend the flat pool + # from the first non-zero interface so ref_ids remain valid. + first_nonzero_seen = False + for iface in interfaces: + if iface.interface_id == 0: + continue + if GET_STRUCTS in supported: + structs = await self.get_structs(addr, iface.interface_id) + pool.interface_structs[iface.interface_id] = {s.struct_id: s for s in structs} + if not first_nonzero_seen: + pool.structs.extend(structs) + if GET_ENUMS in supported: + enums = await self.get_enums(addr, iface.interface_id) + if not first_nonzero_seen: + pool.enums.extend(enums) + first_nonzero_seen = True + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.warning("build_global_type_pool failed for %s: %s", addr, e) + + logger.info( + "Global type pool built: %d structs, %d enums from %d global objects", + len(pool.structs), + len(pool.enums), + len(global_addresses), + ) + return pool + + async def ensure_method_table( + self, + address: Union[Address, str], + *, + _supported: Optional[Set[int]] = None, + _object_info: Optional[ObjectInfo] = None, + ) -> List[MethodInfo]: + """Scan Interface 0 GetMethod for *address* once and cache the full ``MethodInfo`` table. + + Pass ``_object_info`` / ``_supported`` when the caller already has them to avoid redundant + Interface-0 queries on the cold path. + """ + addr = await self._resolve_target_address(address) + cached = self._method_table_by_address.get(addr) + if cached is not None: + return cached + cached_supported = self._supported_i0_by_address.get(addr) + if cached_supported is not None and GET_METHOD not in cached_supported: + self._method_table_by_address[addr] = [] + return [] + if _supported is not None and GET_METHOD not in _supported: + self._supported_i0_by_address[addr] = set(_supported) + self._method_table_by_address[addr] = [] + return [] + if _object_info is None: + _object_info = await self.get_object(addr) + methods: List[MethodInfo] = [] + for i in range(_object_info.method_count): + try: + method = await self.get_method(addr, i) + methods.append(method) + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.warning("Failed to get method %d for %s: %s", i, addr, e) + self._method_table_by_address[addr] = methods + self._supported_i0_by_address[addr] = {m.method_id for m in methods if m.interface_id == 0} + return methods + + async def methods_for_interface( + self, address: Union[Address, str], interface_id: int + ) -> List[MethodInfo]: + """Return methods for *interface_id* using the cached method table when warm.""" + addr = await self._resolve_target_address(address) + table = await self.ensure_method_table(addr) + return [m for m in table if m.interface_id == interface_id] + + async def ensure_structs_enums(self, address: Union[Address, str], interface_id: int) -> None: + """Run GetStructs/GetEnums for one HO interface and cache under ``(address, interface_id)``.""" + addr = await self._resolve_target_address(address) + key = (addr, interface_id) + if key in self._iface_types: + return + supported = await self.get_supported_interface0_method_ids(addr) + structs_map: Dict[int, StructInfo] = {} + enums_map: Dict[int, EnumInfo] = {} + if GET_STRUCTS in supported: + structs = await self.get_structs(addr, interface_id) + structs_map = {s.struct_id: s for s in structs} + if GET_ENUMS in supported: + enums = await self.get_enums(addr, interface_id) + enums_map = {e.enum_id: e for e in enums} + self._iface_types[key] = (structs_map, enums_map) + hc_result = next((e for e in enums_map.values() if e.name == "HcResult"), None) + if hc_result is not None: + self._hc_result_text_by_addr_iface[key] = {int(v): n for n, v in hc_result.values.items()} + else: + self._hc_result_text_by_addr_iface[key] = {} + + async def get_interface_name( + self, address: Union[Address, str], interface_id: int + ) -> Optional[str]: + """Return interface name for ``(address, interface_id)`` using session cache.""" + addr = await self._resolve_target_address(address) + infos = self._interfaces_by_address.get(addr) + if infos is None: + infos = await self.get_interfaces(addr) + self._interfaces_by_address[addr] = infos + for info in infos: + if info.interface_id == interface_id: + return info.name + return None + + async def get_hc_result_text( + self, address: Union[Address, str], interface_id: int, code: int + ) -> Optional[str]: + """Resolve HcResult enum text for one interface using cached enums.""" + addr = await self._resolve_target_address(address) + key = (addr, interface_id) + if key not in self._iface_types: + await self.ensure_structs_enums(addr, interface_id) + return self._hc_result_text_by_addr_iface.get(key, {}).get(code) + + async def ensure_global_type_pool( + self, global_addresses: Optional[Sequence[Address]] = None + ) -> GlobalTypePool: + """Return the session-global :class:`GlobalTypePool` (``source_id=1``), building once.""" + if self._global_type_pool_singleton is not None: + return self._global_type_pool_singleton + addrs = ( + list(global_addresses) + if global_addresses is not None + else list(self._global_object_addresses) + ) + self._global_type_pool_singleton = await self._build_global_type_pool_impl(addrs) + return self._global_type_pool_singleton + + async def signature_lines_for_interface( + self, + address: Union[Address, str], + interface_id: int, + *, + max_methods: int = 50, + ) -> List[str]: + """Resolved signature strings for up to *max_methods* methods on *interface_id* (lazy types).""" + addr = await self._resolve_target_address(address) + methods = [m for m in await self.ensure_method_table(addr) if m.interface_id == interface_id][ + :max_methods + ] + lines: List[str] = [] + for m in methods: + reg = await self._build_minimal_registry_for_signature(addr, m) + lines.append(m.get_signature_string(reg)) + return lines + + async def _resolve_target_address(self, addr_or_path: Union[Address, str]) -> Address: + """Resolve Address or dot-path to Address.""" + if isinstance(addr_or_path, str): + return await self.resolve_path(addr_or_path) + return addr_or_path + + async def _walk_node( + self, addr: Address, path: Optional[str], visited: Set[Address] + ) -> Optional[FirmwareTreeNode]: + """Walk one firmware object node, register it, and recurse into children. + + Used by :meth:`_build_firmware_tree` for a full eager DFS. + Pass ``path=None`` to derive the path from the object's own name (root nodes). + Returns ``None`` if *addr* was already visited. """ - self.backend = backend + if addr in visited: + return None + visited.add(addr) + + obj = await self.get_object(addr) + if path is None: + path = obj.name + supported = await self.get_supported_interface0_method_ids(addr) + node = FirmwareTreeNode( + path=path, + address=addr, + object_info=obj, + supported_interface0_methods=supported, + ) + self._registry.register(path, obj) + + # Keep this guard even though Interface-0 method 3 (GetSubobjectAddress) + # appears ubiquitous in current PREP captures. + if GET_SUBOBJECT_ADDRESS not in supported: + return node + + for i in range(obj.subobject_count): + try: + sub_addr, sub_obj = await _subobject_address_and_info(self, addr, i) + obj.children[sub_obj.name] = sub_obj + child = await self._walk_node(sub_addr, f"{path}.{sub_obj.name}", visited) + if child is not None: + node.children.append(child) + except _TRANSIENT_ERRORS: + raise + except Exception as e: + logger.debug("walk child failed for %s idx=%d: %s", addr, i, e) + return node + + async def resolve_path(self, path: str) -> Address: + """Resolve a dot-path (e.g. ``"MLPrepRoot.MphRoot.MPH"``) to an :class:`Address`. + + Checks the registry cache first. On a miss, resolves one segment at a time — + enumerating only the children needed at each level — so deep paths on large + firmware trees do not trigger a full tree walk. + Raises :exc:`KeyError` if the path cannot be found. + """ + cached = self._registry.address_for(path) + if cached is not None: + return cached + + parts = [p for p in path.split(".") if p] + if not parts: + raise KeyError(f"Invalid path: '{path}'") + + root_addr = self._registry.get_root_address() + if root_addr is None: + raise KeyError(f"No root address registered; cannot resolve path '{path}'") + + root_obj = await self.get_object(root_addr) + self._registry.register(root_obj.name, root_obj) + if root_obj.name != parts[0]: + raise KeyError(f"Root object is '{root_obj.name}', not '{parts[0]}'") + if len(parts) == 1: + return root_addr + + current_addr = root_addr + current_path = parts[0] + for part in parts[1:]: + next_path = f"{current_path}.{part}" + cached = self._registry.address_for(next_path) + if cached is not None: + current_addr = cached + current_path = next_path + continue + + obj = await self.get_object(current_addr) + supported = await self.get_supported_interface0_method_ids(current_addr) + if GET_SUBOBJECT_ADDRESS not in supported: + raise KeyError( + f"'{current_path}' does not support GetSubobjectAddress; cannot resolve child '{part}'" + ) + + found: Optional[Address] = None + for i in range(obj.subobject_count): + sub_addr, sub_obj = await _subobject_address_and_info(self, current_addr, i) + self._registry.register(f"{current_path}.{sub_obj.name}", sub_obj) + if sub_obj.name == part: + found = sub_addr + + if found is None: + raise KeyError(f"Child '{part}' not found under '{current_path}'") + current_addr = found + current_path = next_path + + return current_addr + + async def _build_firmware_tree(self) -> FirmwareTreeNode: + """Build a DFS firmware tree from the single registered root address.""" + root_addr = self._registry.get_root_address() + if root_addr is None: + raise RuntimeError("Cannot build firmware tree: no root address registered") + + visited: Set[Address] = set() + node = await self._walk_node(root_addr, None, visited) + if node is None: + raise RuntimeError(f"Root node walk returned None for address {root_addr}") + return node + + async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTreeNode: + """Return cached firmware tree, or build and cache it when missing.""" + if not refresh and self._firmware_tree_cache is not None: + return self._firmware_tree_cache + + self._firmware_tree_cache = await self._build_firmware_tree() + return self._firmware_tree_cache + + async def get_firmware_tree_flat( + self, refresh: bool = False + ) -> List[Tuple[str, Address, ObjectInfo]]: + """Firmware tree as a flat preorder list of ``(path, address, object_info)``.""" + tree = await self.get_firmware_tree(refresh=refresh) + return flatten_firmware_tree(tree) + + async def get_supported_interface0_method_ids(self, address: Address) -> Set[int]: + """Return the set of Interface 0 method IDs this object supports. + + Calls GetObject to get method_count, then GetMethod(address, i) for each + index and collects method_id for every method where interface_id == 0. + Used to guard calls so we never send an Interface 0 command the object + did not advertise. + """ + cached = self._supported_i0_by_address.get(address) + if cached is not None: + return set(cached) + + methods = self._method_table_by_address.get(address) + if methods is None: + obj = await self.get_object(address) + methods = await self.ensure_method_table(address, _object_info=obj) + supported = {m.method_id for m in methods if m.interface_id == 0} + self._supported_i0_by_address[address] = set(supported) + return set(supported) async def get_object(self, address: Address) -> ObjectInfo: """Get object metadata. @@ -588,13 +1639,15 @@ async def get_object(self, address: Address) -> ObjectInfo: Object metadata """ command = GetObjectCommand(address) - response = await self.backend.send_command(command) + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetObjectCommand returned None") return ObjectInfo( - name=response["name"], - version=response["version"], - method_count=response["method_count"], - subobject_count=response["subobject_count"], + name=response.name, + version=response.version, + method_count=int(response.method_count), + subobject_count=int(response.subobject_count), address=address, ) @@ -609,7 +1662,7 @@ async def get_method(self, address: Address, method_index: int) -> MethodInfo: Method signature """ command = GetMethodCommand(address, method_index) - response = await self.backend.send_command(command) + response = await self._send_discovery_command(command) return MethodInfo( interface_id=response["interface_id"], @@ -633,34 +1686,65 @@ async def get_subobject_address(self, address: Address, subobject_index: int) -> Subobject address """ command = GetSubobjectAddressCommand(address, subobject_index) - response = await self.backend.send_command(command) - - # Type: ignore needed because response dict is typed as dict[str, Any] - # but we know 'address' key contains Address object - return response["address"] # type: ignore[no-any-return, return-value] - - async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetSubobjectAddressCommand returned None") + + return Address(response.module_id, response.node_id, response.object_id) + + async def get_interfaces( + self, + address: Address, + *, + _supported: Optional[Set[int]] = None, + ) -> List[InterfaceInfo]: """Get available interfaces. + The device returns 2 columnar fragments: interface_ids (I8_ARRAY) and + interface_names (STRING_ARRAY). Returns [] if the object does not support + GetInterfaces (interface 0, method 4). + Args: address: Object address + _supported: Pre-computed supported Interface 0 method IDs (internal; + avoids redundant device queries when the caller already has them). Returns: List of interface information """ + if _supported is None: + _supported = await self.get_supported_interface0_method_ids(address) + if GET_INTERFACES not in _supported: + logger.debug( + "Object at %s does not support GetInterfaces (interface 0, method 4); returning []", + address, + ) + return [] command = GetInterfacesCommand(address) - response = await self.backend.send_command(command) + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetInterfacesCommand returned None") - return [ + ids = list(response.interface_ids) + names = list(response.interface_names) + infos = [ InterfaceInfo( - interface_id=iface["interface_id"], name=iface["name"], version=iface["version"] + interface_id=int(ids[i]), + name=names[i] if i < len(names) else f"Interface_{ids[i]}", + version="", ) - for iface in response["interfaces"] + for i in range(len(ids)) ] + self._interfaces_by_address[address] = infos + return infos async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo]: """Get enum definitions. + The device returns 4 columnar fragments: enum_names (STRING_ARRAY), + value_counts (U32_ARRAY), values (I32_ARRAY), value_names (STRING_ARRAY). + Values/names are split across enums using the value_counts. + Args: address: Object address interface_id: Interface ID @@ -669,16 +1753,58 @@ async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo] List of enum definitions """ command = GetEnumsCommand(address, interface_id) - response = await self.backend.send_command(command) - - return [ - EnumInfo(enum_id=enum_def["enum_id"], name=enum_def["name"], values=enum_def["values"]) - for enum_def in response["enums"] - ] + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetEnumsCommand returned None") + + enum_names = list(response.enum_names) + value_counts = list(response.value_counts) + all_values = list(response.values) + all_value_names = list(response.value_names) + n_enums = len(enum_names) + if n_enums == 0: + return [] + offset = 0 + result: List[EnumInfo] = [] + for i in range(n_enums): + cnt = int(value_counts[i]) if i < len(value_counts) else 0 + names_slice = all_value_names[offset : offset + cnt] + values_slice = all_values[offset : offset + cnt] + vals = dict(zip(names_slice, values_slice)) + result.append(EnumInfo(enum_id=i, name=enum_names[i], values=vals)) + offset += cnt + return result + + async def _get_structs_raw(self, address: Address, interface_id: int) -> tuple[bytes, List[dict]]: + """Get raw GetStructs response bytes and a fragment-by-fragment breakdown. + + Use this to see exactly what the device sends so response parsing can + match the wire format. Returns (params_bytes, inspect_hoi_params(params)). + + Example: + raw, fragments = await intro.get_structs_raw(mph_addr, 1) + for i, f in enumerate(fragments): + print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") + """ + command = GetStructsCommand(address, interface_id) + result = await self._send_query(command) + if result is None: + raise RuntimeError("GetStructs query returned no data.") + (params,) = result + return params, inspect_hoi_params(params) async def get_structs(self, address: Address, interface_id: int) -> List[StructInfo]: """Get struct definitions. + The device returns 4 fragments per the StructInfo signature: + [0] struct_names (StrArray): one name per struct + [1] field_counts (U32Array): numberStructureElements — how many fields each struct has + [2] field_type_ids (U8Array): flat field type IDs across all structs + [3] field_names (StrArray): flat field names across all structs + + Struct IDs are positional (0-indexed); the device does not send them explicitly. + field_counts drives the field-to-struct assignment (no even-split heuristic). + Args: address: Object address interface_id: Interface ID @@ -687,146 +1813,233 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI List of struct definitions """ command = GetStructsCommand(address, interface_id) - response = await self.backend.send_command(command) - - return [ - StructInfo( - struct_id=struct_def["struct_id"], name=struct_def["name"], fields=struct_def["fields"] - ) - for struct_def in response["structs"] - ] - - async def get_all_methods(self, address: Address) -> List[MethodInfo]: - """Get all methods for an object. - - Args: - address: Object address - - Returns: - List of all method signatures - """ - # First get object info to know how many methods there are - object_info = await self.get_object(address) - - methods = [] - for i in range(object_info.method_count): - try: - method = await self.get_method(address, i) - methods.append(method) - except Exception as e: - logger.warning(f"Failed to get method {i} for {address}: {e}") - - return methods - - async def discover_hierarchy(self, root_address: Address) -> Dict[str, Any]: - """Recursively discover object hierarchy. + response = await self._send_discovery_command(command) + if response is None: + raise RuntimeError("GetStructsCommand returned None") + + struct_names = list(response.struct_names) + # field_counts = numberStructureElements from the device: logical fields per struct. + # Struct IDs are positional (0-indexed); the device does not send them. + field_counts = [int(c) for c in response.field_counts] + type_bytes = list(response.field_type_ids) # flat byte array; entries are 1, 3, or 7 bytes wide + field_names = list(response.field_names) + n_structs = len(field_counts) + if n_structs == 0: + return [] + + # Walk type_bytes with a byte-level cursor. Width varies: 1=simple, 3=ref (source_id 1–3), + # 7=node-global (source_id=4). field_counts gives logical field count per struct, + # not bytes — _parse_struct_field_types tracks exact byte consumption via _byte_width. + byte_offset = 0 # cursor into type_bytes + name_offset = 0 # cursor into field_names + result: List[StructInfo] = [] + for i, cnt in enumerate(field_counts): + name = struct_names[i] if i < len(struct_names) else f"Struct_{i}" + parsed = _parse_struct_field_types(type_bytes[byte_offset:]) + # Consume exactly `cnt` logical entries; advance byte_offset by the bytes used. + type_entries = parsed[:cnt] + bytes_used = sum(pt._byte_width for pt in type_entries) + names_slice = field_names[name_offset : name_offset + cnt] + fields = dict(zip(names_slice, type_entries)) + result.append(StructInfo(struct_id=i, name=name, fields=fields, interface_id=interface_id)) + byte_offset += bytes_used + name_offset += cnt + return result + + async def build_type_registry( + self, + address: Union[Address, str], + global_pool: Optional[GlobalTypePool] = None, + *, + _supported: Optional[Set[int]] = None, + ) -> TypeRegistry: + """Build a complete TypeRegistry for an object. + + Uses InterfaceDescriptors (get_interfaces) as the canonical source of + interface IDs; then queries structs and enums only for those interfaces. + Only calls Interface 0 methods that the object supports; skips unsupported + commands and builds a partial registry. Args: - root_address: Root object address + address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. If omitted, + uses :meth:`ensure_global_type_pool` (same as :meth:`_build_minimal_registry_for_signature`). + _supported: Pre-computed supported Interface 0 method IDs (internal; + avoids redundant device queries when the caller already has them). Returns: - Nested dictionary of discovered objects + TypeRegistry with all type information for this object """ - hierarchy = {} - - try: - # Get root object info - root_info = await self.get_object(root_address) - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["info"] = root_info # type: ignore[assignment] - - # Discover subobjects - subobjects = {} - for i in range(root_info.subobject_count): - try: - subaddress = await self.get_subobject_address(root_address, i) - subobjects[f"subobject_{i}"] = await self.discover_hierarchy(subaddress) - except Exception as e: - logger.warning(f"Failed to discover subobject {i}: {e}") + address = await self._resolve_target_address(address) + if global_pool is None: + global_pool = await self.ensure_global_type_pool() + registry = TypeRegistry(address=address, global_pool=global_pool) + if _supported is None: + _supported = await self.get_supported_interface0_method_ids(address) + + if GET_INTERFACES in _supported: + interfaces = await self.get_interfaces(address, _supported=_supported) + for iface in interfaces: + registry.interfaces[iface.interface_id] = iface + else: + interfaces = [] - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["subobjects"] = subobjects # type: ignore[assignment] + if GET_METHOD in _supported: + registry.methods = await self.ensure_method_table(address) + else: + registry.methods = [] - # Discover methods - methods = await self.get_all_methods(root_address) - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["methods"] = methods # type: ignore[assignment] + for iface in interfaces: + if GET_STRUCTS in _supported or GET_ENUMS in _supported: + await self.ensure_structs_enums(address, iface.interface_id) + self._attach_iface_types_to_registry(registry, address, iface.interface_id) - except Exception as e: - logger.error(f"Failed to discover hierarchy for {root_address}: {e}") - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["error"] = str(e) # type: ignore[assignment] + return registry - return hierarchy + async def build_type_registry_with_children( + self, + address: Union[Address, str], + subobject_addresses: Optional[List[Address]] = None, + global_pool: Optional[GlobalTypePool] = None, + ) -> TypeRegistry: + """Build a TypeRegistry that includes structs/enums from child objects. - async def discover_all_objects(self, root_addresses: List[Address]) -> Dict[str, Any]: - """Discover all objects starting from root addresses. + Complex type references (e.g. type_57 = PickupTipParameters) may be + defined on a child object's interface rather than the parent. This method + builds the parent's registry, then merges in types from each child so + that MethodParamType.resolve_name() can find them. Args: - root_addresses: List of root addresses to start discovery from + address: Parent object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + subobject_addresses: Optional list of child addresses to include. + If None, all direct subobjects are discovered automatically. + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. If omitted, + :meth:`build_type_registry` attaches the session pool automatically. Returns: - Dictionary mapping address strings to discovered hierarchies + TypeRegistry that can resolve types from both parent and children. """ - all_objects = {} + address = await self._resolve_target_address(address) + supported = await self.get_supported_interface0_method_ids(address) + registry = await self.build_type_registry( + address, global_pool=global_pool, _supported=supported + ) - for root_address in root_addresses: + if subobject_addresses is None: + if GET_SUBOBJECT_ADDRESS not in supported: + subobject_addresses = [] + else: + obj_info = await self.get_object(address) + subobject_addresses = [] + for i in range(obj_info.subobject_count): + try: + sub_addr = await self.get_subobject_address(address, i) + subobject_addresses.append(sub_addr) + except _TRANSIENT_ERRORS: + raise + except Exception: + logger.debug("get_subobject_address(%d) failed for %s", i, address) + + for sub_addr in subobject_addresses: try: - hierarchy = await self.discover_hierarchy(root_address) - all_objects[str(root_address)] = hierarchy + child_reg = await self.build_type_registry(sub_addr) + for iid, struct_map in child_reg.structs.items(): + registry.structs.setdefault(iid, {}).update(struct_map) + for iid, enum_map in child_reg.enums.items(): + registry.enums.setdefault(iid, {}).update(enum_map) + except _TRANSIENT_ERRORS: + raise except Exception as e: - logger.error(f"Failed to discover objects from {root_address}: {e}") - all_objects[str(root_address)] = {"error": str(e)} - - return all_objects + logger.debug("build_type_registry failed for child %s: %s", sub_addr, e) - def print_method_signatures(self, methods: List[MethodInfo]) -> None: - """Print method signatures in a readable format. + return registry - Args: - methods: List of MethodInfo objects to print - """ - print("Method Signatures:") - print("=" * 50) - for method in methods: - print(f" {method.get_signature_string()}") - print(f" Interface: {method.interface_id}, Method ID: {method.method_id}") - print() + async def build_global_type_pool( + self, + global_addresses: List[Address], + ) -> GlobalTypePool: + """Build a fresh global type pool from *global_addresses* (full walk; not the session singleton). - def print_struct_definitions(self, structs: List[StructInfo]) -> None: - """Print struct definitions in a readable format. + Mirrors piglet: walk each global object, iterate interfaces, collect structs/enums in + encounter order for ``source_id=1`` lookups. For lazy signature resolution on a live + session, use :meth:`ensure_global_type_pool` so the pool is built once and reused. Args: - structs: List of StructInfo objects to print + global_addresses: List of global object addresses + (from :attr:`~pylabrobot.hamilton.transport.tcp.tcp.HamiltonTCPClient.global_object_addresses`). + + Returns: + GlobalTypePool with all global structs and enums. """ - print("Struct Definitions:") - print("=" * 50) - for struct in structs: - print(struct.get_struct_string()) - print() + return await self._build_global_type_pool_impl(global_addresses) - def get_methods_by_name(self, methods: List[MethodInfo], name_pattern: str) -> List[MethodInfo]: - """Filter methods by name pattern. + async def get_method_by_id( + self, + address: Union[Address, str], + interface_id: int, + method_id: int, + registry: Optional[TypeRegistry] = None, + ) -> Optional[MethodInfo]: + """Return the method with the given interface_id and method_id (action id). + + When a TypeRegistry is provided and contains the method, returns it + without any device round-trips. Falls back to a full device scan only + when no registry is available or the method isn't in it. Args: - methods: List of MethodInfo objects to filter - name_pattern: Name pattern to search for (case-insensitive) + address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + interface_id: Interface ID (e.g. 1 for IChannel/IMph). + method_id: Method/command ID (e.g. 9 for PickupTips). + registry: Optional TypeRegistry with cached methods. Returns: - List of methods matching the name pattern + MethodInfo for the matching method, or None if not found. """ - return [method for method in methods if name_pattern.lower() in method.name.lower()] - - def get_methods_by_interface( - self, methods: List[MethodInfo], interface_id: int - ) -> List[MethodInfo]: - """Filter methods by interface ID. - - Args: - methods: List of MethodInfo objects to filter - interface_id: Interface ID to filter by + if registry is not None: + cached = registry.get_method(interface_id, method_id) + if cached is not None: + return cached + address = await self._resolve_target_address(address) + methods = await self.ensure_method_table(address) + for m in methods: + if m.interface_id == interface_id and m.method_id == method_id: + return m + return None + + async def resolve_signature( + self, + address: Union[Address, str], + interface_id: int, + method_id: int, + registry: Optional[TypeRegistry] = None, + ) -> str: + """Return a fully resolved method signature string. + + When *registry* is omitted, loads only the + method table, global pool, and structs/enums needed for this signature (no full + :meth:`build_type_registry`). Pass an explicit *registry* for export/golden parity. + + Example:: + + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9) + print(sig) + # PickupTips(tipParameters: PickupTipParameters, finalZ: f32, ...) -> ... Returns: - List of methods from the specified interface + Human-readable signature string, or a descriptive error string. """ - return [method for method in methods if method.interface_id == interface_id] + address = await self._resolve_target_address(address) + if registry is not None: + method = await self.get_method_by_id(address, interface_id, method_id, registry=registry) + if method is None: + return f"" + return method.get_signature_string(registry) + methods = await self.ensure_method_table(address) + method = next( + (m for m in methods if m.interface_id == interface_id and m.method_id == method_id), + None, + ) + if method is None: + return f"" + reg = await self._build_minimal_registry_for_signature(address, method) + return method.get_signature_string(reg) diff --git a/pylabrobot/hamilton/transport/tcp/messages.py b/pylabrobot/hamilton/transport/tcp/messages.py index ea1952b245c..0cb4e69d294 100644 --- a/pylabrobot/hamilton/transport/tcp/messages.py +++ b/pylabrobot/hamilton/transport/tcp/messages.py @@ -1,42 +1,28 @@ -"""High-level Hamilton message builders and response parsers. - -This module provides user-facing message builders and their corresponding -response parsers. Each message type is paired with its response type: - -Request Builders: -- InitMessage: Builds IP[Connection] for initialization -- RegistrationMessage: Builds IP[HARP[Registration]] for discovery -- CommandMessage: Builds IP[HARP[HOI]] for method calls - -Response Parsers: -- InitResponse: Parses initialization responses -- RegistrationResponse: Parses registration responses -- CommandResponse: Parses command responses - -This pairing creates symmetry and makes correlation explicit. - -Architectural Note: -Parameter encoding (HoiParams/HoiParamsParser) is conceptually a separate layer -in the Hamilton protocol architecture (per documented architecture), but is -implemented here for efficiency since it's exclusively used by HOI messages. -This preserves the conceptual separation while optimizing implementation. - -Example: - # Build and send - msg = CommandMessage(dest, interface_id=0, method_id=42) - msg.add_i32(100) - packet_bytes = msg.build(src, seq=1) - - # Parse response - response = CommandResponse.from_bytes(received_bytes) - params = response.hoi.params +"""Framing and protocol message layer for Hamilton TCP. + +HoiParams is a fragment accumulator with add(value, wire_type) and +from_struct(obj); it has no type-specific encoding logic and delegates all +encoding to WireType.encode_into in wire_types. HoiParamsParser is a thin +cursor over sequential DataFragments; it reads [type_id:1][flags:1][length:2] +[data:N] headers and delegates value decoding to wire_types.decode_fragment(). +parse_into_struct() is the dataclass codec that uses WireType annotations to +decode fragment sequences into typed instances. + +Also: message builders (CommandMessage, InitMessage, RegistrationMessage) and +response parsers (CommandResponse, InitResponse, RegistrationResponse). + +STATUS/COMMAND exception param parsing and :class:`~pylabrobot.hamilton.transport.tcp.hoi_error.HoiError` +live in :mod:`pylabrobot.hamilton.transport.tcp.hoi_error`. """ from __future__ import annotations +import logging from dataclasses import dataclass -from typing import Any +from dataclasses import fields as dc_fields +from typing import Any, List, cast, get_args, get_origin, get_type_hints +from pylabrobot.hamilton.transport.tcp.hoi_error import parse_hc_results_from_semicolon_string from pylabrobot.hamilton.transport.tcp.packets import ( Address, HarpPacket, @@ -45,12 +31,20 @@ RegistrationPacket, ) from pylabrobot.hamilton.transport.tcp.protocol import ( - HamiltonDataType, HarpTransportableProtocol, + Hoi2Action, RegistrationOptionType, ) +from pylabrobot.hamilton.transport.tcp.wire_types import ( + HcResultEntry, + decode_fragment, +) from pylabrobot.io.binary import Reader, Writer +PADDED_FLAG = 0x01 + +logger = logging.getLogger(__name__) + # ============================================================================ # HOI PARAMETER ENCODING - DataFragment wrapping for HOI protocol # ============================================================================ @@ -75,9 +69,9 @@ class HoiParams: [0x03|0x00|0x04|0x00|100][0x0F|0x00|0x05|0x00|"test\0"][0x1C|0x00|...array...] params = (HoiParams() - .i32(100) - .string("test") - .u32_array([1, 2, 3]) + .add(100, I32) + .add("test", Str) + .add([1, 2, 3], U32Array) .build()) """ @@ -89,200 +83,122 @@ def _add_fragment(self, type_id: int, data: bytes, flags: int = 0) -> "HoiParams Creates: [type_id:1][flags:1][length:2][data:n] + When flags & PADDED_FLAG, appends a trailing pad byte (Prep convention). + Callers pass unpadded data; _add_fragment centralizes pad handling. + Args: type_id: Data type ID - data: Fragment data bytes - flags: Fragment flags (default: 0, but BOOL_ARRAY uses 0x01) + data: Fragment data bytes (unpadded; pad added here when flags set) + flags: Fragment flags (default: 0; PADDED_FLAG for BoolArray, PaddedBool, PaddedU8) """ + if flags & PADDED_FLAG: + data = data + b"\x00" fragment = Writer().u8(type_id).u8(flags).u16(len(data)).raw_bytes(data).finish() self._fragments.append(fragment) return self - # Scalar integer types - def i8(self, value: int) -> "HoiParams": - """Add signed 8-bit integer parameter.""" - data = Writer().i8(value).finish() - return self._add_fragment(HamiltonDataType.I8, data) - - def i16(self, value: int) -> "HoiParams": - """Add signed 16-bit integer parameter.""" - data = Writer().i16(value).finish() - return self._add_fragment(HamiltonDataType.I16, data) - - def i32(self, value: int) -> "HoiParams": - """Add signed 32-bit integer parameter.""" - data = Writer().i32(value).finish() - return self._add_fragment(HamiltonDataType.I32, data) - - def i64(self, value: int) -> "HoiParams": - """Add signed 64-bit integer parameter.""" - data = Writer().i64(value).finish() - return self._add_fragment(HamiltonDataType.I64, data) + def add(self, value: Any, wire_type: Any) -> "HoiParams": + """Encode a value using its WireType and append the DataFragment. - def u8(self, value: int) -> "HoiParams": - """Add unsigned 8-bit integer parameter.""" - data = Writer().u8(value).finish() - return self._add_fragment(HamiltonDataType.U8, data) + wire_type may be a WireType instance or an Annotated alias (e.g. I32, Str). + """ + if hasattr(wire_type, "__metadata__"): + wire_type = wire_type.__metadata__[0] + return cast("HoiParams", wire_type.encode_into(value, self)) - def u16(self, value: int) -> "HoiParams": - """Add unsigned 16-bit integer parameter.""" - data = Writer().u16(value).finish() - return self._add_fragment(HamiltonDataType.U16, data) + # ------------------------------------------------------------------ + # Ergonomic shims — each delegates to add() with the matching alias + # from wire_types. No encoding logic lives here. + # ------------------------------------------------------------------ - def u32(self, value: int) -> "HoiParams": - """Add unsigned 32-bit integer parameter.""" - data = Writer().u32(value).finish() - return self._add_fragment(HamiltonDataType.U32, data) + def i8(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import I8 - def u64(self, value: int) -> "HoiParams": - """Add unsigned 64-bit integer parameter.""" - data = Writer().u64(value).finish() - return self._add_fragment(HamiltonDataType.U64, data) + return self.add(value, I8) - # Floating-point types - def f32(self, value: float) -> "HoiParams": - """Add 32-bit float parameter.""" - data = Writer().f32(value).finish() - return self._add_fragment(HamiltonDataType.F32, data) + def i16(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import I16 - def f64(self, value: float) -> "HoiParams": - """Add 64-bit double parameter.""" - data = Writer().f64(value).finish() - return self._add_fragment(HamiltonDataType.F64, data) - - # String and bool - def string(self, value: str) -> "HoiParams": - """Add null-terminated string parameter.""" - data = Writer().string(value).finish() - return self._add_fragment(HamiltonDataType.STRING, data) - - def bool_value(self, value: bool) -> "HoiParams": - """Add boolean parameter.""" - data = Writer().u8(1 if value else 0).finish() - return self._add_fragment(HamiltonDataType.BOOL, data) - - # Array types - def i8_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 8-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i8(val) - return self._add_fragment(HamiltonDataType.I8_ARRAY, writer.finish()) + return self.add(value, I16) - def i16_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 16-bit integers. + def i32(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import I32 - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i16(val) - return self._add_fragment(HamiltonDataType.I16_ARRAY, writer.finish()) + return self.add(value, I32) - def i32_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 32-bit integers. + def i64(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import I64 - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i32(val) - return self._add_fragment(HamiltonDataType.I32_ARRAY, writer.finish()) + return self.add(value, I64) - def i64_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 64-bit integers. + def u8(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import U8 - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i64(val) - return self._add_fragment(HamiltonDataType.I64_ARRAY, writer.finish()) + return self.add(value, U8) - def u8_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 8-bit integers. + def u16(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import U16 - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u8(val) - return self._add_fragment(HamiltonDataType.U8_ARRAY, writer.finish()) + return self.add(value, U16) - def u16_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 16-bit integers. + def u32(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import U32 - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u16(val) - return self._add_fragment(HamiltonDataType.U16_ARRAY, writer.finish()) + return self.add(value, U32) - def u32_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 32-bit integers. + def u64(self, value: int) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import U64 - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u32(val) - return self._add_fragment(HamiltonDataType.U32_ARRAY, writer.finish()) + return self.add(value, U64) - def u64_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 64-bit integers. + def f32(self, value: float) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import F32 - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u64(val) - return self._add_fragment(HamiltonDataType.U64_ARRAY, writer.finish()) + return self.add(value, F32) - def f32_array(self, values: list[float]) -> "HoiParams": - """Add array of 32-bit floats. + def f64(self, value: float) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import F64 - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.f32(val) - return self._add_fragment(HamiltonDataType.F32_ARRAY, writer.finish()) + return self.add(value, F64) - def f64_array(self, values: list[float]) -> "HoiParams": - """Add array of 64-bit doubles. + def bool_(self, value: bool) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import Bool - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.f64(val) - return self._add_fragment(HamiltonDataType.F64_ARRAY, writer.finish()) + return self.add(value, Bool) - def bool_array(self, values: list[bool]) -> "HoiParams": - """Add array of booleans (stored as u8: 0 or 1). + def str_(self, value: str) -> "HoiParams": + from pylabrobot.hamilton.transport.tcp.wire_types import Str - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + return self.add(value, Str) - Note: BOOL_ARRAY uses flags=0x01 in the DataFragment header (unlike other types which use 0x00). - """ - writer = Writer() - for val in values: - writer.u8(1 if val else 0) - return self._add_fragment(HamiltonDataType.BOOL_ARRAY, writer.finish(), flags=0x01) + # ------------------------------------------------------------------ + # Generic dataclass serialiser (wire_types.py Annotated metadata) + # ------------------------------------------------------------------ - def string_array(self, values: list[str]) -> "HoiParams": - """Add array of null-terminated strings. + @classmethod + def from_struct(cls, obj) -> "HoiParams": + """Serialize any dataclass whose fields use ``Annotated`` wire-type metadata. - Format: [count:4][str0\0][str1\0]... + Fields without ``Annotated`` metadata (e.g. plain ``Address``) are skipped. + The polymorphic ``WireType.encode_into`` on each annotation handles all + dispatch -- no if/elif required here. """ - writer = Writer().u32(len(values)) - for val in values: - writer.string(val) - return self._add_fragment(HamiltonDataType.STRING_ARRAY, writer.finish()) + from dataclasses import fields as dc_fields + from typing import get_type_hints + + from pylabrobot.hamilton.transport.tcp.wire_types import WireType + + hints = get_type_hints(type(obj), include_extras=True) + params = cls() + for f in dc_fields(obj): + ann = hints.get(f.name) + if ann is None or not hasattr(ann, "__metadata__"): + continue + meta = ann.__metadata__[0] + if not isinstance(meta, WireType): + continue + params = meta.encode_into(getattr(obj, f.name), params) + return cast("HoiParams", params) def build(self) -> bytes: """Return concatenated DataFragments.""" @@ -294,162 +210,301 @@ def count(self) -> int: class HoiParamsParser: - """Parser for HOI DataFragment parameters. + """Cursor over sequential DataFragments in an HOI payload. - Parses DataFragment-wrapped values from HOI response payloads. + Reads [type_id:1][flags:1][length:2][data:N] headers and delegates + value decoding to the unified codec in wire_types.decode_fragment(). """ def __init__(self, data: bytes): + if not isinstance(data, bytes): + raise TypeError( + f"HoiParamsParser requires bytes, got {type(data).__name__}. " + "Use get_structs_raw() and inspect_hoi_params() to see the wire format." + ) self._data = data self._offset = 0 def parse_next(self) -> tuple[int, Any]: - """Parse the next DataFragment and return (type_id, value). - - Returns: - Tuple of (type_id, parsed_value) - - Raises: - ValueError: If data is malformed or insufficient + if self._offset + 4 > len(self._data): + raise ValueError(f"Insufficient data at offset {self._offset}") + type_id = self._data[self._offset] + flags = self._data[self._offset + 1] + length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") + payload_end = self._offset + 4 + length + if payload_end > len(self._data): + raise ValueError( + f"DataFragment data extends beyond buffer: need {payload_end}, have {len(self._data)}" + ) + data = self._data[self._offset + 4 : payload_end] + self._offset = payload_end + if (flags & PADDED_FLAG) and len(data) > 0: + data = data[:-1] + return type_id, decode_fragment(type_id, data) + + def parse_next_raw(self) -> tuple[int, int, int, bytes]: + """Return (type_id, flags, length, payload_bytes) without decoding. + + Use when the wire declares STRING (type_id=15) but the payload is binary + (e.g. GetMethod parameter_types). Normal parse_next() would UTF-8 decode + and fail on bytes like 0xaa. """ if self._offset + 4 > len(self._data): - raise ValueError(f"Insufficient data for DataFragment header at offset {self._offset}") - - # Parse DataFragment header - reader = Reader(self._data[self._offset :]) - type_id = reader.u8() - _flags = reader.u8() # Read but unused - length = reader.u16() - - data_start = self._offset + 4 - data_end = data_start + length - - if data_end > len(self._data): + raise ValueError(f"Insufficient data at offset {self._offset}") + type_id = self._data[self._offset] + flags = self._data[self._offset + 1] + length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") + payload_end = self._offset + 4 + length + if payload_end > len(self._data): raise ValueError( - f"DataFragment data extends beyond buffer: need {data_end}, have {len(self._data)}" + f"DataFragment data extends beyond buffer: need {payload_end}, have {len(self._data)}" ) - - # Extract data payload - fragment_data = self._data[data_start:data_end] - value = self._parse_value(type_id, fragment_data) - - # Move offset past this fragment - self._offset = data_end - - return (type_id, value) - - def _parse_value(self, type_id: int, data: bytes) -> Any: - """Parse value based on type_id using dispatch table.""" - reader = Reader(data) - - # Dispatch table for scalar types - scalar_parsers = { - HamiltonDataType.I8: reader.i8, - HamiltonDataType.I16: reader.i16, - HamiltonDataType.I32: reader.i32, - HamiltonDataType.I64: reader.i64, - HamiltonDataType.U8: reader.u8, - HamiltonDataType.U16: reader.u16, - HamiltonDataType.U32: reader.u32, - HamiltonDataType.U64: reader.u64, - HamiltonDataType.F32: reader.f32, - HamiltonDataType.F64: reader.f64, - HamiltonDataType.STRING: reader.string, - } - - # Check scalar types first - # Cast int to HamiltonDataType enum for dict lookup - try: - data_type = HamiltonDataType(type_id) - if data_type in scalar_parsers: - return scalar_parsers[data_type]() - except ValueError: - pass # Not a valid enum value, continue to other checks - - # Special case: bool - if type_id == HamiltonDataType.BOOL: - return reader.u8() == 1 - - # Dispatch table for array element parsers - array_element_parsers = { - HamiltonDataType.I8_ARRAY: reader.i8, - HamiltonDataType.I16_ARRAY: reader.i16, - HamiltonDataType.I32_ARRAY: reader.i32, - HamiltonDataType.I64_ARRAY: reader.i64, - HamiltonDataType.U8_ARRAY: reader.u8, - HamiltonDataType.U16_ARRAY: reader.u16, - HamiltonDataType.U32_ARRAY: reader.u32, - HamiltonDataType.U64_ARRAY: reader.u64, - HamiltonDataType.F32_ARRAY: reader.f32, - HamiltonDataType.F64_ARRAY: reader.f64, - HamiltonDataType.STRING_ARRAY: reader.string, - } - - # Handle arrays - # Arrays don't have a count prefix - count is derived from DataFragment length - # Calculate element size based on type - element_sizes = { - HamiltonDataType.I8_ARRAY: 1, - HamiltonDataType.I16_ARRAY: 2, - HamiltonDataType.I32_ARRAY: 4, - HamiltonDataType.I64_ARRAY: 8, - HamiltonDataType.U8_ARRAY: 1, - HamiltonDataType.U16_ARRAY: 2, - HamiltonDataType.U32_ARRAY: 4, - HamiltonDataType.U64_ARRAY: 8, - HamiltonDataType.F32_ARRAY: 4, - HamiltonDataType.F64_ARRAY: 8, - HamiltonDataType.STRING_ARRAY: None, # Variable length, handled separately - } - - # Cast int to HamiltonDataType enum for dict lookup - try: - data_type = HamiltonDataType(type_id) - if data_type in array_element_parsers: - element_size = element_sizes.get(data_type) - if element_size is not None: - # Fixed-size elements: calculate count from data length - count = len(data) // element_size - return [array_element_parsers[data_type]() for _ in range(count)] - elif data_type == HamiltonDataType.STRING_ARRAY: - # String arrays: [count:4][str0\0][str1\0]... - count = Reader(data[:4]).u32() - strings = [] - offset = 4 - for _ in range(count): - end = data.index(0, offset) - strings.append(data[offset:end].decode("utf-8", errors="replace")) - offset = end + 1 - return strings - except ValueError: - # Not a valid enum value, continue to other checks - # This shouldn't happen for valid Hamilton types, but we continue anyway - pass - - # Special case: bool array (1 byte per element) - if type_id == HamiltonDataType.BOOL_ARRAY: - count = len(data) // 1 # Each bool is 1 byte - return [reader.u8() == 1 for _ in range(count)] - - # Unknown type - raise ValueError(f"Unknown or unsupported type_id: {type_id}") + payload = self._data[self._offset + 4 : payload_end] + self._offset = payload_end + return type_id, flags, length, payload def has_remaining(self) -> bool: - """Check if there are more DataFragments to parse.""" return self._offset < len(self._data) - def parse_all(self) -> list[tuple[int, Any]]: - """Parse all remaining DataFragments. + def remaining(self) -> bytes: + """Unconsumed payload bytes (from current cursor to end).""" + return self._data[self._offset :] - Returns: - List of (type_id, value) tuples - """ + def skip_next(self) -> None: + """Advance past one DataFragment without decoding the payload.""" + if self._offset + 4 > len(self._data): + raise ValueError(f"Insufficient data at offset {self._offset}") + length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") + payload_end = self._offset + 4 + length + if payload_end > len(self._data): + raise ValueError( + f"DataFragment data extends beyond buffer: need {payload_end}, have {len(self._data)}" + ) + self._offset = payload_end + + def parse_all(self) -> list[tuple[int, Any]]: results = [] while self.has_remaining(): results.append(self.parse_next()) return results +def inspect_hoi_params(params: bytes) -> List[dict]: + """Inspect raw HOI params bytes fragment-by-fragment for debugging. + + Walks the DataFragment stream [type_id:1][flags:1][length:2][data:N] and + returns a list of dicts with: type_id, flags, length, payload_hex (first 80 + chars), payload_len, decoded (decode_fragment result or exception message). + Use this to see exactly what the device sends and fix response parsing. + + Example: + raw, fragments = await intro.get_structs_raw(mph_addr, 1) + for i, f in enumerate(fragments): + print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") + """ + if not params: + return [] + out: List[dict] = [] + offset = 0 + while offset + 4 <= len(params): + type_id = params[offset] + flags = params[offset + 1] + length = int.from_bytes(params[offset + 2 : offset + 4], "little") + payload_end = offset + 4 + length + if payload_end > len(params): + out.append( + { + "type_id": type_id, + "flags": flags, + "length": length, + "payload_hex": "", + "payload_len": 0, + "decoded": f"", + } + ) + break + data = params[offset + 4 : payload_end] + hex_preview = data.hex() if len(data) <= 40 else data[:40].hex() + "..." + try: + decoded = decode_fragment(type_id, data) + if isinstance(decoded, bytes): + decoded = ( + decoded.decode("utf-8", errors="replace").rstrip("\x00") or f"" + ) + decoded_repr = ( + repr(decoded) if not isinstance(decoded, (str, int, float, bool)) else str(decoded) + ) + if isinstance(decoded, list): + decoded_repr = ( + f"list[len={len(decoded)}](elem0_type={type(decoded[0]).__name__ if decoded else 'n/a'})" + ) + except Exception as e: + decoded_repr = f"" + out.append( + { + "type_id": type_id, + "flags": flags, + "length": length, + "payload_hex": hex_preview, + "payload_len": len(data), + "decoded": decoded_repr, + } + ) + offset = payload_end + return out + + +def hoi_action_code_base(action_byte: int) -> int: + """Lower 4 bits of HOI action field (response-required bit is 0x10).""" + return action_byte & 0x0F + + +def split_hoi_params_after_warning_prefix( + action_code: int, params: bytes +) -> tuple[bytes, list[HcResultEntry]]: + """If action is StatusWarning/CommandWarning, drop the first two fragments and parse the string aggregate. + + Mirrors ``SystemController.SendAndReceive``: out-parameters start at fragment index 2; fragment 1 holds + the formatted warning list consumed by ``HoiResult(HoiPacket2)`` / ``GetHcResults``. + """ + if not params: + return params, [] + base = hoi_action_code_base(action_code) + if base not in (Hoi2Action.STATUS_WARNING, Hoi2Action.COMMAND_WARNING): + return params, [] + + parser = HoiParamsParser(params) + if not parser.has_remaining(): + return params, [] + try: + _tid0, _v0 = parser.parse_next() + if not parser.has_remaining(): + return params, [] + _tid1, v1 = parser.parse_next() + except ValueError: + return params, [] + + rest = parser.remaining() + prefix_entries: list[HcResultEntry] = [] + if isinstance(v1, str): + prefix_entries = parse_hc_results_from_semicolon_string(v1) + elif isinstance(v1, (bytes, bytearray)): + prefix_entries = parse_hc_results_from_semicolon_string( + bytes(v1).decode("utf-8", errors="replace") + ) + return rest, prefix_entries + + +def log_hoi_result_entries(command_name: str, entries: list[HcResultEntry], *, source: str) -> None: + """Log non-success ``HcResultEntry`` rows (0x0000 skipped).""" + for entry in entries: + if entry.result == 0: + continue + logger.warning( + "%s %s channel result at %d:%d:%d iface=%d action=%d: 0x%04X (%s)", + command_name, + source, + entry.module_id, + entry.node_id, + entry.object_id, + entry.interface_id, + entry.action_id, + entry.result, + "warning" if entry.is_warning else "error", + ) + + +def interpret_hoi_success_payload(command: Any, params_bytes: bytes) -> Any: + """Decode command ``Response`` from HOI params. + + Used for CommandResponse / StatusResponse payloads after exception and + warning-prefix handling. Success frames carry only the fields declared in + the Response dataclass — no HoiResult trailer (see firmware yaml dumps and + protocol decoder behavior; HoiResult only rides on warning-prefix or exception + frames). + """ + cls = type(command) + if not params_bytes: + return None + + if hasattr(cls, "Response"): + return parse_into_struct(HoiParamsParser(params_bytes), cls.Response) + + return command.parse_response_parameters(params_bytes) + + +def parse_into_struct(parser: HoiParamsParser, cls: type) -> Any: + """Decode a sequence of DataFragments into a dataclass instance using its wire-type annotations. + + Mirrors HoiParams.from_struct: walks the same Annotated field metadata and, for each field in + order, consumes one fragment (via parser.parse_next()). Scalars/arrays/string yield the value + as returned by the parser; Struct recurses on the payload bytes; StructArray yields a list of + recursively decoded instances. + + Args: + parser: Parser positioned at the start of the fragment sequence (e.g. response payload). + cls: Dataclass type whose fields are annotated with wire_types (F32, Struct(), etc.). + + Returns: + An instance of cls with fields populated from the parsed fragments. + + Raises: + ValueError: If data is malformed or insufficient. + """ + from pylabrobot.hamilton.transport.tcp.wire_types import ( + CountedFlatArray, + Struct, + StructArray, + WireType, + ) + + hints = get_type_hints(cls, include_extras=True) + values: dict[str, Any] = {} + for f in dc_fields(cls): + ann = hints.get(f.name) + if ann is None or not hasattr(ann, "__metadata__"): + continue + meta = ann.__metadata__[0] + if not isinstance(meta, WireType): + continue + + if isinstance(meta, CountedFlatArray): + _, raw = parser.parse_next() + element_type = get_args(get_args(ann)[0])[0] + if isinstance(raw, list): + # Single fragment was STRUCTURE_ARRAY: list of payload bytes per element + if raw and not isinstance(raw[0], bytes): + raise ValueError( + f"CountedFlatArray decoded to list of {type(raw[0]).__name__}, expected " + "list of bytes (STRUCTURE_ARRAY). Use get_structs_raw() and " + "inspect_hoi_params() to see the exact wire format." + ) + values[f.name] = [parse_into_struct(HoiParamsParser(p), element_type) for p in raw] + else: + # Count then N flat fragments (count-prefixed stream) + count = int(raw) + values[f.name] = [parse_into_struct(parser, element_type) for _ in range(count)] + continue + + type_id, value = parser.parse_next() + + if isinstance(meta, Struct): + inner_type = get_args(ann)[0] + value = parse_into_struct(HoiParamsParser(value), inner_type) + elif isinstance(meta, StructArray): + inner_ann = get_args(ann)[0] + if get_origin(inner_ann) is list: + element_type = get_args(inner_ann)[0] + else: + element_type = inner_ann + value = [parse_into_struct(HoiParamsParser(p), element_type) for p in value] + # else: decode_fragment() already returned correctly-typed value + + values[f.name] = value + + return cls(**values) + + # ============================================================================ # MESSAGE BUILDERS # ============================================================================ @@ -681,10 +736,10 @@ def build(self) -> bytes: Returns: Complete packet bytes ready to send over TCP """ - # Build raw connection parameters (NOT DataFragments) + # Build raw Protocol-7 connection blob (NOT DataFragments — distinct from HoiParams). # Frame: [version:1][message_id:1][count:1][unknown:1] # Parameters: [id:1][type:1][reserved:2][value:2] repeated - params = ( + connection_blob = ( Writer() # Frame .u8(0) # version @@ -710,7 +765,7 @@ def build(self) -> bytes: ) # Build IP packet - packet_size = 1 + 1 + 2 + len(params) # protocol + version + opts_len + params + packet_size = 1 + 1 + 2 + len(connection_blob) # protocol + version + opts_len + blob return ( Writer() @@ -718,7 +773,7 @@ def build(self) -> bytes: .u8(self.ip_protocol) .u8(self.protocol_version) .u16(0) # options_length - .raw_bytes(params) + .raw_bytes(connection_blob) .finish() ) diff --git a/pylabrobot/hamilton/transport/tcp/packets.py b/pylabrobot/hamilton/transport/tcp/packets.py index 42d308f1c48..fb301cfbef6 100644 --- a/pylabrobot/hamilton/transport/tcp/packets.py +++ b/pylabrobot/hamilton/transport/tcp/packets.py @@ -12,14 +12,11 @@ from __future__ import annotations -import logging import struct from dataclasses import dataclass from pylabrobot.io.binary import Reader, Writer -logger = logging.getLogger(__name__) - # Hamilton protocol version HAMILTON_PROTOCOL_VERSION_MAJOR = 3 HAMILTON_PROTOCOL_VERSION_MINOR = 0 @@ -40,14 +37,14 @@ def encode_version_byte(major: int, minor: int) -> int: return version_byte -def decode_version_byte(version_byte: int) -> tuple[int, int]: +def decode_version_byte(version_bite: int) -> tuple[int, int]: """Decode Hamilton version byte and return (major, minor). Returns: Tuple of (major_version, minor_version), each 0-15 """ - minor = version_byte & 0xF - major = (version_byte >> 4) & 0xF + minor = version_bite & 0xF + major = (version_bite >> 4) & 0xF return (major, minor) @@ -116,13 +113,8 @@ def unpack(cls, data: bytes) -> "IpPacket": # Validate version if major != HAMILTON_PROTOCOL_VERSION_MAJOR or minor != HAMILTON_PROTOCOL_VERSION_MINOR: - logger.warning( - "Hamilton protocol version mismatch: expected %d.%d, got %d.%d", - HAMILTON_PROTOCOL_VERSION_MAJOR, - HAMILTON_PROTOCOL_VERSION_MINOR, - major, - minor, - ) + # Warning but not fatal + pass opts_len = r.u16() options = r.raw_bytes(opts_len) if opts_len > 0 else b"" diff --git a/pylabrobot/hamilton/transport/tcp/protocol.py b/pylabrobot/hamilton/transport/tcp/protocol.py index 9e916e91db3..60b89b61dee 100644 --- a/pylabrobot/hamilton/transport/tcp/protocol.py +++ b/pylabrobot/hamilton/transport/tcp/protocol.py @@ -1,7 +1,8 @@ -"""Hamilton TCP protocol constants and enumerations. +"""Transport-level protocol constants only. -This module contains all protocol-level constants, enumerations, and type definitions -used throughout the Hamilton TCP communication stack. +HamiltonProtocol, Hoi2Action, HarpTransportableProtocol, RegistrationActionCode, +RegistrationOptionType, HoiRequestId. DataFragment type IDs (I8, I32, STRUCTURE, +etc.) are defined in wire_types.HamiltonDataType. """ from __future__ import annotations @@ -124,49 +125,6 @@ class RegistrationOptionType(IntEnum): HARP_PROTOCOL_RESPONSE = 6 # PRIMARY: Contains object ID lists (most commonly used) -class HamiltonDataType(IntEnum): - """Hamilton parameter data types for wire encoding in DataFragments. - - These constants represent the type identifiers used in Hamilton DataFragments - for HOI2 command parameters. Each type ID corresponds to a specific data format - and encoding scheme used on the wire. - - From Hamilton.Components.TransportLayer.Protocols.Parameter.ParameterTypes. - """ - - # Scalar integer types - I8 = 1 - I16 = 2 - I32 = 3 - U8 = 4 - U16 = 5 - U32 = 6 - I64 = 36 - U64 = 37 - - # Floating-point types - F32 = 40 - F64 = 41 - - # String and boolean - STRING = 15 - BOOL = 23 - - # Array types - U8_ARRAY = 22 - I8_ARRAY = 24 - I16_ARRAY = 25 - U16_ARRAY = 26 - I32_ARRAY = 27 - U32_ARRAY = 28 - BOOL_ARRAY = 29 - STRING_ARRAY = 34 - I64_ARRAY = 38 - U64_ARRAY = 39 - F32_ARRAY = 42 - F64_ARRAY = 43 - - class HoiRequestId(IntEnum): """Request types for HarpProtocolRequest (byte 3 in command_data). diff --git a/pylabrobot/hamilton/transport/tcp/tcp.py b/pylabrobot/hamilton/transport/tcp/tcp.py index 7c31e56c291..6d1d8c46ad3 100644 --- a/pylabrobot/hamilton/transport/tcp/tcp.py +++ b/pylabrobot/hamilton/transport/tcp/tcp.py @@ -1,13 +1,27 @@ -"""Hamilton TCP Handler base class 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. +""" from __future__ import annotations import asyncio import logging -from dataclasses import dataclass -from typing import Dict, Optional, Union - -from pylabrobot.hamilton.transport.tcp.commands import HamiltonCommand +from typing import Any, Callable, ClassVar, Dict, Optional, Sequence, Tuple, Union, cast + +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand, hamilton_error_for_entry +from pylabrobot.hamilton.transport.tcp.error_tables import HC_RESULT_PROTOCOL +from pylabrobot.hamilton.transport.tcp.hoi_error import ( + HoiError, + parse_hamilton_error_entries, + parse_hamilton_error_params, +) +from pylabrobot.hamilton.transport.tcp.introspection import ( + HamiltonIntrospection, + MethodDescriptor, + ObjectRegistry, +) from pylabrobot.hamilton.transport.tcp.messages import ( CommandResponse, InitMessage, @@ -22,81 +36,29 @@ RegistrationActionCode, RegistrationOptionType, ) +from pylabrobot.hamilton.transport.tcp.wire_types import HcResultEntry from pylabrobot.io.binary import Reader from pylabrobot.io.socket import Socket +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError logger = logging.getLogger(__name__) -@dataclass -class HamiltonError: - """Hamilton error response.""" - - error_code: int - error_message: str - interface_id: int - action_id: int - - -class ErrorParser: - """Parse Hamilton error responses.""" - - @staticmethod - def parse_error(data: bytes) -> HamiltonError: - """Parse error response from Hamilton instrument.""" - # Error responses have a specific format - # This is a simplified implementation - real errors may vary - if len(data) < 8: - raise ValueError("Error response too short") +class HamiltonTCPClient: + """Standalone transport + discovery/introspection client for Hamilton TCP devices.""" - # Parse error structure (simplified) - error_code = Reader(data).u32() - error_message = data[4:].decode("utf-8", errors="replace") - - return HamiltonError( - error_code=error_code, error_message=error_message, interface_id=0, action_id=0 - ) - - -class HamiltonTCPHandler: - """Base driver for all Hamilton TCP instruments. - - Hamilton TCP instruments include the Nimbus and the Prep, using Hoi and Harp. - STAR and Vantage use the other Hamilton protocol that works over USB. - - This class provides: - - Connection management via Socket (wrapped with state tracking) - - Protocol 7 initialization - - Protocol 3 registration - - Generic command execution - - Object discovery via introspection - - Hamilton uses strict request-response protocol (no unsolicited messages), - so we use simple direct read/write instead of complex routing. - """ + _ERROR_CODES: ClassVar[Dict[Tuple[int, int, int, int, int], str]] = {} 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, ): - """Initialize Hamilton TCP handler. - - Args: - host: Hamilton instrument IP address - port: Hamilton instrument port - read_timeout: Read timeout in seconds - write_timeout: Write timeout in seconds - auto_reconnect: Enable automatic reconnection - max_reconnect_attempts: Maximum reconnection attempts - """ - - super().__init__() - self.io = Socket( human_readable_device_name="Hamilton Liquid Handler", host=host, @@ -105,23 +67,128 @@ def __init__( write_timeout=write_timeout, ) - # Connection state tracking (wrapping Socket) self._connected = False self._reconnect_attempts = 0 self.auto_reconnect = auto_reconnect self.max_reconnect_attempts = max_reconnect_attempts + self._connection_timeout = connection_timeout - # Hamilton-specific state self._client_id: Optional[int] = None self.client_address: Optional[Address] = None self._sequence_numbers: Dict[Address, int] = {} - self._discovered_objects: Dict[str, list[Address]] = {} - - # Instrument-specific addresses (set by subclasses) self._instrument_addresses: Dict[str, Address] = {} + self._registry = ObjectRegistry() + self._global_object_addresses: list[Address] = [] + self._event_handlers: list[Callable[[CommandResponse], None]] = [] + self._introspection_impl: Optional[HamiltonIntrospection] = None + + @property + def registry(self) -> ObjectRegistry: + """Object path registry for this session.""" + return self._registry + + @property + def global_object_addresses(self) -> Sequence[Address]: + """Global object addresses discovered during :meth:`setup` (read-only).""" + return tuple(self._global_object_addresses) + + def get_root_object_addresses(self) -> list[Address]: + """Root address from the registry as a single-element list.""" + addr = self._registry.get_root_address() + return [addr] if addr is not None else [] + + @property + def introspection(self) -> HamiltonIntrospection: + """Lazy Interface-0 / type introspection facet (canonical entry).""" + if self._introspection_impl is None: + self._introspection_impl = HamiltonIntrospection( + registry=self._registry, + global_object_addresses=self._global_object_addresses, + send_discovery_command=self.send_discovery_command, + send_query=self.send_query, + ) + return self._introspection_impl + + def _invalidate_introspection_session(self) -> None: + self._introspection_impl = None + + async def _describe_entry(self, entry: HcResultEntry) -> Tuple[Optional[str], str]: + """Resolve an HcResultEntry to (interface_name, description) for error reporting.""" + addr = Address(entry.module_id, entry.node_id, entry.object_id) + iface_name = await self.introspection.get_interface_name(addr, entry.interface_id) + # Vendor tables key on (module, node, object_id, interface_id, hc_result). The wire + # action_id is the failing method id and must not be used for that slot — otherwise + # we miss lookups and show raw HC_RESULT=0x.... instead of "No Tip Picked Up." / etc. + key_iface = (entry.module_id, entry.node_id, entry.object_id, entry.interface_id, entry.result) + key_action = (entry.module_id, entry.node_id, entry.object_id, entry.action_id, entry.result) + desc = self._ERROR_CODES.get(key_iface) + if desc is None and key_action != key_iface: + desc = self._ERROR_CODES.get(key_action) + if desc is None: + desc = HC_RESULT_PROTOCOL.get(entry.result) + if desc is None: + desc = await self.introspection.get_hc_result_text(addr, entry.interface_id, entry.result) + if desc is None: + desc = f"HC_RESULT=0x{entry.result:04X}" + return iface_name, desc + + async def _format_entry_context(self, entry: HcResultEntry) -> Optional[str]: + """Resolve an HcResultEntry to a human-readable method context string.""" + addr = Address(entry.module_id, entry.node_id, entry.object_id) + path = self._registry.path(addr) + path_part = f"path={path}" if path else "path=?" + descriptor = await self._lookup_method_descriptor(addr, entry.interface_id, entry.action_id) + if descriptor is None: + return f"{path_part}, addr={addr}, iface={entry.interface_id}, action={entry.action_id}" + return ( + f"{path_part}, addr={addr}, method={descriptor.id_string} {descriptor.signature_string()}" + ) + + async def _lookup_method_descriptor( + self, addr: Address, interface_id: int, action_id: int + ) -> Optional[MethodDescriptor]: + try: + method = await self.introspection.get_method_by_id(addr, interface_id, action_id) + if method is None: + return None + return method.describe(None) + except Exception as exc: + logger.debug( + "Method descriptor lookup failed for %s iface=%d action=%d: %s", + addr, + interface_id, + action_id, + exc, + ) + return None + + def on_event(self, callback: Callable[[CommandResponse], None]) -> Callable[[], None]: + """Register a callback for ``Hoi2Action.EVENT`` frames. + + Returns an unsubscribe function. Callback exceptions are logged and swallowed. + """ + self._event_handlers.append(callback) + + def _unsubscribe() -> None: + try: + self._event_handlers.remove(callback) + except ValueError: + pass + + return _unsubscribe + + def _dispatch_event(self, response_message: CommandResponse) -> None: + for handler in list(self._event_handlers): + try: + handler(response_message) + except Exception as exc: + logger.exception("Event handler %r raised: %s", handler, exc) + + def _clear_session_state_for_setup(self) -> None: + self._global_object_addresses = [] + self._invalidate_introspection_session() async def _ensure_connected(self): - """Ensure connection is healthy before operations.""" if not self._connected: if not self.auto_reconnect: raise ConnectionError( @@ -131,7 +198,6 @@ async def _ensure_connected(self): await self._reconnect() async def _reconnect(self): - """Attempt to reconnect with exponential backoff.""" if not self.auto_reconnect: raise ConnectionError(f"{self.io._unique_id} Auto-reconnect disabled") @@ -141,18 +207,15 @@ async def _reconnect(self): f"{self.io._unique_id} Reconnection attempt {attempt + 1}/{self.max_reconnect_attempts}" ) - # Clean up existing connection try: await self.stop() except Exception: pass - # Wait before reconnecting (exponential backoff) if attempt > 0: - wait_time = 1.0 * (2 ** (attempt - 1)) # 1s, 2s, 4s, etc. + wait_time = 1.0 * (2 ** (attempt - 1)) await asyncio.sleep(wait_time) - # Attempt to reconnect await self.setup() self._reconnect_attempts = 0 logger.info(f"{self.io._unique_id} Reconnection successful") @@ -161,19 +224,12 @@ async def _reconnect(self): except Exception as e: logger.warning(f"{self.io._unique_id} Reconnection attempt {attempt + 1} failed: {e}") - # All reconnection attempts failed self._connected = False raise ConnectionError( f"{self.io._unique_id} Failed to reconnect after {self.max_reconnect_attempts} attempts" ) async def write(self, data: bytes, timeout: Optional[float] = None): - """Write data to the socket with connection state tracking. - - Args: - data: The data to write. - timeout: The timeout for writing to the server in seconds. If `None`, use the default timeout. - """ await self._ensure_connected() try: @@ -184,103 +240,54 @@ async def write(self, data: bytes, timeout: Optional[float] = None): raise async def read(self, num_bytes: int = 128, timeout: Optional[float] = None) -> bytes: - """Read data from the socket with connection state tracking. - - Args: - num_bytes: Maximum number of bytes to read. Defaults to 128. - timeout: The timeout for reading from the server in seconds. If `None`, use the default - timeout. - - Returns: - The data read from the socket. - """ await self._ensure_connected() try: data = await self.io.read(num_bytes, timeout=timeout) self._connected = True - return data + return cast(bytes, data) except (ConnectionError, OSError, TimeoutError): self._connected = False raise async def read_exact(self, num_bytes: int, timeout: Optional[float] = None) -> bytes: - """Read exactly num_bytes with connection state tracking. - - Args: - num_bytes: The exact number of bytes to read. - timeout: The timeout for reading from the server in seconds. If `None`, use the default - timeout. - - Returns: - Exactly num_bytes of data. - - Raises: - ConnectionError: If the connection is closed before num_bytes are read. - """ await self._ensure_connected() try: data = await self.io.read_exact(num_bytes, timeout=timeout) self._connected = True - return data + return cast(bytes, data) except (ConnectionError, OSError, TimeoutError): self._connected = False raise @property def is_connected(self) -> bool: - """Check if the connection is currently established.""" return self._connected - async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse]: - """Read one complete Hamilton packet and parse based on protocol. - - Hamilton packets are length-prefixed: - - First 2 bytes: packet size (little-endian) - - Next packet_size bytes: packet payload - - The method inspects the IP protocol field and, for Protocol 6 (HARP), - also checks the HARP protocol field to dispatch correctly. - - Returns: - Union[RegistrationResponse, CommandResponse]: Parsed response - - Raises: - ConnectionError: If connection is lost - TimeoutError: If no message received within timeout - ValueError: If protocol type is unknown - """ - - # Read packet size (2 bytes, little-endian) - size_data = await self.read_exact(2) + 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() - # Read packet payload - payload_data = await self.read_exact(packet_size) + payload_data = await self.read_exact(packet_size, timeout=timeout) complete_data = size_data + payload_data - # Parse IP packet to get protocol field (byte 2) - # Format: [size:2][ip_protocol:1][version:1][options_len:2][options:x][payload:n] ip_protocol = complete_data[2] - # Dispatch based on IP protocol if ip_protocol == 6: - # Protocol 6: HARP wrapper - need to check HARP protocol field - # IP header: [size:2][protocol:1][version:1][options_len:2] ip_options_len = int.from_bytes(complete_data[4:6], "little") harp_start = 6 + ip_options_len - - # HARP header: [src:6][dst:6][seq:1][unk:1][harp_protocol:1][action:1]... - # HARP protocol is at offset 14 within HARP packet harp_protocol_offset = harp_start + 14 harp_protocol = complete_data[harp_protocol_offset] if harp_protocol == 2: - # HARP Protocol 2: HOI2 - return CommandResponse.from_bytes(complete_data) + 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: - # HARP Protocol 3: Registration2 return RegistrationResponse.from_bytes(complete_data) logger.warning(f"Unknown HARP protocol: {harp_protocol}, attempting CommandResponse parse") return CommandResponse.from_bytes(complete_data) @@ -289,125 +296,70 @@ async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse return CommandResponse.from_bytes(complete_data) async def setup(self): - """Initialize Hamilton connection and discover objects. - - Hamilton uses strict request-response protocol: - 1. Establish TCP connection - 2. Protocol 7 initialization (get client ID) - 3. Protocol 3 registration - 4. Discover objects via Protocol 3 introspection - """ - - # Step 1: Establish TCP connection + self._clear_session_state_for_setup() await self.io.setup() - - # Set connection state after successful connection self._connected = True self._reconnect_attempts = 0 - - # Step 2: Initialize connection (Protocol 7) await self._initialize_connection() - - # Step 3: Register client (Protocol 3) await self._register_client() - - # Step 4: Discover root objects await self._discover_root() - - logger.info(f"Hamilton handler setup complete. Client ID: {self._client_id}") + await self._discover_globals() + + root_addr = self._registry.get_root_address() + if root_addr is not None: + root_info = await self.introspection.get_object(root_addr) + root_info.children = {} + self._registry.register(root_info.name, root_info) + + logger.info( + "Hamilton TCP client setup complete. Client ID: %s, globals: %d", + self._client_id, + len(self._global_object_addresses), + ) async def _initialize_connection(self): - """Initialize connection using Protocol 7 (ConnectionPacket). - - Note: Protocol 7 doesn't have sequence numbers, so we send the packet - and read the response directly (blocking) rather than using the - normal routing mechanism. - """ logger.info("Initializing Hamilton connection...") - # Build Protocol 7 ConnectionPacket using new InitMessage - packet = InitMessage(timeout=30).build() - - logger.info("[INIT] Sending Protocol 7 initialization packet:") - logger.info(f"[INIT] Length: {len(packet)} bytes") - logger.info(f"[INIT] Hex: {packet.hex(' ')}") - - # Send packet + packet = InitMessage(timeout=self._connection_timeout).build() await self.write(packet) - # Read response directly (blocking - safe because this is first communication) - # Read packet size (2 bytes, little-endian) size_data = await self.read_exact(2) packet_size = Reader(size_data).u16() - - # Read packet payload payload_data = await self.read_exact(packet_size) response_bytes = size_data + payload_data - - logger.info("[INIT] Received response:") - logger.info(f"[INIT] Length: {len(response_bytes)} bytes") - logger.info(f"[INIT] Hex: {response_bytes.hex(' ')}") - - # Parse response using InitResponse response = InitResponse.from_bytes(response_bytes) self._client_id = response.client_id - # Controller module is 2, node is client_id, object 65535 for general addressing self.client_address = Address(2, response.client_id, 65535) - logger.info(f"[INIT] Client ID: {self._client_id}, Address: {self.client_address}") - async def _register_client(self): - """Register client using Protocol 3.""" logger.info("Registering Hamilton client...") - - # Registration service address (DLL uses 0:0:65534, Piglet comment confirms) registration_service = Address(0, 0, 65534) - # Step 1: Initial registration (action_code=0) reg_msg = RegistrationMessage( dest=registration_service, action_code=RegistrationActionCode.REGISTRATION_REQUEST ) - # Ensure client is initialized if self.client_address is None or self._client_id is None: raise RuntimeError("Client not initialized - call _initialize_connection() first") - # Build and send registration packet seq = self._allocate_sequence_number(registration_service) packet = reg_msg.build( src=self.client_address, - req_addr=Address(2, self._client_id, 65535), # C# DLL: 2:{client_id}:65535 - res_addr=Address(0, 0, 0), # C# DLL: 0:0:0 + req_addr=Address(2, self._client_id, 65535), + res_addr=Address(0, 0, 0), seq=seq, - harp_action_code=3, # COMMAND_REQUEST - harp_response_required=False, # DLL uses 0x03 (no response flag) + harp_action_code=3, + harp_response_required=False, ) - logger.info("[REGISTER] Sending registration packet:") - logger.info(f"[REGISTER] Length: {len(packet)} bytes, Seq: {seq}") - logger.info(f"[REGISTER] Hex: {packet.hex(' ')}") - logger.info(f"[REGISTER] Src: {self.client_address}, Dst: {registration_service}") - - # Send registration packet await self.write(packet) - - # Read response - response = await self._read_one_message() - - logger.info("[REGISTER] Received response:") - logger.info(f"[REGISTER] Length: {len(response.raw_bytes)} bytes") - logger.debug(f"[REGISTER] Hex: {response.raw_bytes.hex(' ')}") - - logger.info("[REGISTER] Registration complete") + await self._read_one_message() async def _discover_root(self): - """Discover root objects via Protocol 3 HARP_PROTOCOL_REQUEST""" logger.info("Discovering Hamilton root objects...") registration_service = Address(0, 0, 65534) - - # Request root objects (request_id=1) root_msg = RegistrationMessage( dest=registration_service, action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST ) @@ -417,7 +369,6 @@ async def _discover_root(self): request_id=HoiRequestId.ROOT_OBJECT_OBJECT_ID, ) - # Ensure client is initialized if self.client_address is None or self._client_id is None: raise RuntimeError("Client not initialized - call _initialize_connection() first") @@ -427,44 +378,52 @@ async def _discover_root(self): req_addr=Address(0, 0, 0), res_addr=Address(0, 0, 0), seq=seq, - harp_action_code=3, # COMMAND_REQUEST - harp_response_required=True, # Request with response + harp_action_code=3, + harp_response_required=True, ) - logger.info("[DISCOVER_ROOT] Sending root object discovery:") - logger.info(f"[DISCOVER_ROOT] Length: {len(packet)} bytes, Seq: {seq}") - logger.info(f"[DISCOVER_ROOT] Hex: {packet.hex(' ')}") - - # Send request await self.write(packet) - - # Read response response = await self._read_one_message() assert isinstance(response, RegistrationResponse) - logger.debug(f"[DISCOVER_ROOT] Received response: {len(response.raw_bytes)} bytes") - - # Parse registration response to extract root object IDs root_objects = self._parse_registration_response(response) - logger.info(f"[DISCOVER_ROOT] Found {len(root_objects)} root objects") - - # Store discovered root objects - self._discovered_objects["root"] = root_objects - - logger.info(f"Discovery complete: {len(root_objects)} root objects") + if len(root_objects) != 1: + raise RuntimeError( + f"Expected exactly one root object from discovery, got {len(root_objects)}: {root_objects}" + ) + self._registry.set_root_address(root_objects[0]) + + async def _discover_globals(self) -> None: + logger.info("Discovering Hamilton global objects...") + registration_service = Address(0, 0, 65534) + global_msg = RegistrationMessage( + dest=registration_service, action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST + ) + global_msg.add_registration_option( + RegistrationOptionType.HARP_PROTOCOL_REQUEST, + protocol=2, + request_id=HoiRequestId.GLOBAL_OBJECT_ADDRESS, + ) - def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: - """Parse registration response options to extract object addresses. + if self.client_address is None or self._client_id is None: + raise RuntimeError("Client not initialized - call _initialize_connection() first") - From Piglet: Option type 6 (HARP_PROTOCOL_RESPONSE) contains object IDs - as a packed list of u16 values. + 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, + ) - Args: - response: Parsed RegistrationResponse + await self.write(packet) + response = await self._read_one_message() + assert isinstance(response, RegistrationResponse) + self._global_object_addresses = self._parse_registration_response(response) - Returns: - List of discovered object addresses - """ + def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: objects: list[Address] = [] options_data = response.registration.options @@ -472,113 +431,297 @@ def _parse_registration_response(self, response: RegistrationResponse) -> list[A logger.debug("No options in registration response (no objects found)") return objects - # Parse options: [option_id:1][length:1][data:x] reader = Reader(options_data) - while reader.has_remaining(): option_id = reader.u8() length = reader.u8() if option_id == RegistrationOptionType.HARP_PROTOCOL_RESPONSE: if length > 0: - # Skip padding u16 _ = reader.u16() - - # Read object IDs (u16 each) num_objects = (length - 2) // 2 for _ in range(num_objects): object_id = reader.u16() - # Objects are at Address(1, 1, object_id) objects.append(Address(1, 1, object_id)) else: logger.warning(f"Unknown registration option ID: {option_id}, skipping {length} bytes") - # Skip unknown option data reader.raw_bytes(length) return objects def _allocate_sequence_number(self, dest_address: Address) -> int: - """Allocate next sequence number for destination. - - Args: - dest_address: Destination object address - - Returns: - Next sequence number for this destination - """ current = self._sequence_numbers.get(dest_address, 0) - next_seq = (current + 1) % 256 # Wrap at 8 bits (1 byte) + 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) -> Optional[dict]: - """Send Hamilton command and wait for response. + async def send_command( + self, + command: TCPCommand, + *, + read_timeout: Optional[float] = None, + ) -> Any: + """Send a command and return the interpreted response. Raises on any firmware error.""" + return await self._send_raw( + command, + ensure_connection=True, + return_raw=False, + raise_on_error=True, + read_timeout=read_timeout, + ) + + async def send_query( + self, + command: TCPCommand, + *, + read_timeout: Optional[float] = None, + ) -> Optional[tuple]: + """Send a read/status command and return raw HOI bytes. Returns None on firmware error. + + Use for hardware state probing where the response needs manual parsing or where + the firmware path may legitimately return an error (e.g. tip-presence checks). + Follows SCPI convention: queries read state, commands change state. + """ + return cast( + Optional[tuple], + await self._send_raw( + command, + ensure_connection=True, + return_raw=True, + raise_on_error=False, + read_timeout=read_timeout, + ), + ) + + async def send_discovery_command( + self, + command: TCPCommand, + *, + read_timeout: Optional[float] = None, + ) -> Any: + """Send an Interface-0 introspection command during setup (no reconnect on failure).""" + return await self._send_raw( + command, + ensure_connection=False, + return_raw=False, + raise_on_error=True, + read_timeout=read_timeout, + ) + + async def _send_raw( + self, + command: TCPCommand, + *, + ensure_connection: bool, + return_raw: bool, + raise_on_error: bool, + read_timeout: Optional[float] = None, + ) -> Any: + connection_errors = ( + BrokenPipeError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + TimeoutError, + OSError, + ) + max_attempts = 2 if ensure_connection else 1 + last_error: Optional[BaseException] = None - Sets source_address if not already set by caller (for testing). - Uses handler's client_address assigned during Protocol 7 initialization. + for attempt in range(max_attempts): + try: + if command.source_address is None: + if self.client_address is None: + raise RuntimeError( + "Client not initialized - call setup() first to assign client_address" + ) + command.source_address = self.client_address + + command.sequence_number = self._allocate_sequence_number(command.dest_address) + message = command.build() + + log_params = command.get_log_params() + logger.debug(f"{command.__class__.__name__} parameters: {log_params}") + + await self.write(message) + + while True: + response_message = await self._read_one_message(timeout=read_timeout) + assert isinstance(response_message, CommandResponse) + action = Hoi2Action(response_message.hoi.action_code) + if action is Hoi2Action.COMMAND_ACK: + logger.debug( + "%s COMMAND_ACK from %s; awaiting terminal response", + command.__class__.__name__, + response_message.harp.src, + ) + continue + if action is Hoi2Action.EVENT: + logger.debug( + "%s EVENT from %s; skipping past to await terminal response", + command.__class__.__name__, + response_message.harp.src, + ) + continue + break + + if action in ( + Hoi2Action.STATUS_EXCEPTION, + Hoi2Action.COMMAND_EXCEPTION, + Hoi2Action.INVALID_ACTION_RESPONSE, + ): + entries = parse_hamilton_error_entries(response_message.hoi.params) + if not entries: + raw = parse_hamilton_error_params(response_message.hoi.params) + enriched_msg = f"Hamilton error {action.name} (action={action:#x}): {raw}" + if raise_on_error: + logger.error(enriched_msg) + raise RuntimeError(enriched_msg) + logger.debug(enriched_msg) + return None + + if command.error_entries_use_physical_channels(): + per_channel: Dict[int, Exception] = {} + context_by_channel: Dict[int, Optional[str]] = {} + hoi_exceptions: Dict[int, Exception] = {} + for idx, entry in enumerate(entries): + _iface_name, desc = await self._describe_entry(entry) + err = hamilton_error_for_entry(entry, desc) + hoi_exceptions[idx] = err + channel = command._channel_index_for_entry(idx, entry) + if channel is None: + channel = idx + per_channel.setdefault(channel, err) + if channel not in context_by_channel: + context_by_channel[channel] = await self._format_entry_context(entry) + + if raise_on_error: + channel_summary = ", ".join( + ( + f"ch{ch}: {per_channel[ch]} ({context_by_channel[ch]})" + if context_by_channel.get(ch) + else f"ch{ch}: {per_channel[ch]}" + ) + for ch in sorted(per_channel) + ) + logger.error( + "Hamilton %s (action=%#x) on %d channel(s): %s", + action.name, + action, + len(per_channel), + channel_summary, + ) + raise ChannelizedError( + errors=per_channel, + raw_response=response_message.hoi.params, + hoi_entries=list(entries), + hoi_exceptions=hoi_exceptions, + ) + logger.debug( + "Hamilton %s (action=%#x) suppressed; entries=%d (raise_on_error=False)", + action.name, + action, + len(entries), + ) + return None + + entry_errors: Dict[int, Exception] = {} + context_by_idx: Dict[int, Optional[str]] = {} + for idx, entry in enumerate(entries): + _iface_name, desc = await self._describe_entry(entry) + err = hamilton_error_for_entry(entry, desc) + entry_errors[idx] = err + context_by_idx[idx] = await self._format_entry_context(entry) + + if raise_on_error: + summary = ", ".join( + ( + f"entry[{idx}]: {entry_errors[idx]} ({context_by_idx[idx]})" + if context_by_idx.get(idx) + else f"entry[{idx}]: {entry_errors[idx]}" + ) + for idx in sorted(entry_errors) + ) + logger.error( + "Hamilton %s (action=%#x), instrument-wide error (%d entries): %s", + action.name, + action, + len(entries), + summary, + ) + raise HoiError( + exceptions=entry_errors, + entries=list(entries), + raw_response=response_message.hoi.params, + ) + logger.debug( + "Hamilton %s (action=%#x) suppressed; entries=%d (raise_on_error=False)", + action.name, + action, + len(entries), + ) + return None + + if return_raw: + return (response_message.hoi.params,) + + result = command.interpret_response(response_message) + fatal = command.fatal_entries_by_channel(response_message) + if fatal: + fatal_per_channel: Dict[int, Exception] = {} + fatal_context_by_channel: Dict[int, Optional[str]] = {} + for ch, e in fatal.items(): + _iface_name, desc = await self._describe_entry(e) + fatal_per_channel[ch] = hamilton_error_for_entry(e, desc) + fatal_context_by_channel[ch] = await self._format_entry_context(e) + logger.error( + "Hamilton command fatal entries: %s", + ", ".join( + ( + f"ch{ch}: {fatal_per_channel[ch]} ({fatal_context_by_channel[ch]})" + if fatal_context_by_channel.get(ch) + else f"ch{ch}: {fatal_per_channel[ch]}" + ) + for ch in sorted(fatal_per_channel) + ), + ) + raise ChannelizedError(errors=fatal_per_channel, raw_response=response_message.hoi.params) + return result + + except connection_errors as e: + last_error = e + self._connected = False + if not self.auto_reconnect or attempt == max_attempts - 1: + raise + logger.warning( + f"{self.io._unique_id} Command failed (connection error), reconnecting and retrying: {e}" + ) + await self._reconnect() - Args: - command: Hamilton command to execute - timeout: Maximum time to wait for response + assert last_error is not None + raise last_error - Returns: - Parsed response dictionary, or None if command has no information to extract + async def resolve_path(self, path: str) -> Address: + """Resolve dot-path to Address (delegates to introspection).""" + return await self.introspection.resolve_path(path) - Raises: - TimeoutError: If no response received within timeout - HamiltonError: If command returned an error - """ - # Set source address with smart fallback - if command.source_address is None: - if self.client_address is None: - raise RuntimeError("Handler not initialized - call setup() first to assign client_address") - command.source_address = self.client_address - - # Allocate sequence number for this command - command.sequence_number = self._allocate_sequence_number(command.dest_address) - - # Build command message - message = command.build() - - # Log command parameters for debugging - log_params = command.get_log_params() - logger.info(f"{command.__class__.__name__} parameters:") - for key, value in log_params.items(): - # Format arrays nicely if very long - if isinstance(value, list) and len(value) > 8: - logger.info(f" {key}: {value[:4]}... ({len(value)} items)") - else: - logger.info(f" {key}: {value}") - - # Send command - await self.write(message) - - # Read response, honoring the per-call timeout when provided. - if timeout is None: - response_message = await self._read_one_message() - else: - response_message = await asyncio.wait_for(self._read_one_message(), timeout) - assert isinstance(response_message, CommandResponse) - - # Check for error actions - action = Hoi2Action(response_message.hoi.action_code) - if action in ( - Hoi2Action.STATUS_EXCEPTION, - Hoi2Action.COMMAND_EXCEPTION, - Hoi2Action.INVALID_ACTION_RESPONSE, - ): - error_message = f"Error response (action={action:#x}): {response_message.hoi.params.hex()}" - logger.error(f"Hamilton error {action}: {error_message}") - raise RuntimeError(f"Hamilton error {action}: {error_message}") - - return command.interpret_response(response_message) + async def resolve_target( + self, + target: Union[Address, str], + aliases: Optional[Dict[str, str]] = None, + ) -> Address: + """Resolve Address | alias | dot-path to Address.""" + if isinstance(target, Address): + return target + resolved = aliases.get(target, target) if aliases is not None else target + return await self.resolve_path(resolved) async def stop(self): - """Stop the handler and close connection.""" try: await self.io.stop() except Exception as e: logger.warning(f"Error during stop: {e}") finally: self._connected = False - logger.info("Hamilton handler stopped") + self._invalidate_introspection_session() + logger.info("Hamilton TCP client stopped") diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py new file mode 100644 index 00000000000..66ddc7bf0c7 --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -0,0 +1,966 @@ +"""Curated tests for Hamilton TCP protocol implementation. + +Focused on high-value invariants: +- packet/frame wire shape and round-trip parsing +- DataFragment encode/decode and parser behavior +- warning/exception payload semantics +- command response auto-decode contract +""" + +from __future__ import annotations + +import asyncio +import struct +import unittest +from dataclasses import dataclass +from typing import Annotated, cast +from unittest.mock import AsyncMock + +import pylabrobot.hamilton.transport.tcp.introspection as introspection_mod +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.error_tables import NIMBUS_ERROR_CODES +from pylabrobot.hamilton.transport.tcp.hoi_error import ( + HoiError, + parse_hamilton_error_entries, + parse_hamilton_error_entry, +) +from pylabrobot.hamilton.transport.tcp.introspection import ( + EnumInfo, + FirmwareTreeNode, + GlobalTypePool, + HamiltonIntrospection, + InterfaceInfo, + MethodInfo, + ObjectInfo, + ObjectRegistry, + StructInfo, + TypeRegistry, + flatten_firmware_tree, +) +from pylabrobot.hamilton.transport.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParams, + HoiParamsParser, + InitMessage, + InitResponse, + RegistrationMessage, + RegistrationResponse, + parse_into_struct, + split_hoi_params_after_warning_prefix, +) +from pylabrobot.hamilton.transport.tcp.packets import ( + Address, + HarpPacket, + HoiPacket, + IpPacket, + RegistrationPacket, + decode_version_byte, + encode_version_byte, +) +from pylabrobot.hamilton.transport.tcp.protocol import ( + HamiltonProtocol, + Hoi2Action, + RegistrationActionCode, + RegistrationOptionType, +) +from pylabrobot.hamilton.transport.tcp.tcp import HamiltonTCPClient +from pylabrobot.hamilton.transport.tcp.wire_types import ( + I32, + I64, + U16, + Bool, + BoolArray, + CountedFlatArray, + HamiltonDataType, + HcResultEntry, + Str, + StrArray, + decode_fragment, +) +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError + + +@dataclass +class _EnumValueWire: + name: Str + value: I64 + + +@dataclass +class _EnumWire: + enum_id: I64 + name: Str + values: Annotated[list[_EnumValueWire], CountedFlatArray()] + + +@dataclass +class _GetEnumsResponse: + enums: Annotated[list[_EnumWire], CountedFlatArray()] + + +class TestVersionByte(unittest.TestCase): + def test_encode_decode_roundtrip(self): + for major in range(16): + for minor in range(16): + encoded = encode_version_byte(major, minor) + got_major, got_minor = decode_version_byte(encoded) + self.assertEqual((got_major, got_minor), (major, minor)) + + def test_encode_version_byte_invalid(self): + with self.assertRaises(ValueError): + encode_version_byte(16, 0) + with self.assertRaises(ValueError): + encode_version_byte(0, 16) + + +class TestPacketWireShape(unittest.TestCase): + def test_ip_packet_roundtrip(self): + original = IpPacket(protocol=6, payload=b"\xaa\xbb", options=b"\x10\x20") + packed = original.pack() + unpacked = IpPacket.unpack(packed) + self.assertEqual(unpacked.protocol, 6) + self.assertEqual(unpacked.options, b"\x10\x20") + self.assertEqual(unpacked.payload, b"\xaa\xbb") + + def test_harp_action_bit_and_roundtrip(self): + original = HarpPacket( + src=Address(2, 1, 65535), + dst=Address(1, 1, 257), + seq=7, + protocol=2, + action_code=3, + payload=b"\x01", + response_required=True, + ) + self.assertEqual(original.action, 0x13) + unpacked = HarpPacket.unpack(original.pack()) + self.assertEqual(unpacked.action_code, 3) + self.assertTrue(unpacked.response_required) + + def test_hoi_fragment_count_reflects_fragmented_params(self): + frag1 = b"\x03\x00\x04\x00" + b"\x01\x02\x03\x04" + frag2 = b"\x04\x00\x01\x00" + b"\x05" + packet = HoiPacket(interface_id=1, action_code=3, action_id=9, params=frag1 + frag2) + packed = packet.pack() + self.assertEqual(packed[5], 2) + + def test_registration_packet_roundtrip(self): + original = RegistrationPacket( + action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST, + response_code=0, + req_address=Address(2, 5, 65535), + res_address=Address(0, 0, 0), + options=b"\x05\x02\x02\x01", + ) + unpacked = RegistrationPacket.unpack(original.pack()) + self.assertEqual(unpacked.action_code, original.action_code) + self.assertEqual(unpacked.req_address, original.req_address) + self.assertEqual(unpacked.options, original.options) + + +class TestHoiParamsAndParser(unittest.TestCase): + def test_bool_array_wire_shape_keeps_padding_semantics(self): + params = HoiParams().add([True, False, True], BoolArray).build() + self.assertEqual(params[0], HamiltonDataType.BOOL_ARRAY) + self.assertEqual(params[1], 0x01) # padded flag required by protocol + self.assertEqual(params[2:4], b"\x04\x00") + self.assertEqual(params[4:], b"\x01\x00\x01\x00") + + def test_string_array_wire_shape(self): + params = HoiParams().add(["a", "bc"], StrArray).build() + self.assertEqual(params[0], HamiltonDataType.STRING_ARRAY) + self.assertEqual(params[2:4], b"\x05\x00") + self.assertEqual(params[4:], b"a\x00bc\x00") + + def test_parser_roundtrip_mixed_payload(self): + payload = HoiParams().add(42, I32).add("ok", Str).add(True, Bool).build() + parser = HoiParamsParser(payload) + values = [parser.parse_next()[1], parser.parse_next()[1], parser.parse_next()[1]] + self.assertEqual(values, [42, "ok", True]) + self.assertFalse(parser.has_remaining()) + + def test_decode_fragment_structure_array(self): + p1 = b"a" + p2 = b"bc" + inner = ( + bytes([HamiltonDataType.STRUCTURE, 0]) + + struct.pack(" Address: + self.assertEqual(path, "Root.Child") + return Address(1, 1, 999) + + client.resolve_path = _fake_resolve_path # type: ignore[method-assign] + got = asyncio.run( + client.resolve_target("pipettor_service", aliases={"pipettor_service": "Root.Child"}) + ) + self.assertEqual(got, Address(1, 1, 999)) + + def test_send_query_returns_hoi_payload_tuple(self): + class Cmd(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 1 + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + payload = HoiParams().add(123, I32).build() + hoi = HoiPacket( + interface_id=0, action_code=Hoi2Action.COMMAND_RESPONSE, action_id=1, params=payload + ) + harp = HarpPacket( + src=Address(1, 1, 257), + dst=Address(2, 1, 65535), + seq=1, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + raw = asyncio.run(client.send_query(Cmd(Address(1, 1, 257)))) + assert raw is not None + self.assertIsInstance(raw, tuple) + self.assertEqual(raw[0], HoiParams().add(123, I32).build()) + + def test_get_firmware_tree_uses_cache_and_refresh(self): + registry = ObjectRegistry() + registry.set_root_address(Address(1, 1, 100)) + + async def _unused(*a, **k): + raise RuntimeError("unused in this test") + + intro = HamiltonIntrospection( + registry=registry, + global_object_addresses=[], + send_discovery_command=_unused, + send_query=_unused, + ) + counts = {"obj": 0, "sub": 0} + root = Address(1, 1, 100) + child = Address(1, 1, 101) + + async def fake_get_object(addr: Address) -> ObjectInfo: + counts["obj"] += 1 + if addr == root: + return ObjectInfo("Root", "", method_count=2, subobject_count=1, address=addr) + return ObjectInfo("Child", "", method_count=1, subobject_count=0, address=addr) + + async def fake_get_supported(addr: Address): + return {1, 3} if addr == root else {1} + + async def fake_get_subobject_address(_addr: Address, idx: int) -> Address: + counts["sub"] += 1 + self.assertEqual(idx, 0) + return child + + intro.get_object = fake_get_object # type: ignore[method-assign, assignment] + intro.get_supported_interface0_method_ids = fake_get_supported # type: ignore[method-assign, assignment] + intro.get_subobject_address = fake_get_subobject_address # type: ignore[method-assign, assignment] + + t1 = asyncio.run(intro.get_firmware_tree()) + t2 = asyncio.run(intro.get_firmware_tree()) + t3 = asyncio.run(intro.get_firmware_tree(refresh=True)) + + self.assertIs(t1, t2) + self.assertIsNot(t1, t3) + self.assertEqual(t1.path, "Root") + self.assertEqual(len(t1.children), 1) + self.assertIn("Root.Child", str(t1)) + self.assertGreaterEqual(counts["obj"], 4) # built twice (initial + refresh) + self.assertGreaterEqual(counts["sub"], 2) + + def test_flatten_firmware_tree_preorder(self): + a0 = Address(1, 1, 10) + a1 = Address(1, 1, 11) + a2 = Address(1, 1, 12) + o0 = ObjectInfo(name="root", version="v", method_count=1, subobject_count=2, address=a0) + o1 = ObjectInfo(name="child", version="v", method_count=1, subobject_count=0, address=a1) + o2 = ObjectInfo(name="other", version="v", method_count=1, subobject_count=0, address=a2) + c1 = FirmwareTreeNode(path="R.child", address=a1, object_info=o1, children=[]) + c2 = FirmwareTreeNode(path="R.other", address=a2, object_info=o2, children=[]) + root = FirmwareTreeNode(path="R", address=a0, object_info=o0, children=[c1, c2]) + flat = flatten_firmware_tree(root) + self.assertEqual([p for p, _, _ in flat], ["R", "R.child", "R.other"]) + + def test_get_firmware_tree_flat_delegates_to_flatten(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + a0 = Address(1, 1, 20) + o0 = ObjectInfo(name="only", version="v", method_count=0, subobject_count=0, address=a0) + root = FirmwareTreeNode(path="Only", address=a0, object_info=o0, children=[]) + + async def fake_get_firmware_tree(refresh: bool = False): + del refresh + return root + + client.introspection.get_firmware_tree = fake_get_firmware_tree # type: ignore[method-assign] + got = asyncio.run(client.introspection.get_firmware_tree_flat()) + self.assertEqual(len(got), 1) + self.assertEqual(got[0][0], "Only") + self.assertEqual(got[0][1], a0) + self.assertIs(got[0][2], o0) + + +class TestHcResultHelperUsesIntrospection(unittest.IsolatedAsyncioTestCase): + async def test_describe_entry_routes_to_introspection(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + entry = HcResultEntry(1, 1, 257, 1, 6, 0xF08) + client.introspection.get_interface_name = AsyncMock(return_value="ITest") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock( # type: ignore[method-assign] + return_value="Simulated" + ) + + iface_name, desc = await client._describe_entry(entry) + self.assertEqual(iface_name, "ITest") + self.assertEqual(desc, "Simulated") + + async def test_format_entry_context_uses_method_lookup_from_introspection(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + addr = Address(1, 1, 257) + client.registry.register( + "Root.Channel", + ObjectInfo(name="Channel", version="", method_count=0, subobject_count=0, address=addr), + ) + method = MethodInfo(interface_id=1, call_type=0, method_id=6, name="DoThing") + client.introspection.get_method_by_id = AsyncMock(return_value=method) # type: ignore[method-assign] + entry = HcResultEntry(1, 1, 257, 1, 6, 0xF08) + + context = await client._format_entry_context(entry) + assert context is not None + self.assertIn("path=Root.Channel", context) + self.assertIn("DoThing(void) -> void", context) + + +class TestWarningAndExceptionSemantics(unittest.TestCase): + @staticmethod + def _format_entry(entry: HcResultEntry) -> str: + return ( + f"0x{entry.module_id:04X}.0x{entry.node_id:04X}.0x{entry.object_id:04X}:" + f"0x{entry.interface_id:02X},0x{entry.action_id:04X},0x{entry.result:04X}" + ) + + @classmethod + def _build_warning_params(cls, entries: list[HcResultEntry], tail: bytes = b"") -> bytes: + summary = HoiParams().add(len(entries), U16).build() + entries_frag = HoiParams().add(";".join(cls._format_entry(e) for e in entries), Str).build() + return cast(bytes, summary + entries_frag + tail) + + def test_non_warning_action_does_not_strip(self): + payload = HoiParams().add(True, Bool).build() + rest, entries = split_hoi_params_after_warning_prefix(Hoi2Action.COMMAND_RESPONSE, payload) + self.assertEqual(rest, payload) + self.assertEqual(entries, []) + + def test_warning_prefix_strip_and_parse_entries(self): + entries = [HcResultEntry(1, 1, 257, 1, 6, 0x8001)] + tail = HoiParams().add(99, I32).build() + params = self._build_warning_params(entries, tail=tail) + rest, parsed = split_hoi_params_after_warning_prefix(Hoi2Action.COMMAND_WARNING, params) + self.assertEqual(rest, tail) + self.assertEqual(len(parsed), 1) + self.assertEqual(parsed[0].result, 0x8001) + self.assertTrue(parsed[0].is_warning) + + def test_parse_hamilton_error_entry_and_entries(self): + e1 = HcResultEntry(1, 1, 257, 1, 6, 0x0F08) + e2 = HcResultEntry(1, 1, 257, 1, 6, 0x0F09) + + one = HoiParams().add(self._format_entry(e1), Str).build() + got_one = parse_hamilton_error_entry(one) + assert got_one is not None + self.assertEqual(got_one.result, 0x0F08) + + two = HoiParams().add(self._format_entry(e1), Str).add(self._format_entry(e2), Str).build() + got_two = parse_hamilton_error_entries(two) + self.assertEqual([e.result for e in got_two], [0x0F08, 0x0F09]) + + +class TestErrorEntryChannelDetection(unittest.TestCase): + @dataclass + class _Ap: + channel: int + + @dataclass + class _CmdPrep(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 1 + dest: Address + aspirate_parameters: list + + def __post_init__(self): + super().__init__(self.dest) + + def test_true_when_struct_array_has_channel(self): + c = TestErrorEntryChannelDetection._CmdPrep( + Address(1, 1, 1), aspirate_parameters=[TestErrorEntryChannelDetection._Ap(0)] + ) + self.assertTrue(c.error_entries_use_physical_channels()) + + @dataclass + class _CmdVoid(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 35 + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + def test_false_for_void_command(self): + c = TestErrorEntryChannelDetection._CmdVoid(Address(1, 1, 1)) + self.assertFalse(c.error_entries_use_physical_channels()) + + @dataclass + class _CmdNimbus(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 4 + dest: Address + channels_involved: tuple + + def __post_init__(self): + super().__init__(self.dest) + + def test_true_when_channels_involved_present(self): + c = TestErrorEntryChannelDetection._CmdNimbus(Address(1, 1, 1), (1, 0)) + self.assertTrue(c.error_entries_use_physical_channels()) + + +class TestSendCommandStatusException(unittest.IsolatedAsyncioTestCase): + @staticmethod + def _format_wire_entry(entry: HcResultEntry) -> str: + return ( + f"0x{entry.module_id:04X}.0x{entry.node_id:04X}.0x{entry.object_id:04X}:" + f"0x{entry.interface_id:02X},0x{entry.action_id:04X},0x{entry.result:04X}" + ) + + async def test_void_command_raises_hoi_error(self): + entry = HcResultEntry(1, 1, 5376, 1, 35, 0x0206) + err_params = HoiParams().add(self._format_wire_entry(entry), Str).build() + + @dataclass + class CmdVoid(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 35 + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.STATUS_EXCEPTION, + action_id=0, + params=err_params, + ) + harp = HarpPacket( + src=Address(1, 1, 5376), + dst=Address(2, 1, 65535), + seq=1, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + client.introspection.get_interface_name = AsyncMock(return_value="MLPrep") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] + + cmd = CmdVoid(Address(1, 1, 5376)) + with self.assertRaises(HoiError) as ctx: + await client.send_command(cmd) + self.assertIn(0, ctx.exception.exceptions) + self.assertEqual(ctx.exception.entries[0].result, 0x0206) + + async def test_channels_involved_raises_channelized_error(self): + entry = HcResultEntry(1, 1, 257, 1, 6, 0x0F08) + err_params = HoiParams().add(self._format_wire_entry(entry), Str).build() + + @dataclass + class CmdPick(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 4 + dest: Address + channels_involved: tuple + + def __post_init__(self): + super().__init__(self.dest) + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.STATUS_EXCEPTION, + action_id=0, + params=err_params, + ) + harp = HarpPacket( + src=Address(1, 1, 257), + dst=Address(2, 1, 65535), + seq=1, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + client.introspection.get_interface_name = AsyncMock(return_value="Pipette") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] + + cmd = CmdPick(Address(1, 1, 257), (1, 0)) + with self.assertRaises(ChannelizedError) as ctx: + await client.send_command(cmd) + self.assertIn(0, ctx.exception.errors) + self.assertEqual(len(ctx.exception.kwargs["hoi_entries"]), 1) + self.assertIn(0, ctx.exception.kwargs["hoi_exceptions"]) + + +class TestHcResultDescriptionNimbusTable(unittest.IsolatedAsyncioTestCase): + """NIMBUS_ERROR_CODES keys use interface_id in the 4th slot; describe_entry must match that.""" + + async def test_lookup_uses_interface_id_not_method_id(self): + class _NimbusClient(HamiltonTCPClient): + _ERROR_CODES = NIMBUS_ERROR_CODES + + client = _NimbusClient(host="127.0.0.1", port=0) + client.introspection.get_interface_name = AsyncMock(return_value="Pipette") # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] + entry = HcResultEntry(0x0001, 0x0001, 0x0110, 1, 6, 0x0F4E) + _iface, desc = await client._describe_entry(entry) + self.assertIn("Tip Detected Not Correct Tip", desc) + entry_b = HcResultEntry(0x0001, 0x0001, 0x0110, 1, 6, 0x0F4B) + _iface_b, desc_b = await client._describe_entry(entry_b) + self.assertIn("No Tip Picked Up", desc_b) + + +class TestCountedFlatArrayDecode(unittest.TestCase): + def test_counted_flat_array_nested_decode(self): + data = ( + HoiParams() + .add(1, I64) # enum_count + .add(1, I64) # enum_id + .add("E1", Str) + .add(2, I64) # value_count + .add("v1", Str) + .add(10, I64) + .add("v2", Str) + .add(20, I64) + .build() + ) + + parsed = parse_into_struct(HoiParamsParser(data), _GetEnumsResponse) + self.assertEqual(len(parsed.enums), 1) + self.assertEqual(parsed.enums[0].name, "E1") + self.assertEqual([v.name for v in parsed.enums[0].values], ["v1", "v2"]) + self.assertEqual([v.value for v in parsed.enums[0].values], [10, 20]) + + def test_i16_array_roundtrip_decode_fragment(self): + payload = struct.pack(" tuple[int, ...]: + """Collect all IDs from rows matching a boolean flag (is_struct_kind, is_enum_kind, etc.).""" + ids: list[int] = [] + for row in introspection_mod._HOI_TYPE_ROWS: + if getattr(row, flag): + ids.extend(tid for tid in row.ids if tid != 0) + return tuple(ids) + + def test_complex_method_and_struct_sets_are_disjoint(self): + self.assertTrue( + introspection_mod._COMPLEX_METHOD_TYPE_IDS.isdisjoint( + introspection_mod._COMPLEX_STRUCT_TYPE_IDS + ) + ) + + def test_method_param_struct_and_enum_ref_types_are_disjoint(self): + struct_wire = {HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY} + enum_wire = {HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY} + self.assertTrue(struct_wire.isdisjoint(enum_wire)) + + def test_method_param_type_struct_refs_cover_all_directions(self): + for row in introspection_mod._HOI_TYPE_ROWS: + if not row.is_struct_kind: + continue + for direction, tid in zip(introspection_mod.Direction, row.ids): + pt = introspection_mod.MethodParamType(row.wire_type, direction, source_id=2, ref_id=1) + self.assertTrue(pt.is_struct_ref) + self.assertFalse(pt.is_enum_ref) + + def test_struct_field_type_struct_refs_cover_wire_sentinels(self): + for wire_type in (HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE_ARRAY): + sft = introspection_mod.StructFieldType(wire_type, source_id=2, ref_id=1) + self.assertTrue(sft.is_complex) + self.assertTrue(sft.is_struct_ref) + self.assertFalse(sft.is_enum_ref) + + def test_method_param_type_enum_refs_cover_all_directions(self): + for row in introspection_mod._HOI_TYPE_ROWS: + if not row.is_enum_kind: + continue + for direction, tid in zip(introspection_mod.Direction, row.ids): + pt = introspection_mod.MethodParamType(row.wire_type, direction, source_id=2, ref_id=1) + self.assertTrue(pt.is_enum_ref) + self.assertFalse(pt.is_struct_ref) + + def test_struct_field_type_enum_refs_cover_wire_sentinels(self): + for wire_type in (HamiltonDataType.ENUM, HamiltonDataType.ENUM_ARRAY): + sft = introspection_mod.StructFieldType(wire_type, source_id=2, ref_id=1) + self.assertTrue(sft.is_complex) + self.assertTrue(sft.is_enum_ref) + self.assertFalse(sft.is_struct_ref) + + def test_scalar_method_param_type_is_not_a_reference(self): + row = next(r for r in introspection_mod._HOI_TYPE_ROWS if r.display_name == "i32") + pt = introspection_mod.MethodParamType(row.wire_type, introspection_mod.Direction.In) + self.assertFalse(pt.is_struct_ref) + self.assertFalse(pt.is_enum_ref) + + def test_scalar_struct_field_type_is_not_complex_or_reference(self): + sft = introspection_mod.StructFieldType(HamiltonDataType.F32) + self.assertFalse(sft.is_complex) + self.assertFalse(sft.is_struct_ref) + self.assertFalse(sft.is_enum_ref) + + +class TestIntrospectionTypeParsers(unittest.TestCase): + def test_parse_method_param_types_supports_simple_ref_and_node_global(self): + # [i8 In] + [struct In source=2 id=1] + [struct In source=4 id=9 "01" ] + raw = [1, 57, 2, 1, 57, 4, 9, 0x22, 0x30, 0x31, 0x22, 0x20] + parsed = introspection_mod._parse_method_param_types(raw) + self.assertEqual(len(parsed), 3) + self.assertEqual( + [pt.wire_type for pt in parsed], + [HamiltonDataType.I8, HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE], + ) + self.assertEqual( + [pt.direction for pt in parsed], + [ + introspection_mod.Direction.In, + introspection_mod.Direction.In, + introspection_mod.Direction.In, + ], + ) + self.assertEqual([pt._byte_width for pt in parsed], [1, 3, 8]) + self.assertEqual((parsed[1].source_id, parsed[1].ref_id), (2, 1)) + self.assertEqual((parsed[2].source_id, parsed[2].ref_id), (4, 9)) + + def test_parse_struct_field_types_supports_simple_ref_and_node_global(self): + # [F32 simple] + [STRUCT source=2 id=3] + [STRUCT source=4 id=7 ModHi ModLo NodeHi NodeLo] + raw = [40, 30, 2, 3, 30, 4, 7, 0x00, 0x01, 0x00, 0x02] + parsed = introspection_mod._parse_struct_field_types(raw) + self.assertEqual(len(parsed), 3) + self.assertEqual( + [pt.type_id for pt in parsed], + [HamiltonDataType.F32, HamiltonDataType.STRUCTURE, HamiltonDataType.STRUCTURE], + ) + self.assertEqual([pt._byte_width for pt in parsed], [1, 3, 7]) + self.assertEqual((parsed[1].source_id, parsed[1].ref_id), (2, 3)) + self.assertEqual((parsed[2].source_id, parsed[2].ref_id), (4, 7)) + + def test_struct_parser_byte_width_sum_matches_cursor_advance(self): + raw = [40, 30, 2, 3, 30, 4, 7, 0x00, 0x01, 0x00, 0x02] + parsed = introspection_mod._parse_struct_field_types(raw) + bytes_used = sum(pt._byte_width for pt in parsed[:3]) + self.assertEqual(bytes_used, len(raw)) + + +class TestHamiltonIntrospectionLazyCaches(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.addr = Address(1, 1, 99) + + async def _should_not_be_called(*a, **k): + raise AssertionError("transport should be patched out in introspection cache tests") + + self.intro = HamiltonIntrospection( + registry=ObjectRegistry(), + global_object_addresses=[], + send_discovery_command=_should_not_be_called, + send_query=_should_not_be_called, + ) + + async def test_second_ensure_method_table_skips_get_method(self): + info = ObjectInfo(name="O", version="", method_count=2, subobject_count=0, address=self.addr) + self.intro.get_object = AsyncMock(return_value=info) # type: ignore[method-assign] + self.intro.get_supported_interface0_method_ids = AsyncMock( # type: ignore[method-assign] + return_value={1, 2, 4, 5, 6} + ) + gm = AsyncMock( + side_effect=[ + MethodInfo(1, 0, 0, "a", [], [], [], []), + MethodInfo(1, 0, 1, "b", [], [], [], []), + ] + ) + self.intro.get_method = gm # type: ignore[method-assign] + r1 = await self.intro.ensure_method_table(self.addr) + self.assertEqual(len(r1), 2) + self.assertEqual(gm.call_count, 2) + r2 = await self.intro.methods_for_interface(self.addr, 1) + self.assertEqual(len(r2), 2) + self.assertEqual(gm.call_count, 2) + r3 = await self.intro.ensure_method_table(self.addr) + self.assertIs(r1, r3) + + async def test_lazy_signature_loads_only_referenced_iface(self): + st = StructInfo(struct_id=0, name="TipParams", fields={}, interface_id=1) + pt = introspection_mod.MethodParamType( + HamiltonDataType.STRUCTURE, introspection_mod.Direction.In, source_id=2, ref_id=1 + ) + m = MethodInfo(1, 0, 3, "Foo", [pt], ["p"], [], []) + info = ObjectInfo(name="O", version="", method_count=1, subobject_count=0, address=self.addr) + self.intro.get_object = AsyncMock(return_value=info) # type: ignore[method-assign] + self.intro.get_supported_interface0_method_ids = AsyncMock( # type: ignore[method-assign] + return_value={1, 2, 4, 5, 6} + ) + self.intro.get_method = AsyncMock(return_value=m) # type: ignore[method-assign] + self.intro.ensure_global_type_pool = AsyncMock( # type: ignore[method-assign] + return_value=GlobalTypePool() + ) + touched: list[int] = [] + + async def fake_ensure(addr, iface_id): + touched.append(iface_id) + key = (addr, iface_id) + self.intro._iface_types[key] = ({0: st}, {}) + + self.intro.ensure_structs_enums = fake_ensure # type: ignore[method-assign] + + sig = await self.intro.resolve_signature(self.addr, 1, 3) + self.assertIn("TipParams", sig) + self.assertEqual(touched, [1]) + + async def test_lazy_signature_matches_full_registry_for_local_struct(self): + st = StructInfo(struct_id=0, name="TipParams", fields={}, interface_id=1) + pt = introspection_mod.MethodParamType( + HamiltonDataType.STRUCTURE, introspection_mod.Direction.In, source_id=2, ref_id=1 + ) + m = MethodInfo(1, 0, 3, "Foo", [pt], ["p"], [], []) + info = ObjectInfo(name="O", version="", method_count=1, subobject_count=0, address=self.addr) + self.intro.get_object = AsyncMock(return_value=info) # type: ignore[method-assign] + self.intro.get_supported_interface0_method_ids = AsyncMock( # type: ignore[method-assign] + return_value={1, 2, 4, 5, 6} + ) + self.intro.get_method = AsyncMock(return_value=m) # type: ignore[method-assign] + self.intro.get_structs = AsyncMock(return_value=[st]) # type: ignore[method-assign] + self.intro.get_enums = AsyncMock(return_value=[]) # type: ignore[method-assign] + self.intro.ensure_global_type_pool = AsyncMock( # type: ignore[method-assign] + return_value=GlobalTypePool() + ) + + lazy_sig = await self.intro.resolve_signature(self.addr, 1, 3) + + full = TypeRegistry(address=self.addr, global_pool=GlobalTypePool()) + full.methods = [m] + full.structs[1] = {0: st} + full_sig = m.get_signature_string(full) + self.assertEqual(lazy_sig, full_sig) + + async def test_interface_name_and_hc_result_text_use_introspection_session_cache(self): + self.intro.get_interfaces = AsyncMock( # type: ignore[method-assign] + return_value=[InterfaceInfo(interface_id=1, name="ITest", version="")] + ) + name1 = await self.intro.get_interface_name(self.addr, 1) + name2 = await self.intro.get_interface_name(self.addr, 1) + self.assertEqual(name1, "ITest") + self.assertEqual(name2, "ITest") + self.assertEqual(self.intro.get_interfaces.call_count, 1) + + self.intro.get_supported_interface0_method_ids = AsyncMock(return_value={5, 6}) # type: ignore[method-assign] + self.intro.get_structs = AsyncMock(return_value=[]) # type: ignore[method-assign] + self.intro.get_enums = AsyncMock( # type: ignore[method-assign] + return_value=[ + EnumInfo( + enum_id=0, + name="HcResult", + values={"OK": 0, "SomethingFailed": 0xF08}, + ) + ] + ) + text1 = await self.intro.get_hc_result_text(self.addr, 1, 0xF08) + text2 = await self.intro.get_hc_result_text(self.addr, 1, 0xF08) + self.assertEqual(text1, "SomethingFailed") + self.assertEqual(text2, "SomethingFailed") + self.assertEqual(self.intro.get_enums.call_count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/hamilton/transport/tcp/wire_types.py b/pylabrobot/hamilton/transport/tcp/wire_types.py new file mode 100644 index 00000000000..d60adde3eb7 --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/wire_types.py @@ -0,0 +1,393 @@ +"""Unified bidirectional codec layer for Hamilton DataFragments. + +Single source of truth for DataFragment type IDs, encoding, and decoding. Each +WireType handles both encode (encode_into) and decode (decode_from) via the +same format; no separate dispatch tables or coercion blocks. + +Layout: (1) HamiltonDataType enum (type IDs for [type_id:1][flags:1][length:2] +[data:N]); (2) WireType hierarchy and Annotated type aliases (I32, F32, Bool, +Str, I32Array, etc.); (3) type registry and decode_fragment(). HoiParams and +HoiParamsParser delegate to this layer exclusively. +""" + +from __future__ import annotations + +import struct as _struct +from dataclasses import dataclass +from enum import IntEnum +from typing import TYPE_CHECKING, Annotated, Any + +if TYPE_CHECKING: + from pylabrobot.hamilton.transport.tcp.messages import HoiParams + + +# --------------------------------------------------------------------------- +# Hamilton DataFragment type IDs (codec layer) +# --------------------------------------------------------------------------- +# Type identifiers for the DataFragment wire format [type_id:1][flags:1][length:2][data:N]. +# From Hamilton.Components.TransportLayer.Protocols.Parameter.ParameterTypes. + + +class HamiltonDataType(IntEnum): + """Hamilton parameter data types for wire encoding in DataFragments.""" + + VOID = 0 + # Scalar integer types + I8 = 1 + I16 = 2 + I32 = 3 + U8 = 4 + U16 = 5 + U32 = 6 + I64 = 36 + U64 = 37 + + # Floating-point types + F32 = 40 + F64 = 41 + + # String and boolean + STRING = 15 + BOOL = 23 + + # Structure and enum types (Prep and introspection) + STRUCTURE = 30 + STRUCTURE_ARRAY = 31 + ENUM = 32 + HC_RESULT = 33 # Same wire format as U16, used for error codes + ENUM_ARRAY = 35 + + # Introspection-only compound result type (no wire codec; used for HOI_RESULT method returns) + HOI_RESULT = 44 + + # Array types + U8_ARRAY = 22 + I8_ARRAY = 24 + I16_ARRAY = 25 + U16_ARRAY = 26 + I32_ARRAY = 27 + U32_ARRAY = 28 + BOOL_ARRAY = 29 + STRING_ARRAY = 34 + I64_ARRAY = 38 + U64_ARRAY = 39 + F32_ARRAY = 42 + F64_ARRAY = 43 + + +# --------------------------------------------------------------------------- +# WireType hierarchy +# --------------------------------------------------------------------------- + + +class WireType: + """Base class: a wire-format type that can encode and decode HoiParams fragments.""" + + __slots__ = ("type_id",) + + def __init__(self, type_id: int): + self.type_id = type_id + + def encode_into(self, value, params: HoiParams) -> HoiParams: + raise NotImplementedError + + def decode_from(self, data: bytes) -> Any: + raise NotImplementedError + + +class Scalar(WireType): + """Fixed-size scalar encoded via ``struct.pack(fmt, value)``. + + When *padded* is ``True`` the Prep convention is used: flags byte = 0x01 + and one ``\\x00`` pad byte is appended after the value. + """ + + __slots__ = ("fmt", "padded") + + def __init__(self, type_id: int, fmt: str, padded: bool = False): + super().__init__(type_id) + self.fmt = fmt + self.padded = padded + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = _struct.pack(self.fmt, value) + return params._add_fragment(self.type_id, data, 0x01 if self.padded else 0) + + def decode_from(self, data: bytes) -> Any: + size = _struct.calcsize(self.fmt) + val = _struct.unpack(self.fmt, data[:size])[0] + if self.type_id == HamiltonDataType.BOOL: + return bool(val) + if self.type_id in ( + HamiltonDataType.F32, + HamiltonDataType.F64, + ): + return float(val) + return int(val) + + +class Array(WireType): + """Homogeneous array of packed scalars (no length prefix on the wire).""" + + __slots__ = ("element_fmt",) + + def __init__(self, type_id: int, element_fmt: str): + super().__init__(type_id) + self.element_fmt = element_fmt + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = _struct.pack(f"{len(value)}{self.element_fmt}", *value) + flags = 0x01 if self.type_id == HamiltonDataType.BOOL_ARRAY else 0 + return params._add_fragment(self.type_id, data, flags) + + def decode_from(self, data: bytes) -> Any: + el_size = _struct.calcsize(self.element_fmt) + count = len(data) // el_size + values = _struct.unpack(f"{count}{self.element_fmt}", data[: count * el_size]) + if self.type_id == HamiltonDataType.BOOL_ARRAY: + return [bool(v) for v in values] + return list(values) + + +class Struct(WireType): + """Nested structure -- recurse via ``HoiParams.from_struct``.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRUCTURE) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + from pylabrobot.hamilton.transport.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.transport.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 4e71a71e0197d7c6d63f1716eee580507b32ad17 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:52:28 -0700 Subject: [PATCH 2/2] fix(hamilton.transport.tcp): run client API tests under an event loop Python 3.9 binds asyncio.Lock to the current loop at Socket construction, so HamiltonTCPClient cannot be created in sync TestCase methods. Use IsolatedAsyncioTestCase for those cases. --- .../hamilton/transport/tcp/tests/tcp_tests.py | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index 66ddc7bf0c7..0439cd74e5e 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -9,7 +9,6 @@ from __future__ import annotations -import asyncio import struct import unittest from dataclasses import dataclass @@ -290,14 +289,16 @@ class Response: self.assertEqual(result.value, 42) -class TestTransportApiAlignment(unittest.TestCase): - def test_resolve_target_accepts_address_passthrough(self): +class TestTransportApiAlignment(unittest.IsolatedAsyncioTestCase): + """Client construction needs a running loop on Python 3.9 (``asyncio.Lock`` in ``Socket``).""" + + async def test_resolve_target_accepts_address_passthrough(self): client = HamiltonTCPClient(host="127.0.0.1", port=0) addr = Address(1, 1, 257) - got = asyncio.run(client.resolve_target(addr)) + got = await client.resolve_target(addr) self.assertEqual(got, addr) - def test_resolve_target_applies_aliases(self): + async def test_resolve_target_applies_aliases(self): client = HamiltonTCPClient(host="127.0.0.1", port=0) async def _fake_resolve_path(path: str) -> Address: @@ -305,12 +306,12 @@ 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 = await client.resolve_target( + "pipettor_service", aliases={"pipettor_service": "Root.Child"} ) self.assertEqual(got, Address(1, 1, 999)) - def test_send_query_returns_hoi_payload_tuple(self): + async def test_send_query_returns_hoi_payload_tuple(self): class Cmd(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -338,12 +339,12 @@ 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_query(Cmd(Address(1, 1, 257)))) + raw = await client.send_query(Cmd(Address(1, 1, 257))) assert raw is not None self.assertIsInstance(raw, tuple) self.assertEqual(raw[0], HoiParams().add(123, I32).build()) - def test_get_firmware_tree_uses_cache_and_refresh(self): + async def test_get_firmware_tree_uses_cache_and_refresh(self): registry = ObjectRegistry() registry.set_root_address(Address(1, 1, 100)) @@ -378,9 +379,9 @@ async def fake_get_subobject_address(_addr: Address, idx: int) -> Address: intro.get_supported_interface0_method_ids = fake_get_supported # type: ignore[method-assign, assignment] intro.get_subobject_address = fake_get_subobject_address # type: ignore[method-assign, assignment] - t1 = asyncio.run(intro.get_firmware_tree()) - t2 = asyncio.run(intro.get_firmware_tree()) - t3 = asyncio.run(intro.get_firmware_tree(refresh=True)) + t1 = await intro.get_firmware_tree() + t2 = await intro.get_firmware_tree() + t3 = await intro.get_firmware_tree(refresh=True) self.assertIs(t1, t2) self.assertIsNot(t1, t3) @@ -403,7 +404,7 @@ def test_flatten_firmware_tree_preorder(self): 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): + async 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) @@ -414,7 +415,7 @@ async def fake_get_firmware_tree(refresh: bool = False): return root client.introspection.get_firmware_tree = fake_get_firmware_tree # type: ignore[method-assign] - got = asyncio.run(client.introspection.get_firmware_tree_flat()) + got = await client.introspection.get_firmware_tree_flat() self.assertEqual(len(got), 1) self.assertEqual(got[0][0], "Only") self.assertEqual(got[0][1], a0)