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 01/15] 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 02/15] 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) From fb26a973f179dbc7a8195160c268189299ce196a Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 15 Aug 2026 20:33:45 -0700 Subject: [PATCH 03/15] fix(hamilton): mark transport as a package --- pylabrobot/hamilton/transport/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 pylabrobot/hamilton/transport/__init__.py diff --git a/pylabrobot/hamilton/transport/__init__.py b/pylabrobot/hamilton/transport/__init__.py new file mode 100644 index 00000000000..b55f1787f1c --- /dev/null +++ b/pylabrobot/hamilton/transport/__init__.py @@ -0,0 +1 @@ +"""Transport implementations for Hamilton devices.""" From 8627f84b33c7791212e343634607d05a935ee4e2 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 15 Aug 2026 20:43:20 -0700 Subject: [PATCH 04/15] Move Nimbus and Prep error tables --- pylabrobot/hamilton/nimbus/__init__.py | 1 + pylabrobot/hamilton/nimbus/error_tables.py | 1635 ++++++++++++ pylabrobot/hamilton/prep/__init__.py | 1 + pylabrobot/hamilton/prep/error_tables.py | 551 +++++ .../hamilton/transport/tcp/error_tables.py | 2197 +---------------- .../hamilton/transport/tcp/tests/tcp_tests.py | 2 +- 6 files changed, 2191 insertions(+), 2196 deletions(-) create mode 100644 pylabrobot/hamilton/nimbus/__init__.py create mode 100644 pylabrobot/hamilton/nimbus/error_tables.py create mode 100644 pylabrobot/hamilton/prep/__init__.py create mode 100644 pylabrobot/hamilton/prep/error_tables.py diff --git a/pylabrobot/hamilton/nimbus/__init__.py b/pylabrobot/hamilton/nimbus/__init__.py new file mode 100644 index 00000000000..33ff046d078 --- /dev/null +++ b/pylabrobot/hamilton/nimbus/__init__.py @@ -0,0 +1 @@ +"""Hamilton Nimbus support.""" diff --git a/pylabrobot/hamilton/nimbus/error_tables.py b/pylabrobot/hamilton/nimbus/error_tables.py new file mode 100644 index 00000000000..b01fefe1724 --- /dev/null +++ b/pylabrobot/hamilton/nimbus/error_tables.py @@ -0,0 +1,1635 @@ +"""Hamilton Nimbus error-code table.""" + +from __future__ import annotations + +from typing import Dict, Tuple + +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.", +} diff --git a/pylabrobot/hamilton/prep/__init__.py b/pylabrobot/hamilton/prep/__init__.py new file mode 100644 index 00000000000..cc0e36dfd8e --- /dev/null +++ b/pylabrobot/hamilton/prep/__init__.py @@ -0,0 +1 @@ +"""Hamilton Prep support.""" diff --git a/pylabrobot/hamilton/prep/error_tables.py b/pylabrobot/hamilton/prep/error_tables.py new file mode 100644 index 00000000000..28f7315e698 --- /dev/null +++ b/pylabrobot/hamilton/prep/error_tables.py @@ -0,0 +1,551 @@ +"""Hamilton Prep error-code table.""" + +from __future__ import annotations + +from typing import Dict, Tuple + +# 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/error_tables.py b/pylabrobot/hamilton/transport/tcp/error_tables.py index f7a50da856d..418f30e870f 100644 --- a/pylabrobot/hamilton/transport/tcp/error_tables.py +++ b/pylabrobot/hamilton/transport/tcp/error_tables.py @@ -1,25 +1,8 @@ -"""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. -""" +"""Universal Hamilton HOI result codes.""" from __future__ import annotations -from typing import Dict, Tuple +from typing import Dict HC_RESULT_PROTOCOL: Dict[int, str] = { 0: "Success", @@ -223,2179 +206,3 @@ 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/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index 0439cd74e5e..08695ef7702 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -17,7 +17,7 @@ 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.nimbus.error_tables import NIMBUS_ERROR_CODES from pylabrobot.hamilton.transport.tcp.hoi_error import ( HoiError, parse_hamilton_error_entries, From f6431fc022c27ca54cea621bda7a9047a9c4045c Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:58:45 -0700 Subject: [PATCH 05/15] fix(hamilton.transport.tcp): never retransmit a command; drop auto-reconnect _send_raw retried by re-writing the command on connection errors. TimeoutError and OSError were both treated as retryable, and the read uses a 300s default timeout, so a slow motion command that timed out on the read was physically executed twice. Reconnection now belongs to the caller: stop() then setup(). No other transport in the repo self-heals a connection, and re-establishing the session cannot report whether the in-flight command completed. Session state is scoped to the connected session and fully reset by setup(): client id, sequence numbers, instrument addresses and the object registry all survived a reconnect before. setup() on a live client now raises instead of leaking the socket. --- .../hamilton/transport/tcp/introspection.py | 6 + pylabrobot/hamilton/transport/tcp/tcp.py | 420 ++++++++---------- 2 files changed, 198 insertions(+), 228 deletions(-) diff --git a/pylabrobot/hamilton/transport/tcp/introspection.py b/pylabrobot/hamilton/transport/tcp/introspection.py index a5149578e89..05404bcc2e5 100644 --- a/pylabrobot/hamilton/transport/tcp/introspection.py +++ b/pylabrobot/hamilton/transport/tcp/introspection.py @@ -265,6 +265,12 @@ def __init__(self): self._address_to_path: Dict[Address, str] = {} self._root_address: Optional[Address] = None + def clear(self) -> None: + """Drop every mapping. Object addresses are scoped to one connected session.""" + self._objects.clear() + self._address_to_path.clear() + self._root_address = None + def set_root_address(self, address: Address) -> None: self._root_address = address diff --git a/pylabrobot/hamilton/transport/tcp/tcp.py b/pylabrobot/hamilton/transport/tcp/tcp.py index 6d1d8c46ad3..135f09e247c 100644 --- a/pylabrobot/hamilton/transport/tcp/tcp.py +++ b/pylabrobot/hamilton/transport/tcp/tcp.py @@ -2,11 +2,24 @@ Use :attr:`HamiltonTCPClient.introspection` as the **only** supported entry for Interface-0 discovery and type work. + +Connection lifecycle +-------------------- +Connecting is always caller-driven. :meth:`HamiltonTCPClient.setup` performs the +init handshake (during which the device assigns the client id that identifies the +session), registers the client, and discovers the root and global objects. There +is no automatic reconnection: if the connection drops, recovery is +``await client.stop()`` followed by ``await client.setup()``. + +That is deliberate. Re-establishing the session cannot tell the caller whether an +in-flight command completed, and it does not stop instrument motion, so continuity +across a reconnect must never be assumed. Reconnecting silently would only hide +when that happened. :attr:`HamiltonTCPClient.is_connected` is available for callers +that want to implement their own policy. """ from __future__ import annotations -import asyncio import logging from typing import Any, Callable, ClassVar, Dict, Optional, Sequence, Tuple, Union, cast @@ -55,8 +68,6 @@ def __init__( port: int, read_timeout: float = 300.0, write_timeout: float = 30.0, - auto_reconnect: bool = True, - max_reconnect_attempts: int = 3, connection_timeout: int = 600, ): self.io = Socket( @@ -68,9 +79,6 @@ def __init__( ) self._connected = False - self._reconnect_attempts = 0 - self.auto_reconnect = auto_reconnect - self.max_reconnect_attempts = max_reconnect_attempts self._connection_timeout = connection_timeout self._client_id: Optional[int] = None @@ -185,52 +193,27 @@ def _dispatch_event(self, response_message: CommandResponse) -> None: logger.exception("Event handler %r raised: %s", handler, exc) def _clear_session_state_for_setup(self) -> None: + """Drop every piece of state scoped to one connected session. + + A session's identity is the client id the device assigns during the init + handshake. Sequence numbers, discovered object addresses and the path + registry are all keyed to that session and are meaningless — actively + misleading — once it ends, so ``setup()`` starts from empty every time. + """ + self._client_id = None + self.client_address = None + self._sequence_numbers = {} + self._instrument_addresses = {} self._global_object_addresses = [] + self._registry.clear() self._invalidate_introspection_session() - async def _ensure_connected(self): + def _require_connected(self) -> None: if not self._connected: - if not self.auto_reconnect: - raise ConnectionError( - f"{self.io._unique_id} Connection not established and auto-reconnect disabled" - ) - logger.info(f"{self.io._unique_id} Connection not established, attempting to reconnect...") - await self._reconnect() - - async def _reconnect(self): - if not self.auto_reconnect: - raise ConnectionError(f"{self.io._unique_id} Auto-reconnect disabled") - - for attempt in range(self.max_reconnect_attempts): - try: - logger.info( - f"{self.io._unique_id} Reconnection attempt {attempt + 1}/{self.max_reconnect_attempts}" - ) - - try: - await self.stop() - except Exception: - pass - - if attempt > 0: - wait_time = 1.0 * (2 ** (attempt - 1)) - await asyncio.sleep(wait_time) - - await self.setup() - self._reconnect_attempts = 0 - logger.info(f"{self.io._unique_id} Reconnection successful") - return - - except Exception as e: - logger.warning(f"{self.io._unique_id} Reconnection attempt {attempt + 1} failed: {e}") - - self._connected = False - raise ConnectionError( - f"{self.io._unique_id} Failed to reconnect after {self.max_reconnect_attempts} attempts" - ) + raise ConnectionError(f"{self.io._unique_id} not connected - call setup()") async def write(self, data: bytes, timeout: Optional[float] = None): - await self._ensure_connected() + self._require_connected() try: await self.io.write(data, timeout=timeout) @@ -240,7 +223,7 @@ async def write(self, data: bytes, timeout: Optional[float] = None): raise async def read(self, num_bytes: int = 128, timeout: Optional[float] = None) -> bytes: - await self._ensure_connected() + self._require_connected() try: data = await self.io.read(num_bytes, timeout=timeout) @@ -251,7 +234,7 @@ async def read(self, num_bytes: int = 128, timeout: Optional[float] = None) -> b raise async def read_exact(self, num_bytes: int, timeout: Optional[float] = None) -> bytes: - await self._ensure_connected() + self._require_connected() try: data = await self.io.read_exact(num_bytes, timeout=timeout) @@ -296,10 +279,13 @@ async def _read_one_message( return CommandResponse.from_bytes(complete_data) async def setup(self): + if self._connected: + raise RuntimeError( + f"{self.io._unique_id} already set up - call stop() before setting up again" + ) self._clear_session_state_for_setup() await self.io.setup() self._connected = True - self._reconnect_attempts = 0 await self._initialize_connection() await self._register_client() await self._discover_root() @@ -464,7 +450,6 @@ async def send_command( """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, @@ -486,7 +471,6 @@ async def send_query( Optional[tuple], await self._send_raw( command, - ensure_connection=True, return_raw=True, raise_on_error=False, read_timeout=read_timeout, @@ -499,10 +483,13 @@ async def send_discovery_command( *, read_timeout: Optional[float] = None, ) -> Any: - """Send an Interface-0 introspection command during setup (no reconnect on failure).""" + """Send an Interface-0 introspection command. + + Behaviourally identical to :meth:`send_command`; kept as a distinct name so + introspection call sites read as discovery rather than device control. + """ return await self._send_raw( command, - ensure_connection=False, return_raw=False, raise_on_error=True, read_timeout=read_timeout, @@ -512,194 +499,171 @@ async def _send_raw( self, command: TCPCommand, *, - ensure_connection: bool, return_raw: bool, raise_on_error: bool, read_timeout: Optional[float] = None, ) -> Any: - connection_errors = ( - BrokenPipeError, - ConnectionError, - ConnectionResetError, - ConnectionAbortedError, - TimeoutError, - OSError, - ) - max_attempts = 2 if ensure_connection else 1 - last_error: Optional[BaseException] = None - - for attempt in range(max_attempts): - try: - if command.source_address is None: - if self.client_address is None: - raise RuntimeError( - "Client not initialized - call setup() first to assign client_address" - ) - command.source_address = self.client_address - - command.sequence_number = self._allocate_sequence_number(command.dest_address) - message = command.build() + """Transmit *command* once and interpret the terminal response. - log_params = command.get_log_params() - logger.debug(f"{command.__class__.__name__} parameters: {log_params}") - - await self.write(message) + Never retransmits. A command that fails after the write has left the client + may or may not have been executed by the instrument, so re-sending it could + run a motion twice; recovery is the caller's decision. + """ + 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 - 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, + 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]}" ) - 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)", + for ch in sorted(per_channel) + ) + logger.error( + "Hamilton %s (action=%#x) on %d channel(s): %s", action.name, action, - len(entries), + len(per_channel), + channel_summary, ) - 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=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]}" ) - 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}" + for idx in sorted(entry_errors) + ) + logger.error( + "Hamilton %s (action=%#x), instrument-wide error (%d entries): %s", + action.name, + action, + len(entries), + summary, ) - await self._reconnect() + 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 - assert last_error is not None - raise last_error + 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 async def resolve_path(self, path: str) -> Address: """Resolve dot-path to Address (delegates to introspection).""" From 3337d56fbf58a0cc7c7c5802b000ea0bc877a21e Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:01:25 -0700 Subject: [PATCH 06/15] refactor(hamilton.transport.tcp): replace attribute reflection with declared data wire_type_of() resolves an Annotated alias or bare WireType in one place, replacing four ad-hoc __metadata__ probes across messages.py and wire_types.py. TCPCommand now declares Response and uses_physical_channels as ClassVars. Response replaces a hasattr() dispatch; uses_physical_channels replaces duck-typing that inferred channel semantics by looking for a "channel" attribute on the first element of any list field. Device peers declare the flag; the transport no longer guesses. get_log_params reads dataclass fields instead of walking the __init__ signature and probing self. assert isinstance on wire-derived responses became real raises: asserts are stripped under python -O, which is exactly when a malformed frame most needs to fail loudly. --- pylabrobot/hamilton/transport/tcp/commands.py | 61 ++++++++----------- pylabrobot/hamilton/transport/tcp/messages.py | 28 ++++----- pylabrobot/hamilton/transport/tcp/tcp.py | 18 ++++-- .../hamilton/transport/tcp/tests/tcp_tests.py | 38 ++++++++++-- .../hamilton/transport/tcp/wire_types.py | 25 ++++++-- 5 files changed, 104 insertions(+), 66 deletions(-) diff --git a/pylabrobot/hamilton/transport/tcp/commands.py b/pylabrobot/hamilton/transport/tcp/commands.py index 953659120ff..0ff9ec1af43 100644 --- a/pylabrobot/hamilton/transport/tcp/commands.py +++ b/pylabrobot/hamilton/transport/tcp/commands.py @@ -7,9 +7,8 @@ from __future__ import annotations -import inspect from dataclasses import fields, is_dataclass -from typing import Any, Optional +from typing import Any, ClassVar, Optional from pylabrobot.hamilton.transport.tcp.messages import ( CommandMessage, @@ -48,14 +47,25 @@ class Response: """ # Class-level attributes that subclasses must override - protocol: Optional[HamiltonProtocol] = None - interface_id: Optional[int] = None - command_id: Optional[int] = None + protocol: ClassVar[Optional[HamiltonProtocol]] = None + interface_id: ClassVar[Optional[int]] = None + command_id: ClassVar[Optional[int]] = None + + # Nested dataclass describing the success payload, shadowed by subclasses that + # declare one. None means the command decodes its own response via + # parse_response_parameters(). + Response: ClassVar[Optional[type]] = None + + # Whether STATUS_EXCEPTION entries map onto PLR channel indices. Commands that + # carry per-channel wire parameters set this so the client raises + # ChannelizedError; everything else raises HoiError rather than attributing an + # instrument-wide fault to a synthetic ch0. + uses_physical_channels: ClassVar[bool] = False # Action configuration (can be overridden by subclasses) - action_code: int = 3 # Default: COMMAND_REQUEST - harp_protocol: int = 2 # Default: HOI2 - ip_protocol: int = 6 # Default: OBJECT_DISCOVERY + action_code: ClassVar[int] = 3 # Default: COMMAND_REQUEST + harp_protocol: ClassVar[int] = 2 # Default: HOI2 + ip_protocol: ClassVar[int] = 6 # Default: OBJECT_DISCOVERY def __init__(self, dest: Address): """Initialize TCP command. @@ -94,8 +104,8 @@ def build_parameters(self) -> HoiParams: def get_log_params(self) -> dict: """Get parameters to log for this command. - Lazily computes the parameters by inspecting the __init__ signature - and reading current attribute values from self. + Reads the declared dataclass fields. Non-dataclass subclasses declare no + fields and log nothing, matching ``build_parameters``. Subclasses can override to customize formatting (e.g., unit conversions, array truncation). @@ -103,13 +113,10 @@ def get_log_params(self) -> dict: Returns: Dictionary of parameter names to values """ - exclude = {"self", "dest"} - sig = inspect.signature(type(self).__init__) - params = {} - for param_name in sig.parameters: - if param_name not in exclude and hasattr(self, param_name): - params[param_name] = getattr(self, param_name) - return params + if not is_dataclass(self): + return {} + exclude = {"dest", "dest_address"} + return {f.name: getattr(self, f.name) for f in fields(self) if f.name not in exclude} def build( self, src: Optional[Address] = None, seq: Optional[int] = None, response_required: bool = True @@ -165,26 +172,6 @@ def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Op """ return entry_index - def error_entries_use_physical_channels(self) -> bool: - """Whether ``STATUS_EXCEPTION`` entries should be mapped to PLR channel indices. - - Returns ``True`` when the command carries per-channel wire parameters: - Prep ``StructArray`` elements with a ``channel`` field, or Nimbus - ``channels_involved`` parallel arrays. Void MLPrep / status queries return - ``False`` so the client raises :class:`~pylabrobot.hamilton.transport.tcp.hoi_error.HoiError` - instead of attributing errors to synthetic ``ch0``. - """ - if not is_dataclass(self): - return False - for f in fields(self): - if f.name == "channels_involved": - return True - value = getattr(self, f.name, None) - if isinstance(value, list) and value: - if getattr(value[0], "channel", None) is not None: - return True - return False - def interpret_response(self, response: CommandResponse) -> Any: """Pure decoder for a success response — never raises on channel errors. diff --git a/pylabrobot/hamilton/transport/tcp/messages.py b/pylabrobot/hamilton/transport/tcp/messages.py index 0cb4e69d294..41b64f98c00 100644 --- a/pylabrobot/hamilton/transport/tcp/messages.py +++ b/pylabrobot/hamilton/transport/tcp/messages.py @@ -102,9 +102,12 @@ def add(self, value: Any, wire_type: Any) -> "HoiParams": 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)) + from pylabrobot.hamilton.transport.tcp.wire_types import wire_type_of + + meta = wire_type_of(wire_type) + if meta is None: + raise TypeError(f"Not a wire type or Annotated wire-type alias: {wire_type!r}") + return cast("HoiParams", meta.encode_into(value, self)) # ------------------------------------------------------------------ # Ergonomic shims — each delegates to add() with the matching alias @@ -186,16 +189,13 @@ def from_struct(cls, obj) -> "HoiParams": from dataclasses import fields as dc_fields from typing import get_type_hints - from pylabrobot.hamilton.transport.tcp.wire_types import WireType + from pylabrobot.hamilton.transport.tcp.wire_types import wire_type_of 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): + meta = wire_type_of(hints.get(f.name)) + if meta is None: continue params = meta.encode_into(getattr(obj, f.name), params) return cast("HoiParams", params) @@ -427,7 +427,7 @@ def interpret_hoi_success_payload(command: Any, params_bytes: bytes) -> Any: if not params_bytes: return None - if hasattr(cls, "Response"): + if cls.Response is not None: return parse_into_struct(HoiParamsParser(params_bytes), cls.Response) return command.parse_response_parameters(params_bytes) @@ -455,17 +455,15 @@ def parse_into_struct(parser: HoiParamsParser, cls: type) -> Any: CountedFlatArray, Struct, StructArray, - WireType, + wire_type_of, ) 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): + meta = wire_type_of(ann) + if meta is None: continue if isinstance(meta, CountedFlatArray): diff --git a/pylabrobot/hamilton/transport/tcp/tcp.py b/pylabrobot/hamilton/transport/tcp/tcp.py index 135f09e247c..8ba5269a7a2 100644 --- a/pylabrobot/hamilton/transport/tcp/tcp.py +++ b/pylabrobot/hamilton/transport/tcp/tcp.py @@ -370,7 +370,10 @@ async def _discover_root(self): await self.write(packet) response = await self._read_one_message() - assert isinstance(response, RegistrationResponse) + if not isinstance(response, RegistrationResponse): + raise RuntimeError( + f"Expected a RegistrationResponse during root discovery, got {type(response).__name__}" + ) root_objects = self._parse_registration_response(response) if len(root_objects) != 1: @@ -406,7 +409,10 @@ async def _discover_globals(self) -> None: await self.write(packet) response = await self._read_one_message() - assert isinstance(response, RegistrationResponse) + if not isinstance(response, RegistrationResponse): + raise RuntimeError( + f"Expected a RegistrationResponse during global discovery, got {type(response).__name__}" + ) self._global_object_addresses = self._parse_registration_response(response) def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: @@ -524,7 +530,11 @@ async def _send_raw( while True: response_message = await self._read_one_message(timeout=read_timeout) - assert isinstance(response_message, CommandResponse) + if not isinstance(response_message, CommandResponse): + raise RuntimeError( + f"Expected a CommandResponse for {command.__class__.__name__}, " + f"got {type(response_message).__name__}" + ) action = Hoi2Action(response_message.hoi.action_code) if action is Hoi2Action.COMMAND_ACK: logger.debug( @@ -557,7 +567,7 @@ async def _send_raw( logger.debug(enriched_msg) return None - if command.error_entries_use_physical_channels(): + if command.uses_physical_channels: per_channel: Dict[int, Exception] = {} context_by_channel: Dict[int, Optional[str]] = {} hoi_exceptions: Dict[int, Exception] = {} diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index 0439cd74e5e..e9b167d081d 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -497,6 +497,13 @@ def test_parse_hamilton_error_entry_and_entries(self): class TestErrorEntryChannelDetection(unittest.TestCase): + """``uses_physical_channels`` is declared by the command, not inferred from its fields. + + Device peers (Nimbus/Prep) set it True so per-channel firmware errors surface as + ChannelizedError; everything else leaves it False so an instrument-wide fault is + not attributed to a synthetic ch0. + """ + @dataclass class _Ap: channel: int @@ -506,17 +513,18 @@ class _CmdPrep(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 1 command_id = 1 + uses_physical_channels = True dest: Address aspirate_parameters: list def __post_init__(self): super().__init__(self.dest) - def test_true_when_struct_array_has_channel(self): + def test_true_when_command_declares_physical_channels(self): c = TestErrorEntryChannelDetection._CmdPrep( Address(1, 1, 1), aspirate_parameters=[TestErrorEntryChannelDetection._Ap(0)] ) - self.assertTrue(c.error_entries_use_physical_channels()) + self.assertTrue(c.uses_physical_channels) @dataclass class _CmdVoid(TCPCommand): @@ -528,24 +536,41 @@ class _CmdVoid(TCPCommand): def __post_init__(self): super().__init__(self.dest) - def test_false_for_void_command(self): + def test_false_by_default_for_void_command(self): c = TestErrorEntryChannelDetection._CmdVoid(Address(1, 1, 1)) - self.assertFalse(c.error_entries_use_physical_channels()) + self.assertFalse(c.uses_physical_channels) @dataclass class _CmdNimbus(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 1 command_id = 4 + uses_physical_channels = True dest: Address channels_involved: tuple def __post_init__(self): super().__init__(self.dest) - def test_true_when_channels_involved_present(self): + def test_true_when_channels_involved_command_declares_it(self): c = TestErrorEntryChannelDetection._CmdNimbus(Address(1, 1, 1), (1, 0)) - self.assertTrue(c.error_entries_use_physical_channels()) + self.assertTrue(c.uses_physical_channels) + + def test_carrying_per_channel_fields_alone_does_not_enable_it(self): + """A command with channel-shaped fields that does not declare the flag stays False.""" + + @dataclass + class _CmdUndeclared(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 1 + dest: Address + channels_involved: tuple + + def __post_init__(self): + super().__init__(self.dest) + + self.assertFalse(_CmdUndeclared(Address(1, 1, 1), (1, 0)).uses_physical_channels) class TestSendCommandStatusException(unittest.IsolatedAsyncioTestCase): @@ -612,6 +637,7 @@ class CmdPick(TCPCommand): protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 1 command_id = 4 + uses_physical_channels = True dest: Address channels_involved: tuple diff --git a/pylabrobot/hamilton/transport/tcp/wire_types.py b/pylabrobot/hamilton/transport/tcp/wire_types.py index d60adde3eb7..56c6c660660 100644 --- a/pylabrobot/hamilton/transport/tcp/wire_types.py +++ b/pylabrobot/hamilton/transport/tcp/wire_types.py @@ -15,7 +15,7 @@ import struct as _struct from dataclasses import dataclass from enum import IntEnum -from typing import TYPE_CHECKING, Annotated, Any +from typing import TYPE_CHECKING, Annotated, Any, Optional, get_args, get_origin if TYPE_CHECKING: from pylabrobot.hamilton.transport.tcp.messages import HoiParams @@ -341,12 +341,29 @@ def decode_from(self, data: bytes) -> Any: # Type registry and decode_fragment # --------------------------------------------------------------------------- + +def wire_type_of(annotation: Any) -> Optional[WireType]: + """Return the :class:`WireType` carried by *annotation*, or None if it carries none. + + Accepts either an ``Annotated`` alias (``I32``, ``Str``, ``Annotated[Foo, Struct()]``) + or a bare ``WireType`` instance, so call sites can pass whichever they hold. Fields + annotated with plain types (e.g. ``Address``) have no wire representation and yield + None. + """ + if isinstance(annotation, WireType): + return annotation + if get_origin(annotation) is not Annotated: + return None + meta = get_args(annotation)[1] + return meta if isinstance(meta, WireType) else None + + _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}" +def _register(alias: Any) -> None: + meta = wire_type_of(alias) + assert meta is not None, f"Expected Annotated alias with WireType metadata: {alias}" _WIRE_TYPE_REGISTRY[meta.type_id] = meta From 1ff6cd08173e24c32e8442698aa55c8bedbf5763 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:04:01 -0700 Subject: [PATCH 07/15] feat(hamilton.transport.tcp): serialize commands on a transaction lock TCP-side concurrency semantics are not established for this protocol, so only one command is in flight at a time. _transact holds the lock across sequence-number allocation, build, write and the terminal read, then releases it before the response is decoded. That boundary is load-bearing: error enrichment resolves interface and method names through introspection, which sends further commands through _transact, so a lock spanning the whole of _send_raw would deadlock on the first firmware error. A test covers that path and fails with a timeout under the naive design. --- pylabrobot/hamilton/transport/tcp/tcp.py | 106 ++++++++++------ .../hamilton/transport/tcp/tests/tcp_tests.py | 116 ++++++++++++++++++ 2 files changed, 186 insertions(+), 36 deletions(-) diff --git a/pylabrobot/hamilton/transport/tcp/tcp.py b/pylabrobot/hamilton/transport/tcp/tcp.py index 8ba5269a7a2..836da176021 100644 --- a/pylabrobot/hamilton/transport/tcp/tcp.py +++ b/pylabrobot/hamilton/transport/tcp/tcp.py @@ -16,10 +16,20 @@ across a reconnect must never be assumed. Reconnecting silently would only hide when that happened. :attr:`HamiltonTCPClient.is_connected` is available for callers that want to implement their own policy. + +Concurrency +----------- +One command is in flight at a time. Concurrent callers are serialized on a single +lock spanning write-through-terminal-response, so commands never interleave on the +socket. The instrument's own concurrency semantics over TCP are not yet +established; until they are, this is deliberately conservative and there is no +per-object or read/write parallelism (contrast ``pylabrobot.hamilton.star.lock``, +where the module topology is known well enough to overlap commands). """ from __future__ import annotations +import asyncio import logging from typing import Any, Callable, ClassVar, Dict, Optional, Sequence, Tuple, Union, cast @@ -81,6 +91,11 @@ def __init__( self._connected = False self._connection_timeout = connection_timeout + # Serializes command exchanges on the socket. TCP-side concurrency semantics + # are not yet established for this protocol, so only one command is in flight + # at a time (cf. pylabrobot.hamilton.star.lock for the STAR equivalent). + self._command_lock = asyncio.Lock() + self._client_id: Optional[int] = None self.client_address: Optional[Address] = None self._sequence_numbers: Dict[Address, int] = {} @@ -501,6 +516,59 @@ async def send_discovery_command( read_timeout=read_timeout, ) + async def _transact( + self, command: TCPCommand, *, read_timeout: Optional[float] = None + ) -> CommandResponse: + """Exchange one request/response pair on the wire, serialized against all others. + + The lock spans sequence-number allocation, build, write, and the read of the + terminal response, so two commands can never interleave on the socket. It is + released before the caller decodes or enriches that response: enrichment + resolves interface and method names through :attr:`introspection`, which sends + further commands through this same method, and holding the lock across that + would deadlock on the first firmware error. + + Intermediate ``COMMAND_ACK`` and ``EVENT`` frames are skipped; the first + terminal frame is returned. + """ + async with self._command_lock: + 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) + if not isinstance(response_message, CommandResponse): + raise RuntimeError( + f"Expected a CommandResponse for {command.__class__.__name__}, " + f"got {type(response_message).__name__}" + ) + 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 + return response_message + async def _send_raw( self, command: TCPCommand, @@ -515,42 +583,8 @@ async def _send_raw( may or may not have been executed by the instrument, so re-sending it could run a motion twice; recovery is the caller's decision. """ - 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) - if not isinstance(response_message, CommandResponse): - raise RuntimeError( - f"Expected a CommandResponse for {command.__class__.__name__}, " - f"got {type(response_message).__name__}" - ) - 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 + response_message = await self._transact(command, read_timeout=read_timeout) + action = Hoi2Action(response_message.hoi.action_code) if action in ( Hoi2Action.STATUS_EXCEPTION, diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index e9b167d081d..5ad657b8cf1 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -9,6 +9,7 @@ from __future__ import annotations +import asyncio import struct import unittest from dataclasses import dataclass @@ -679,6 +680,121 @@ async def _read_one_message(self, timeout=None): # type: ignore[override] self.assertIn(0, ctx.exception.kwargs["hoi_exceptions"]) +class TestCommandSerialization(unittest.IsolatedAsyncioTestCase): + """One command in flight at a time, and enrichment must not deadlock against the lock.""" + + @staticmethod + def _ok_response(src: Address) -> CommandResponse: + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.COMMAND_RESPONSE, + action_id=0, + params=HoiParams().add(1, I32).build(), + ) + harp = HarpPacket( + src=src, 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()) + + @dataclass + class _Cmd(TCPCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 1 + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + async def test_concurrent_commands_do_not_interleave_on_the_wire(self): + events: list[str] = [] + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + events.append("write") + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + # Yield control so an unserialized second command could interleave here. + await asyncio.sleep(0) + events.append("read") + return TestCommandSerialization._ok_response(Address(1, 1, 257)) + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + + await asyncio.gather( + client.send_command(TestCommandSerialization._Cmd(Address(1, 1, 257))), + client.send_command(TestCommandSerialization._Cmd(Address(1, 1, 257))), + ) + + self.assertEqual(events, ["write", "read", "write", "read"]) + + async def test_error_enrichment_reentrancy_does_not_deadlock(self): + """Error enrichment sends introspection commands through the same lock. + + Holding the transaction lock across enrichment would deadlock here rather + than raising, so this asserts the lock is released before decoding. + """ + entry = HcResultEntry(1, 1, 257, 1, 6, 0x0F08) + err_params = ( + HoiParams() + .add( + 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}", + Str, + ) + .build() + ) + + class FakeClient(HamiltonTCPClient): + reads = 0 + + 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 + FakeClient.reads += 1 + # Only the first command fails; the introspection round-trips that + # enrichment issues afterwards succeed. + if FakeClient.reads > 1: + return TestCommandSerialization._ok_response(Address(1, 1, 257)) + 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) + + # Real introspection round-trips: each re-enters _transact and takes the lock. + async def _get_interface_name(addr, iface_id): + del addr, iface_id + await client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))) + return "Pipette" + + client.introspection.get_interface_name = _get_interface_name # type: ignore[method-assign] + client.introspection.get_hc_result_text = AsyncMock(return_value=None) # type: ignore[method-assign] + + with self.assertRaises(HoiError): + await asyncio.wait_for( + client.send_command(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=5 + ) + + class TestHcResultDescriptionNimbusTable(unittest.IsolatedAsyncioTestCase): """NIMBUS_ERROR_CODES keys use interface_id in the 4th slot; describe_entry must match that.""" From 4fe661307d86edf9037afd3e85bbdbb0baabc6e7 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:07:10 -0700 Subject: [PATCH 08/15] feat(hamilton.transport.tcp): read frames on a background task Reading used to happen inline inside a command, which meant events were only observed while a command was in flight, and any non-ACK non-EVENT frame was accepted as the current command's response regardless of origin. A response arriving late, after a timeout, silently became the next command's answer. The reader owns the socket from the end of setup() until stop(). It dispatches events continuously, skips ACKs, and hands the terminal frame to the waiting command. Frames arriving with nothing waiting are dropped and logged rather than queued. The handoff is a single slot, not a keyed map: the command lock already allows one command in flight, and whether the device echoes the request sequence number is unverified. Mismatches are logged, so the pairing can be confirmed from real traffic before a keyed demux is built on it. Reads stay inline during the init and registration handshake, which exchanges Registration frames rather than commands. A reader that dies on a live connection fails the waiting command instead of hanging it. --- pylabrobot/hamilton/transport/tcp/tcp.py | 164 +++++++++++++++--- .../hamilton/transport/tcp/tests/tcp_tests.py | 133 ++++++++++++++ 2 files changed, 272 insertions(+), 25 deletions(-) diff --git a/pylabrobot/hamilton/transport/tcp/tcp.py b/pylabrobot/hamilton/transport/tcp/tcp.py index 836da176021..dff62a67c53 100644 --- a/pylabrobot/hamilton/transport/tcp/tcp.py +++ b/pylabrobot/hamilton/transport/tcp/tcp.py @@ -90,12 +90,20 @@ def __init__( self._connected = False self._connection_timeout = connection_timeout + self._read_timeout = read_timeout # Serializes command exchanges on the socket. TCP-side concurrency semantics # are not yet established for this protocol, so only one command is in flight # at a time (cf. pylabrobot.hamilton.star.lock for the STAR equivalent). self._command_lock = asyncio.Lock() + # Background reader. Owns the socket from the end of setup() until stop(). + # Because the lock above allows one command in flight, the response handoff + # is a single slot rather than a map of pending requests. + self._reader_task: Optional[asyncio.Task] = None + self._pending_response: Optional[asyncio.Future] = None + self._pending_expect: Optional[Tuple[Address, int]] = None + self._client_id: Optional[int] = None self.client_address: Optional[Address] = None self._sequence_numbers: Dict[Address, int] = {} @@ -293,6 +301,68 @@ async def _read_one_message( logger.warning(f"Unknown IP protocol: {ip_protocol}, attempting CommandResponse parse") return CommandResponse.from_bytes(complete_data) + def _is_reader_running(self) -> bool: + return self._reader_task is not None and not self._reader_task.done() + + async def _reader_loop(self) -> None: + """Read frames continuously and route them to events or the waiting command. + + Owning the socket for the whole session is what makes events observable + between commands, and what stops a late response from being mistaken for the + next command's answer. + """ + try: + while True: + message = await self._read_one_message() + if not isinstance(message, CommandResponse): + logger.warning("Reader dropped an unexpected %s frame", type(message).__name__) + continue + action = Hoi2Action(message.hoi.action_code) + if action is Hoi2Action.EVENT: + # Already dispatched to subscribers by _read_one_message. + continue + if action is Hoi2Action.COMMAND_ACK: + logger.debug("COMMAND_ACK from %s; awaiting terminal response", message.harp.src) + continue + self._deliver_response(message) + except asyncio.CancelledError: + raise + except BaseException as exc: + # A live connection losing its reader would hang every later command, so + # hand the failure to whoever is waiting and make the cause visible. + self._fail_pending(exc) + if self._connected: + logger.exception("Hamilton TCP reader stopped unexpectedly: %s", exc) + + def _deliver_response(self, message: CommandResponse) -> None: + future = self._pending_response + if future is None or future.done(): + logger.warning( + "Dropping response from %s (action=%#x): no command is awaiting it", + message.harp.src, + message.hoi.action_code, + ) + return + + expected = self._pending_expect + if expected is not None and (message.harp.src, message.harp.seq) != expected: + # Whether the device echoes the request sequence number is not established, + # so this only reports the mismatch. Frequent warnings here mean the pairing + # is real and the single-slot handoff can become a keyed demux. + logger.warning( + "Response from %s seq=%d does not match outstanding command to %s seq=%d", + message.harp.src, + message.harp.seq, + expected[0], + expected[1], + ) + future.set_result(message) + + def _fail_pending(self, exc: BaseException) -> None: + future = self._pending_response + if future is not None and not future.done(): + future.set_exception(exc) + async def setup(self): if self._connected: raise RuntimeError( @@ -306,6 +376,11 @@ async def setup(self): await self._discover_root() await self._discover_globals() + # The handshake above reads inline because it exchanges Init and Registration + # frames rather than commands. Everything past this point is HOI command + # traffic, so the reader takes over the socket here. + self._reader_task = asyncio.create_task(self._reader_loop()) + root_addr = self._registry.get_root_address() if root_addr is not None: root_info = await self.introspection.get_object(root_addr) @@ -543,31 +618,55 @@ async def _transact( log_params = command.get_log_params() logger.debug(f"{command.__class__.__name__} parameters: {log_params}") - await self.write(message) + if self._reader_task is not None and self._reader_task.done(): + raise ConnectionError( + f"{self.io._unique_id} reader is not running - call stop() then setup()" + ) - while True: - response_message = await self._read_one_message(timeout=read_timeout) - if not isinstance(response_message, CommandResponse): - raise RuntimeError( - f"Expected a CommandResponse for {command.__class__.__name__}, " - f"got {type(response_message).__name__}" - ) - 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 - return response_message + if not self._is_reader_running(): + # Pre-setup: the reader does not own the socket yet, so read inline. + await self.write(message) + return await self._read_terminal_frame(command, read_timeout=read_timeout) + + loop = asyncio.get_running_loop() + future: asyncio.Future = loop.create_future() + self._pending_response = future + self._pending_expect = (command.dest_address, command.sequence_number) + try: + await self.write(message) + timeout = self._read_timeout if read_timeout is None else read_timeout + return cast(CommandResponse, await asyncio.wait_for(future, timeout=timeout)) + finally: + self._pending_response = None + self._pending_expect = None + + async def _read_terminal_frame( + self, command: TCPCommand, *, read_timeout: Optional[float] = None + ) -> CommandResponse: + """Read inline until the first terminal frame. Used before the reader starts.""" + while True: + response_message = await self._read_one_message(timeout=read_timeout) + if not isinstance(response_message, CommandResponse): + raise RuntimeError( + f"Expected a CommandResponse for {command.__class__.__name__}, " + f"got {type(response_message).__name__}" + ) + 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 + return response_message async def _send_raw( self, @@ -725,11 +824,26 @@ async def resolve_target( return await self.resolve_path(resolved) async def stop(self): + # Mark disconnected first so the reader reports its own cancellation as an + # ordinary shutdown rather than an unexpected failure. + self._connected = False + + reader_task = self._reader_task + self._reader_task = None + if reader_task is not None and not reader_task.done(): + reader_task.cancel() + try: + await reader_task + except asyncio.CancelledError: + pass + except Exception as e: + logger.warning(f"Error while stopping reader: {e}") + self._fail_pending(ConnectionError(f"{self.io._unique_id} stopped - call setup()")) + try: await self.io.stop() except Exception as e: logger.warning(f"Error during stop: {e}") finally: - self._connected = False self._invalidate_introspection_session() logger.info("Hamilton TCP client stopped") diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index 5ad657b8cf1..cf81531008a 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -795,6 +795,139 @@ async def _get_interface_name(addr, iface_id): ) +class TestBackgroundReader(unittest.IsolatedAsyncioTestCase): + """The reader owns the socket for the session and routes frames by kind.""" + + @staticmethod + def _frame(action_code: int, src: Address, seq: int = 1) -> CommandResponse: + hoi = HoiPacket( + interface_id=1, + action_code=action_code, + action_id=0, + params=HoiParams().add(1, I32).build(), + ) + harp = HarpPacket( + src=src, dst=Address(2, 1, 65535), seq=seq, protocol=2, action_code=4, payload=hoi.pack() + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) + + def _make_client(self, frames: list): + """Client whose reader consumes *frames*, then blocks as an idle socket would.""" + queue: asyncio.Queue = asyncio.Queue() + for f in frames: + queue.put_nowait(f) + + class FakeClient(HamiltonTCPClient): + writes = 0 + + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del data, timeout + FakeClient.writes += 1 + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + message = await queue.get() + if isinstance(message, BaseException): + raise message + if message.hoi.action_code == Hoi2Action.EVENT: + self._dispatch_event(message) + return message + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + client._connected = True + client._reader_task = asyncio.create_task(client._reader_loop()) + self.addAsyncCleanup(client.stop) + return client, queue + + async def test_events_arriving_between_commands_reach_subscribers(self): + seen: list = [] + client, queue = self._make_client([self._frame(Hoi2Action.EVENT, Address(1, 1, 257))]) + client.on_event(seen.append) + + # No command is in flight; the reader still has to observe the event. + del queue + for _ in range(10): + if seen: + break + await asyncio.sleep(0) + + self.assertEqual(len(seen), 1) + self.assertEqual(seen[0].hoi.action_code, Hoi2Action.EVENT) + + async def test_ack_is_skipped_and_terminal_frame_is_delivered(self): + client, _ = self._make_client( + [ + self._frame(Hoi2Action.COMMAND_ACK, Address(1, 1, 257)), + self._frame(Hoi2Action.COMMAND_RESPONSE, Address(1, 1, 257)), + ] + ) + result = await asyncio.wait_for( + client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=5 + ) + self.assertIsNotNone(result) + + async def test_unmatched_late_frame_is_not_given_to_the_next_command(self): + """A response arriving with no command waiting must be dropped, not queued.""" + late = self._frame(Hoi2Action.COMMAND_RESPONSE, Address(1, 1, 999), seq=99) + client, queue = self._make_client([late]) + + # Let the reader consume the stale frame while nothing is awaiting it. + for _ in range(10): + if queue.empty(): + break + await asyncio.sleep(0) + + # The next command must wait for its own response, not inherit the stale one. + with self.assertRaises(asyncio.TimeoutError): + await asyncio.wait_for( + client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=0.25 + ) + + queue.put_nowait(self._frame(Hoi2Action.COMMAND_RESPONSE, Address(1, 1, 257))) + self.assertIsNotNone( + await asyncio.wait_for( + client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=5 + ) + ) + + async def test_reader_failure_fails_the_waiting_command(self): + """A dead reader must surface on the waiting command instead of hanging it.""" + + class Boom(Exception): + pass + + client, queue = self._make_client([]) + task = asyncio.ensure_future( + client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))) + ) + for _ in range(10): + if client._pending_response is not None: + break + await asyncio.sleep(0) + + queue.put_nowait(Boom()) # _read_one_message raises when the reader dequeues this + + with self.assertRaises(Boom): + await asyncio.wait_for(task, timeout=5) + + async def test_command_after_reader_death_reports_the_connection(self): + class Boom(Exception): + pass + + client, queue = self._make_client([]) + queue.put_nowait(Boom()) + for _ in range(10): + if client._reader_task is not None and client._reader_task.done(): + break + await asyncio.sleep(0) + + with self.assertRaises(ConnectionError): + await asyncio.wait_for( + client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=5 + ) + + class TestHcResultDescriptionNimbusTable(unittest.IsolatedAsyncioTestCase): """NIMBUS_ERROR_CODES keys use interface_id in the 4th slot; describe_entry must match that.""" From 19b49ae5ee96afcfa68aa03c260c712dbf9255bd Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:08:42 -0700 Subject: [PATCH 09/15] test(hamilton.transport.tcp): cover connection lifecycle and reader routing The transport had no coverage of connection lifecycle, retry, event dispatch or concurrency. Adds the regressions for the behaviour the preceding commits established: a command is written exactly once when the read fails, I/O on a disconnected client names setup(), setup() refuses to run twice, session state and sequence numbers reset between sessions, commands do not interleave, error enrichment does not deadlock against the command lock, events arrive between commands, unmatched frames are dropped rather than misdelivered, and a dead reader fails the waiting command. --- CHANGELOG.md | 11 +++ .../hamilton/transport/tcp/tests/tcp_tests.py | 95 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 870ab59605e..d5856cd38c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `Plate`: optional `stacking_z_height` parameter -- the per-plate vertical pitch when plates are stacked directly on top of each other (`size_z` minus the nesting overlap), mirroring `NestedTipRack.stacking_z_height`. Because it is a physical dimension, plates that differ in it no longer compare equal; `Plate` also now serializes `stacking_z_height` and the pre-existing `plate_type` so both round-trip through `deserialize`/`copy`. (#1110) - `ResourceStack`: bare plates stacked in the z direction now nest into one another by their `stacking_z_height` (a stack of `N` identical plates is `size_z + (N - 1) * stacking_z_height` tall, for both `get_size_z()` and child placement). Plates without a `stacking_z_height`, and plates wearing a lid, do not nest, so existing behaviour is unchanged. (#1112) +- Background reader task on `pylabrobot.hamilton.transport.tcp.HamiltonTCPClient` that owns the socket for the session, so `on_event` subscribers receive events between commands and a response arriving with no command waiting is dropped and logged instead of being handed to the next command (#1195). +- Command serialization on `HamiltonTCPClient`: one command is in flight at a time. The lock spans write through terminal response and is released before the response is decoded, because error enrichment sends further commands through the same path (#1195). +- `ObjectRegistry.clear()` (`pylabrobot.hamilton.transport.tcp.introspection`), used to drop path and address mappings that are scoped to a single connected session (#1195). + ### Fixed - Imported `unittest.mock` in `pylabrobot/centrifuge/centrifuge_tests.py` (pre-existing bug that prevented the test class from running). +- `HamiltonTCPClient` no longer retransmits a command after a failed read. A read timeout on a slow motion command previously re-sent it, which could execute the motion twice (#1195). +- `HamiltonTCPClient.setup()` now resets all per-session state (client id, sequence numbers, instrument addresses, object registry) rather than carrying it into the new session, and refuses to run on an already-connected client instead of leaking the socket (#1195). + +### Changed + +- `HamiltonTCPClient` no longer reconnects automatically; `auto_reconnect` and `max_reconnect_attempts` are gone from its constructor. Recovery is `await client.stop()` followed by `await client.setup()`, matching every other transport in the library. `is_connected` remains for callers implementing their own policy (#1195). +- `TCPCommand` declares `Response` and `uses_physical_channels` as class attributes instead of the transport inferring them by attribute probing. Commands with per-channel firmware errors must set `uses_physical_channels = True` to raise `ChannelizedError` (#1195). ## 0.2.1 diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index cf81531008a..4ecdca8e2b3 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -795,6 +795,101 @@ async def _get_interface_name(addr, iface_id): ) +class TestConnectionLifecycle(unittest.IsolatedAsyncioTestCase): + """No command is ever transmitted twice, and a session leaves no state behind.""" + + async def test_read_failure_does_not_retransmit_the_command(self): + """The regression this transport exists to avoid: a timed-out read re-running a motion. + + The write may already have reached the instrument, so the command must be + written exactly once and the failure surfaced to the caller. + """ + writes: list[bytes] = [] + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del timeout + writes.append(data) + + async def _read_one_message(self, timeout=None): # type: ignore[override] + del timeout + raise TimeoutError("device did not answer") + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + + with self.assertRaises(TimeoutError): + await client.send_command(TestCommandSerialization._Cmd(Address(1, 1, 257))) + + self.assertEqual(len(writes), 1) + + async def test_connection_errors_do_not_retransmit_either(self): + for exc in (ConnectionResetError("reset"), BrokenPipeError("pipe"), OSError("io")): + with self.subTest(exc=type(exc).__name__): + writes: list[bytes] = [] + + class FakeClient(HamiltonTCPClient): + async def write(self, data: bytes, timeout=None): # type: ignore[override] + del timeout + writes.append(data) + + async def _read_one_message(self, timeout=None, _exc=exc): # type: ignore[override] + del timeout + raise _exc + + client = FakeClient(host="127.0.0.1", port=0) + client.client_address = Address(2, 1, 65535) + + with self.assertRaises(type(exc)): + await client.send_command(TestCommandSerialization._Cmd(Address(1, 1, 257))) + self.assertEqual(len(writes), 1) + + async def test_io_on_a_disconnected_client_names_setup(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + self.assertFalse(client.is_connected) + for op in (client.write(b"x"), client.read(1), client.read_exact(1)): + with self.assertRaises(ConnectionError) as ctx: + await op + self.assertIn("setup()", str(ctx.exception)) + + async def test_setup_twice_without_stop_is_refused(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + client._connected = True + with self.assertRaises(RuntimeError) as ctx: + await client.setup() + self.assertIn("stop()", str(ctx.exception)) + + def test_clearing_session_state_drops_everything_scoped_to_the_session(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + client._client_id = 5 + client.client_address = Address(2, 5, 65535) + client._sequence_numbers[Address(1, 1, 257)] = 42 + client._instrument_addresses["arm"] = Address(1, 1, 257) + client._global_object_addresses = [Address(1, 1, 1)] + client.registry.set_root_address(Address(1, 1, 1)) + client.registry.register( + "root", ObjectInfo("root", "", method_count=0, subobject_count=0, address=Address(1, 1, 1)) + ) + + client._clear_session_state_for_setup() + + self.assertIsNone(client._client_id) + self.assertIsNone(client.client_address) + self.assertEqual(client._sequence_numbers, {}) + self.assertEqual(client._instrument_addresses, {}) + self.assertEqual(list(client.global_object_addresses), []) + self.assertIsNone(client.registry.get_root_address()) + self.assertIsNone(client.registry.address_for("root")) + self.assertIsNone(client.registry.path(Address(1, 1, 1))) + + def test_sequence_numbers_restart_after_a_session_ends(self): + client = HamiltonTCPClient(host="127.0.0.1", port=0) + dest = Address(1, 1, 257) + first = [client._allocate_sequence_number(dest) for _ in range(3)] + client._clear_session_state_for_setup() + self.assertEqual(client._allocate_sequence_number(dest), first[0]) + + class TestBackgroundReader(unittest.IsolatedAsyncioTestCase): """The reader owns the socket for the session and routes frames by kind.""" From e463803df8f329201e4433ce4375effcbb3aa882 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:22:02 -0700 Subject: [PATCH 10/15] fix(hamilton.transport.tcp): keep the reader alive on non-command frames Found against MLPrep firmware. Shortly after registration the device sends a HARP protocol-2 frame carrying options and no HOI body. _read_one_message routed every protocol-2 frame to CommandResponse, which unpacks a HOI header that is not there, so the reader died and every later command failed with "reader is not running". Inline reading never hit this because nothing read the socket between commands. _read_one_message now returns None for a frame with no routable message, and the reader skips unparseable frames instead of terminating. Frames are length-prefixed and consumed whole, so skipping one cannot desynchronize the stream. Also records what the same session established: the device echoes the request address and sequence number on every response (26/26), so the mismatch warning now documents a real invariant rather than an open question. --- pylabrobot/hamilton/transport/tcp/tcp.py | 45 ++++++++++++++++--- .../hamilton/transport/tcp/tests/tcp_tests.py | 35 +++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/pylabrobot/hamilton/transport/tcp/tcp.py b/pylabrobot/hamilton/transport/tcp/tcp.py index dff62a67c53..7d90555d49b 100644 --- a/pylabrobot/hamilton/transport/tcp/tcp.py +++ b/pylabrobot/hamilton/transport/tcp/tcp.py @@ -52,7 +52,7 @@ RegistrationMessage, RegistrationResponse, ) -from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.packets import Address, HarpPacket, IpPacket from pylabrobot.hamilton.transport.tcp.protocol import ( Hoi2Action, HoiRequestId, @@ -99,7 +99,9 @@ def __init__( # Background reader. Owns the socket from the end of setup() until stop(). # Because the lock above allows one command in flight, the response handoff - # is a single slot rather than a map of pending requests. + # is a single slot rather than a map of pending requests. Responses do carry + # the request's address and sequence number, so this can become a keyed demux + # if commands are ever allowed to overlap. self._reader_task: Optional[asyncio.Task] = None self._pending_response: Optional[asyncio.Future] = None self._pending_expect: Optional[Tuple[Address, int]] = None @@ -273,7 +275,12 @@ def is_connected(self) -> bool: async def _read_one_message( self, timeout: Optional[float] = None - ) -> Union[RegistrationResponse, CommandResponse]: + ) -> Optional[Union[RegistrationResponse, CommandResponse]]: + """Read one length-prefixed frame and route it by protocol. + + Returns ``None`` for frames that carry no routable message. Each frame is + consumed in full regardless, so skipping one never desynchronizes the stream. + """ size_data = await self.read_exact(2, timeout=timeout) packet_size = Reader(size_data).u16() @@ -289,6 +296,17 @@ async def _read_one_message( harp_protocol = complete_data[harp_protocol_offset] if harp_protocol == 2: + harp = HarpPacket.unpack(IpPacket.unpack(complete_data).payload) + if not harp.payload: + # HARP-level control frame: options, no HOI body. The device sends one + # to the client shortly after registration. Nothing to route. + logger.debug( + "Ignoring HARP control frame from %s (action=%d, %d option bytes)", + harp.src, + harp.action_code, + len(harp.options), + ) + return None resp = CommandResponse.from_bytes(complete_data) if resp.hoi.action_code == Hoi2Action.EVENT and self._event_handlers: self._dispatch_event(resp) @@ -313,7 +331,16 @@ async def _reader_loop(self) -> None: """ try: while True: - message = await self._read_one_message() + try: + message = await self._read_one_message() + except ValueError as exc: + # A malformed frame must not take the reader down with it. Frames are + # length-prefixed and consumed whole, so skipping one keeps the stream + # in sync. + logger.warning("Skipping unparseable frame: %s", exc) + continue + if message is None: + continue if not isinstance(message, CommandResponse): logger.warning("Reader dropped an unexpected %s frame", type(message).__name__) continue @@ -346,9 +373,11 @@ def _deliver_response(self, message: CommandResponse) -> None: expected = self._pending_expect if expected is not None and (message.harp.src, message.harp.seq) != expected: - # Whether the device echoes the request sequence number is not established, - # so this only reports the mismatch. Frequent warnings here mean the pairing - # is real and the single-slot handoff can become a keyed demux. + # MLPrep firmware echoes the request address and sequence number on every + # response (26/26 over a live introspection session), so a mismatch here is + # unexpected. It is reported rather than rejected because that evidence + # covers one device family; rejecting outright would strand a command on + # firmware that pairs responses differently. logger.warning( "Response from %s seq=%d does not match outstanding command to %s seq=%d", message.harp.src, @@ -646,6 +675,8 @@ async def _read_terminal_frame( """Read inline until the first terminal frame. Used before the reader starts.""" while True: response_message = await self._read_one_message(timeout=read_timeout) + if response_message is None: + continue if not isinstance(response_message, CommandResponse): raise RuntimeError( f"Expected a CommandResponse for {command.__class__.__name__}, " diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index 4ecdca8e2b3..72c258bd4e0 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -986,6 +986,41 @@ async def test_unmatched_late_frame_is_not_given_to_the_next_command(self): ) ) + async def test_harp_control_frame_without_hoi_body_is_skipped(self): + """The device sends a HARP control frame (options, no HOI body) after registration. + + Captured from an MLPrep at 127.0.0.1:2000. It has no HOI header at all, so + parsing it as a command response raises; the reader must skip it. + """ + control = bytes.fromhex( + "24000630000000000000000002000600ffff0100020020000a00010801000100ffff04020000" + ) + harp = HarpPacket.unpack(IpPacket.unpack(control).payload) + self.assertEqual(harp.protocol, 2) + self.assertEqual(harp.payload, b"") + self.assertEqual(len(harp.options), 10) + + class FakeClient(HamiltonTCPClient): + async def read_exact(self, num_bytes: int, timeout=None): # type: ignore[override] + del timeout + return control[:num_bytes] if num_bytes == 2 else control[2 : 2 + num_bytes] + + client = FakeClient(host="127.0.0.1", port=0) + client._connected = True + self.assertIsNone(await client._read_one_message()) + + async def test_reader_survives_an_unparseable_frame(self): + """A malformed frame is skipped; the next command still gets its response.""" + client, queue = self._make_client([]) + queue.put_nowait(ValueError("Not enough data for u8 at offset 0")) + queue.put_nowait(self._frame(Hoi2Action.COMMAND_RESPONSE, Address(1, 1, 257))) + + result = await asyncio.wait_for( + client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=5 + ) + self.assertIsNotNone(result) + self.assertTrue(client._is_reader_running()) + async def test_reader_failure_fails_the_waiting_command(self): """A dead reader must surface on the waiting command instead of hanging it.""" From 3fae48879d8589bae25ea17d7ec771ef935c43ab Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:22:20 -0700 Subject: [PATCH 11/15] docs(changelog): note the HARP control-frame fix (#1195) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5856cd38c0..bd6dadfac70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Imported `unittest.mock` in `pylabrobot/centrifuge/centrifuge_tests.py` (pre-existing bug that prevented the test class from running). - `HamiltonTCPClient` no longer retransmits a command after a failed read. A read timeout on a slow motion command previously re-sent it, which could execute the motion twice (#1195). - `HamiltonTCPClient.setup()` now resets all per-session state (client id, sequence numbers, instrument addresses, object registry) rather than carrying it into the new session, and refuses to run on an already-connected client instead of leaking the socket (#1195). +- `HamiltonTCPClient` no longer fails every command after the device sends a HARP control frame. MLPrep firmware sends one (options, no HOI body) shortly after registration; it was parsed as a command response and killed the reader. Frames with no routable message are skipped, as are unparseable frames, which is safe because frames are length-prefixed and consumed whole (#1195). ### Changed From c7ed8eb78fc00ae5a85dafd51da9a5cd78ae44c4 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:26:51 -0700 Subject: [PATCH 12/15] fix(hamilton.transport.tcp): stop error enrichment from recursing Turning a firmware error into a readable message asks the device for interface and method names. When those queries fail too, the failure was enriched the same way, which enriched again: a device answering STATUS_EXCEPTION to every request produced a RecursionError after 55 reads rather than an HoiError. Pre-dates this branch; reproduced identically at 4e71a71e0 and confirmed to need a device that fails Interface-0 queries, which is why healthy hardware never showed it. It surfaces exactly when the error message matters most. Enrichment is now non-re-entrant, guarded by a ContextVar so concurrent callers keep their own state. A nested entry falls back to HC_RESULT_PROTOCOL and terse addressing, so the caller still gets a real HoiError naming the failing address, interface and action. --- CHANGELOG.md | 1 + pylabrobot/hamilton/transport/tcp/tcp.py | 36 +++++++++ .../hamilton/transport/tcp/tests/tcp_tests.py | 78 +++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6dadfac70..af65f9abf6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Imported `unittest.mock` in `pylabrobot/centrifuge/centrifuge_tests.py` (pre-existing bug that prevented the test class from running). - `HamiltonTCPClient` no longer retransmits a command after a failed read. A read timeout on a slow motion command previously re-sent it, which could execute the motion twice (#1195). - `HamiltonTCPClient.setup()` now resets all per-session state (client id, sequence numbers, instrument addresses, object registry) rather than carrying it into the new session, and refuses to run on an already-connected client instead of leaking the socket (#1195). +- `HamiltonTCPClient` no longer recurses without bound when a device fails the introspection queries that error enrichment itself issues. Enrichment is now non-re-entrant and falls back to the static HC_RESULT tables, so a degraded instrument yields a terse error instead of a `RecursionError` (#1195). - `HamiltonTCPClient` no longer fails every command after the device sends a HARP control frame. MLPrep firmware sends one (options, no HOI body) shortly after registration; it was parsed as a command response and killed the reader. Frames with no routable message are skipped, as are unparseable frames, which is safe because frames are length-prefixed and consumed whole (#1195). ### Changed diff --git a/pylabrobot/hamilton/transport/tcp/tcp.py b/pylabrobot/hamilton/transport/tcp/tcp.py index 7d90555d49b..2a27c2581f1 100644 --- a/pylabrobot/hamilton/transport/tcp/tcp.py +++ b/pylabrobot/hamilton/transport/tcp/tcp.py @@ -31,6 +31,7 @@ import asyncio import logging +from contextvars import ContextVar from typing import Any, Callable, ClassVar, Dict, Optional, Sequence, Tuple, Union, cast from pylabrobot.hamilton.transport.tcp.commands import TCPCommand, hamilton_error_for_entry @@ -66,6 +67,13 @@ logger = logging.getLogger(__name__) +# Set while an error is being turned into a readable message. Enrichment asks the +# device for interface and method names, and those queries can themselves fail; without +# this guard each failure would enrich again, recursing until the interpreter gives up. +# A ContextVar rather than an attribute so concurrent callers cannot see each other's +# state: a task inherits a copy of the context and its changes stay local to it. +_enriching: ContextVar[bool] = ContextVar("hamilton_tcp_enriching", default=False) + class HamiltonTCPClient: """Standalone transport + discovery/introspection client for Hamilton TCP devices.""" @@ -145,8 +153,26 @@ def introspection(self) -> HamiltonIntrospection: def _invalidate_introspection_session(self) -> None: self._introspection_impl = None + @staticmethod + def _offline_description(entry: HcResultEntry) -> str: + """Describe an entry without asking the device anything.""" + return HC_RESULT_PROTOCOL.get(entry.result) or f"HC_RESULT=0x{entry.result:04X}" + async def _describe_entry(self, entry: HcResultEntry) -> Tuple[Optional[str], str]: """Resolve an HcResultEntry to (interface_name, description) for error reporting.""" + if _enriching.get(): + # Already describing an error: this entry belongs to a query that enrichment + # itself issued. Fall back to the static tables so a device that fails its + # introspection queries degrades to terse text instead of recursing. + return None, self._offline_description(entry) + + token = _enriching.set(True) + try: + return await self._describe_entry_impl(entry) + finally: + _enriching.reset(token) + + async def _describe_entry_impl(self, entry: HcResultEntry) -> Tuple[Optional[str], str]: 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 @@ -168,6 +194,16 @@ async def _describe_entry(self, entry: HcResultEntry) -> Tuple[Optional[str], st 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) + if _enriching.get(): + return f"addr={addr}, iface={entry.interface_id}, action={entry.action_id}" + + token = _enriching.set(True) + try: + return await self._format_entry_context_impl(entry, addr) + finally: + _enriching.reset(token) + + async def _format_entry_context_impl(self, entry: HcResultEntry, addr: Address) -> Optional[str]: 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) diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index 72c258bd4e0..20b339f6dab 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -890,6 +890,84 @@ def test_sequence_numbers_restart_after_a_session_ends(self): self.assertEqual(client._allocate_sequence_number(dest), first[0]) +class TestErrorEnrichmentRecursion(unittest.IsolatedAsyncioTestCase): + """A device that fails its own introspection queries must not recurse.""" + + async def test_device_failing_every_request_still_raises_once(self): + """Every read returns STATUS_EXCEPTION, including enrichment's own queries. + + Enrichment asks the device for interface and method names; those queries hit + the same failure, which would enrich again. Without the re-entrancy guard this + recurses until the interpreter aborts (55 reads before RecursionError). + """ + entry = HcResultEntry(1, 1, 257, 1, 6, 0x0F08) + err_params = ( + HoiParams() + .add( + 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}", + Str, + ) + .build() + ) + reads = 0 + + 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 + nonlocal reads + reads += 1 + 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) + + # Real introspection, deliberately not stubbed: enrichment issues real queries. + with self.assertRaises(HoiError) as ctx: + await asyncio.wait_for( + client.send_command(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=10 + ) + + self.assertEqual(ctx.exception.entries[0].result, 0x0F08) + # Bounded work: the failing command plus enrichment's first query, no cascade. + self.assertLessEqual(reads, 5) + + async def test_guard_is_released_so_later_errors_still_enrich(self): + """The re-entrancy guard must not leak across commands.""" + from pylabrobot.hamilton.transport.tcp import tcp as tcp_module + + self.assertFalse(tcp_module._enriching.get()) + client = HamiltonTCPClient(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.assertEqual(iface, "Pipette") + self.assertFalse(tcp_module._enriching.get()) + + # Second call still enriches rather than falling back to terse text. + iface2, _desc2 = await client._describe_entry(entry) + self.assertEqual(iface2, "Pipette") + + class TestBackgroundReader(unittest.IsolatedAsyncioTestCase): """The reader owns the socket for the session and routes frames by kind.""" From 12fe4f4e8eddc34898327f846662b5a6c6a9effe Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:45:12 -0700 Subject: [PATCH 13/15] fix(hamilton.transport.tcp): keep command identity attributes overridable Declaring protocol, interface_id, command_id and the action configuration as ClassVar forbids a subclass from redeclaring them as per-instance dataclass fields. Prep's PrepStatusRequest does exactly that by design, carrying command_id and interface_id on the instance so one class can serve many firmware methods. The ClassVar conversion was incidental to removing attribute probing and was not needed for it; Response and uses_physical_channels remain ClassVar because nothing overrides them per instance. --- pylabrobot/hamilton/transport/tcp/commands.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pylabrobot/hamilton/transport/tcp/commands.py b/pylabrobot/hamilton/transport/tcp/commands.py index 0ff9ec1af43..0e6c30c767c 100644 --- a/pylabrobot/hamilton/transport/tcp/commands.py +++ b/pylabrobot/hamilton/transport/tcp/commands.py @@ -47,9 +47,12 @@ class Response: """ # Class-level attributes that subclasses must override - protocol: ClassVar[Optional[HamiltonProtocol]] = None - interface_id: ClassVar[Optional[int]] = None - command_id: ClassVar[Optional[int]] = None + # Not ClassVar: subclasses may redeclare these as per-instance dataclass fields + # when the value varies per command instance rather than per type (see + # PrepStatusRequest, which carries command_id on the instance). + protocol: Optional[HamiltonProtocol] = None + interface_id: Optional[int] = None + command_id: Optional[int] = None # Nested dataclass describing the success payload, shadowed by subclasses that # declare one. None means the command decodes its own response via @@ -62,10 +65,10 @@ class Response: # instrument-wide fault to a synthetic ch0. uses_physical_channels: ClassVar[bool] = False - # Action configuration (can be overridden by subclasses) - action_code: ClassVar[int] = 3 # Default: COMMAND_REQUEST - harp_protocol: ClassVar[int] = 2 # Default: HOI2 - ip_protocol: ClassVar[int] = 6 # Default: OBJECT_DISCOVERY + # Action configuration (can be overridden by subclasses, per type or per instance) + action_code: int = 3 # Default: COMMAND_REQUEST + harp_protocol: int = 2 # Default: HOI2 + ip_protocol: int = 6 # Default: OBJECT_DISCOVERY def __init__(self, dest: Address): """Initialize TCP command. From e64163ae063caac398986fd55be5c403b69c50fb Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:56:13 -0700 Subject: [PATCH 14/15] test(hamilton.transport.tcp): feed reader frames only once a command waits Three reader tests queued response frames before sending the command. The reader could drain them first and correctly drop them as unmatched, leaving the command waiting until its timeout. Whether that happened depended on task scheduling: they passed on 3.14 and failed on 3.11, so CI would have caught them intermittently at best. Frames are now fed after the command registers its pending response, with an explicit wait for that registration. Also asserts the stale-frame test really did consume the frame it is about to prove was dropped, rather than passing because the reader had not run yet. Sorts imports after the error-table move, which CI checks via ruff check --select I in make format-check. --- .../hamilton/transport/tcp/tests/tcp_tests.py | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index 9a51344f6ec..7b43b935521 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -17,8 +17,8 @@ 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.nimbus.error_tables import NIMBUS_ERROR_CODES +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand from pylabrobot.hamilton.transport.tcp.hoi_error import ( HoiError, parse_hamilton_error_entries, @@ -1013,6 +1013,21 @@ async def _read_one_message(self, timeout=None): # type: ignore[override] self.addAsyncCleanup(client.stop) return client, queue + @staticmethod + async def _await_registered(client) -> None: + """Block until a command has registered its pending response. + + Frames must only be fed once the command is waiting. Queueing them earlier + is a race: the reader may drain them first and correctly drop them as + unmatched, after which the command waits forever. Whether that happened + varied by Python version, so it is made explicit here. + """ + for _ in range(1000): + if client._pending_response is not None: + return + await asyncio.sleep(0) + raise AssertionError("command never registered a pending response") + async def test_events_arriving_between_commands_reach_subscribers(self): seen: list = [] client, queue = self._make_client([self._frame(Hoi2Action.EVENT, Address(1, 1, 257))]) @@ -1029,16 +1044,16 @@ async def test_events_arriving_between_commands_reach_subscribers(self): self.assertEqual(seen[0].hoi.action_code, Hoi2Action.EVENT) async def test_ack_is_skipped_and_terminal_frame_is_delivered(self): - client, _ = self._make_client( - [ - self._frame(Hoi2Action.COMMAND_ACK, Address(1, 1, 257)), - self._frame(Hoi2Action.COMMAND_RESPONSE, Address(1, 1, 257)), - ] - ) - result = await asyncio.wait_for( - client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=5 + client, queue = self._make_client([]) + task = asyncio.ensure_future( + client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))) ) - self.assertIsNotNone(result) + await self._await_registered(client) + + queue.put_nowait(self._frame(Hoi2Action.COMMAND_ACK, Address(1, 1, 257))) + queue.put_nowait(self._frame(Hoi2Action.COMMAND_RESPONSE, Address(1, 1, 257))) + + self.assertIsNotNone(await asyncio.wait_for(task, timeout=5)) async def test_unmatched_late_frame_is_not_given_to_the_next_command(self): """A response arriving with no command waiting must be dropped, not queued.""" @@ -1046,10 +1061,11 @@ async def test_unmatched_late_frame_is_not_given_to_the_next_command(self): client, queue = self._make_client([late]) # Let the reader consume the stale frame while nothing is awaiting it. - for _ in range(10): + for _ in range(100): if queue.empty(): break await asyncio.sleep(0) + self.assertTrue(queue.empty(), "reader never consumed the stale frame") # The next command must wait for its own response, not inherit the stale one. with self.assertRaises(asyncio.TimeoutError): @@ -1057,12 +1073,13 @@ async def test_unmatched_late_frame_is_not_given_to_the_next_command(self): client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=0.25 ) - queue.put_nowait(self._frame(Hoi2Action.COMMAND_RESPONSE, Address(1, 1, 257))) - self.assertIsNotNone( - await asyncio.wait_for( - client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=5 - ) + # ...and still completes when its own response does arrive. + task = asyncio.ensure_future( + client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))) ) + await self._await_registered(client) + queue.put_nowait(self._frame(Hoi2Action.COMMAND_RESPONSE, Address(1, 1, 257))) + self.assertIsNotNone(await asyncio.wait_for(task, timeout=5)) async def test_harp_control_frame_without_hoi_body_is_skipped(self): """The device sends a HARP control frame (options, no HOI body) after registration. @@ -1090,13 +1107,15 @@ async def read_exact(self, num_bytes: int, timeout=None): # type: ignore[overri async def test_reader_survives_an_unparseable_frame(self): """A malformed frame is skipped; the next command still gets its response.""" client, queue = self._make_client([]) + task = asyncio.ensure_future( + client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))) + ) + await self._await_registered(client) + queue.put_nowait(ValueError("Not enough data for u8 at offset 0")) queue.put_nowait(self._frame(Hoi2Action.COMMAND_RESPONSE, Address(1, 1, 257))) - result = await asyncio.wait_for( - client.send_query(TestCommandSerialization._Cmd(Address(1, 1, 257))), timeout=5 - ) - self.assertIsNotNone(result) + self.assertIsNotNone(await asyncio.wait_for(task, timeout=5)) self.assertTrue(client._is_reader_running()) async def test_reader_failure_fails_the_waiting_command(self): From 3e07fd95bb61e088d4f8ac687a295d3234ebf990 Mon Sep 17 00:00:00 2001 From: cmoscy <46687103+cmoscy@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:04:46 -0700 Subject: [PATCH 15/15] style(hamilton.transport.tcp): spell unparsable the way the repo does The typos CI check rejects 'unparseable'; the rest of the codebase already uses 'unparsable'. --- CHANGELOG.md | 2 +- pylabrobot/hamilton/transport/tcp/tcp.py | 2 +- pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af65f9abf6d..39026716c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `HamiltonTCPClient` no longer retransmits a command after a failed read. A read timeout on a slow motion command previously re-sent it, which could execute the motion twice (#1195). - `HamiltonTCPClient.setup()` now resets all per-session state (client id, sequence numbers, instrument addresses, object registry) rather than carrying it into the new session, and refuses to run on an already-connected client instead of leaking the socket (#1195). - `HamiltonTCPClient` no longer recurses without bound when a device fails the introspection queries that error enrichment itself issues. Enrichment is now non-re-entrant and falls back to the static HC_RESULT tables, so a degraded instrument yields a terse error instead of a `RecursionError` (#1195). -- `HamiltonTCPClient` no longer fails every command after the device sends a HARP control frame. MLPrep firmware sends one (options, no HOI body) shortly after registration; it was parsed as a command response and killed the reader. Frames with no routable message are skipped, as are unparseable frames, which is safe because frames are length-prefixed and consumed whole (#1195). +- `HamiltonTCPClient` no longer fails every command after the device sends a HARP control frame. MLPrep firmware sends one (options, no HOI body) shortly after registration; it was parsed as a command response and killed the reader. Frames with no routable message are skipped, as are unparsable frames, which is safe because frames are length-prefixed and consumed whole (#1195). ### Changed diff --git a/pylabrobot/hamilton/transport/tcp/tcp.py b/pylabrobot/hamilton/transport/tcp/tcp.py index 2a27c2581f1..47de8516dcc 100644 --- a/pylabrobot/hamilton/transport/tcp/tcp.py +++ b/pylabrobot/hamilton/transport/tcp/tcp.py @@ -373,7 +373,7 @@ async def _reader_loop(self) -> None: # A malformed frame must not take the reader down with it. Frames are # length-prefixed and consumed whole, so skipping one keeps the stream # in sync. - logger.warning("Skipping unparseable frame: %s", exc) + logger.warning("Skipping unparsable frame: %s", exc) continue if message is None: continue diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index 7b43b935521..a7dab2cfb9c 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -1104,7 +1104,7 @@ async def read_exact(self, num_bytes: int, timeout=None): # type: ignore[overri client._connected = True self.assertIsNone(await client._read_one_message()) - async def test_reader_survives_an_unparseable_frame(self): + async def test_reader_survives_an_unparsable_frame(self): """A malformed frame is skipped; the next command still gets its response.""" client, queue = self._make_client([]) task = asyncio.ensure_future(