diff --git a/docs/api/pylabrobot.hamilton.rst b/docs/api/pylabrobot.hamilton.rst index 45234392833..8b31c3739db 100644 --- a/docs/api/pylabrobot.hamilton.rst +++ b/docs/api/pylabrobot.hamilton.rst @@ -2,3 +2,10 @@ pylabrobot.hamilton package =========================== + +.. autosummary:: + :toctree: _autosummary + :recursive: + + prep + transport.tcp diff --git a/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb new file mode 100644 index 00000000000..cb88e97654a --- /dev/null +++ b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb @@ -0,0 +1,485 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0130a732", + "metadata": {}, + "source": [ + "# Hamilton PREP: Concise demo — teaching needle, liquid transfer, plate movement\n", + "\n", + "Single notebook demonstrating:\n", + "1. **Teaching needle** — Pick up teaching tip, move above plate A1 at safe height, drop tip.\n", + "2. **Dual-channel liquid handling** — Tip pickup, aspirate, and dispense (tip + volume tracking).\n", + "3. **8MPH (`head8`) liquid handling** — Full-column tip pickup, aspirate, and dispense when `has_mph`.\n", + "4. **Plate movement** — CoRe gripper: pick plate from deck[4], drop at deck[2].\n", + "\n", + "**Deck layout:** 1× 50 µL NTR tips at deck[3], 1× plate at deck[4] (moved to deck[2]). Visualizer is rooted on the deck: tip-spot occupancy, well fills, and plate assignment update live when tracking is enabled. Pipette mount state lives on `prep.channels.head` / `prep.head8.head` (printed below; not yet wired into the visualizer pipette panel).\n", + "\n", + "Uses {class}`~pylabrobot.hamilton.prep.prep.Prep` with `prep.channels`, `prep.head8`, and `prep.pick_up_core_grippers()`. Firmware tree / command-signature dumps live in the channel introspection notebooks.\n" + ] + }, + { + "cell_type": "markdown", + "id": "9d5a702a", + "metadata": {}, + "source": [ + "## 1. Imports and config\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "530ed0bf", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "import sys\n", + "from asyncio import sleep\n", + "\n", + "from pylabrobot.hamilton.prep import Prep\n", + "from pylabrobot.resources import Coordinate, set_tip_tracking, set_volume_tracking\n", + "from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub\n", + "from pylabrobot.resources.hamilton import PrepDeck, hamilton_96_tiprack_50uL_NTR\n", + "from pylabrobot.visualizer import Visualizer\n", + "\n", + "logging.getLogger(\"pylabrobot\").setLevel(logging.INFO)\n", + "logging.getLogger(\"pylabrobot\").handlers.clear()\n", + "handler = logging.StreamHandler(sys.stdout)\n", + "handler.setFormatter(logging.Formatter(\"%(levelname)s - %(message)s\"))\n", + "logging.getLogger(\"pylabrobot\").addHandler(handler)\n", + "\n", + "# Opt-in labware tracking (drives TipSpot / well updates in Visualizer(deck)).\n", + "set_tip_tracking(True)\n", + "set_volume_tracking(True)\n", + "\n", + "HOST = \"192.168.100.102\" # \"127.0.0.1\" For port forwarded connection if set up\n", + "PORT = 2000\n", + "SAFE_HEIGHT_MM_ABOVE_WELL = 20\n" + ] + }, + { + "cell_type": "markdown", + "id": "1c6ddc58", + "metadata": {}, + "source": [ + "## 2. Deck layout and visualizer\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "fb2dbcdd", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Websocket server started at http://127.0.0.1:2122\n", + "File server started at http://127.0.0.1:1338 . Open this URL in your browser.\n" + ] + } + ], + "source": [ + "# PrepDeck: spots 0–7 (column-major). With CoRe grippers mount for plate movement.\n", + "deck = PrepDeck(with_core_grippers=True)\n", + "\n", + "tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name=\"ntr_50\", with_tips=True)\n", + "plate = deck[4] = cor_axy_96_wellplate_500uL_Ub(\"plate\")\n", + "\n", + "visualizer = Visualizer(deck, open_browser=False)\n", + "await visualizer.setup()\n" + ] + }, + { + "cell_type": "markdown", + "id": "9df37796", + "metadata": {}, + "source": [ + "## 3. Prep device and setup\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "7c1b3d1c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Initializing Hamilton connection...\n", + "INFO - Registering Hamilton client...\n", + "INFO - Discovering Hamilton root objects...\n", + "INFO - Discovering Hamilton global objects...\n", + "INFO - Hamilton TCP client setup complete. Client ID: 7, globals: 1\n", + "INFO - MLPrep already initialized, skipping Initialize\n", + "INFO - Discovered 2 Channel Root channel drive pair(s)\n", + "INFO - Hardware config: has_enclosure=True, safe_speeds=False, traverse_height=167.5, deck_bounds=DeckBounds(min_x=0.0, max_x=299.0, min_y=-9.0, max_y=385.0, min_z=19.5, max_z=167.5), deck_sites=6, waste_sites=3, num_channels=2, has_mph=True\n", + "INFO - Channel bounds: [{'x_min': 1.5235061645507812, 'x_max': 300.52349853515625, 'y_min': 0.0, 'y_max': 385.0, 'z_min': 19.5, 'z_max': 167.5}, {'x_min': 1.5235061645507812, 'x_max': 300.52349853515625, 'y_min': -9.0, 'y_max': 376.0, 'z_min': 19.5, 'z_max': 167.5}]\n", + "INFO - V2 aspirate/dispense support: True\n", + "INFO - Discovered 1 MPH Channel Root channel drive pair(s)\n", + "INFO - MPH V2 aspirate/dispense support: True\n" + ] + } + ], + "source": [ + "prep = Prep(deck=deck, host=HOST, port=PORT)\n", + "await prep.setup(smart=True, force_initialize=False)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff331c04", + "metadata": {}, + "outputs": [], + "source": [ + "await prep.set_deck_light(255, 0, 0, 0)\n" + ] + }, + { + "cell_type": "markdown", + "id": "f9d0dd3c", + "metadata": {}, + "source": [ + "## 4. Device snapshot\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "72746142", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "channels=2, has_mph=True, traverse_height=167.5, enclosure=True, safe_speeds=False\n" + ] + } + ], + "source": [ + "cfg = prep.info.config\n", + "print(\n", + " f\"channels={cfg.num_channels}, has_mph={cfg.has_mph}, \"\n", + " f\"traverse_height={cfg.default_traverse_height}, \"\n", + " f\"enclosure={cfg.has_enclosure}, safe_speeds={cfg.safe_speeds_enabled}\"\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "5ec9e02e", + "metadata": {}, + "source": [ + "## 5. Teaching needle: above plate A1 at safe height\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87c8a767", + "metadata": {}, + "outputs": [], + "source": [ + "logging.getLogger(\"pylabrobot\").setLevel(logging.DEBUG)\n", + "\n", + "assert prep.channels is not None\n", + "teaching_tip = deck.get_resource(\"teaching_tip\")\n", + "if not teaching_tip.has_tip():\n", + " teaching_tip.tracker.add_tip(teaching_tip.make_tip(), origin=teaching_tip, commit=True)\n", + "\n", + "await prep.channels.pick_up_tips([teaching_tip], use_channels=[0])\n", + "\n", + "a1 = plate.get_item(\"A1\")\n", + "safe_pos = a1.get_absolute_location(\"c\", \"c\", \"b\") + Coordinate(0, 0, SAFE_HEIGHT_MM_ABOVE_WELL)\n", + "await prep.channels.move_to_position(safe_pos.x, safe_pos.y, safe_pos.z, use_channels=[0])\n", + "await sleep(3)\n", + "\n", + "await prep.channels.drop_tips([teaching_tip], use_channels=[0])\n", + "\n", + "logging.getLogger(\"pylabrobot\").setLevel(logging.INFO)\n" + ] + }, + { + "cell_type": "markdown", + "id": "e5f4ee40", + "metadata": {}, + "source": [ + "## 6. Tip pickup, aspirate, dispense (dual channel)\n", + "\n", + "Tip and volume tracking are on: tip spots and well fills update in the visualizer; `prep.channels.head` holds mount identity (printed below).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "86c2325e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "before pick\n", + " tip spots have tip: [True, True]\n", + " mounted tips: [None, None]\n", + " src volumes: [100.0, 100.0]\n", + " dst volumes: [0, 0]\n", + " tip volumes: [None, None]\n", + "after pick\n", + " tip spots have tip: [False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [100.0, 100.0]\n", + " dst volumes: [0, 0]\n", + " tip volumes: [0, 0]\n", + "after aspirate\n", + " tip spots have tip: [False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [65.0, 75.0]\n", + " dst volumes: [0, 0]\n", + " tip volumes: [35.0, 25.0]\n", + "after dispense\n", + " tip spots have tip: [False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [65.0, 75.0]\n", + " dst volumes: [35.0, 25.0]\n", + " tip volumes: [0.0, 0.0]\n", + "after drop\n", + " tip spots have tip: [True, True]\n", + " mounted tips: [None, None]\n", + " src volumes: [65.0, 75.0]\n", + " dst volumes: [35.0, 25.0]\n", + " tip volumes: [None, None]\n" + ] + } + ], + "source": [ + "assert prep.channels is not None\n", + "tip_spots = tip_rack[\"A1:B1\"]\n", + "channels = [0, 1]\n", + "src = plate[\"A1:B1\"]\n", + "dst = plate[\"A7:B7\"]\n", + "vols = [35.0, 25.0]\n", + "\n", + "for well in src:\n", + " well.tracker.set_volume(100.0)\n", + "\n", + "def _status(label: str) -> None:\n", + " print(label)\n", + " print(f\" tip spots have tip: {[s.has_tip() for s in tip_spots]}\")\n", + " print(f\" mounted tips: {prep.channels.get_mounted_tips()}\")\n", + " print(f\" src volumes: {[w.tracker.get_used_volume() for w in src]}\")\n", + " print(f\" dst volumes: {[w.tracker.get_used_volume() for w in dst]}\")\n", + " tips = [prep.channels.head[ch].get_tip() if prep.channels.head[ch].has_tip else None for ch in channels]\n", + " print(f\" tip volumes: {[t.tracker.get_used_volume() if t is not None else None for t in tips]}\")\n", + "\n", + "_status(\"before pick\")\n", + "await prep.channels.pick_up_tips(tip_spots, use_channels=channels)\n", + "_status(\"after pick\")\n", + "\n", + "await prep.channels.aspirate(\n", + " src,\n", + " vols=vols,\n", + " use_channels=channels,\n", + " liquid_height=[3.0, 3.0],\n", + " z_liquid_exit_speed=[25.0, 25.0],\n", + ")\n", + "_status(\"after aspirate\")\n", + "\n", + "await prep.channels.dispense(\n", + " dst,\n", + " vols=vols,\n", + " use_channels=channels,\n", + " liquid_height=[3.0, 3.0],\n", + " z_liquid_exit_speed=[25.0, 25.0],\n", + ")\n", + "_status(\"after dispense\")\n", + "\n", + "await prep.channels.drop_tips(tip_spots, use_channels=channels)\n", + "_status(\"after drop\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "31fa0172", + "metadata": {}, + "source": [ + "## 7. Tip pickup, aspirate, dispense (8MPH / head8)\n", + "\n", + "Skipped when `prep.head8` is None. Uses tip rack `A2:H2` and plate `A2:H2` → `A4:H4` so it does not collide with the dual-channel wells above. All 8 probes operate together.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "11e1622b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "before pick8\n", + " tip spots have tip: [True, True, True, True, True, True, True, True]\n", + " mounted tips: [None, None, None, None, None, None, None, None]\n", + " src volumes: [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0]\n", + " dst volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + " tip volumes: [None, None, None, None, None, None, None, None]\n", + "INFO - [Prep MPH] pick_up_tips: rack=ntr_50, tip_spots=['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2']\n", + "after pick8\n", + " tip spots have tip: [False, False, False, False, False, False, False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_C2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_D2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_E2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_F2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_G2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_H2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0]\n", + " dst volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + " tip volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + "INFO - [Prep MPH] aspirate: resource=plate, wells=['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2'], volume=15.000, flow_rate=100.0\n", + "after aspirate8\n", + " tip spots have tip: [False, False, False, False, False, False, False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_C2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_D2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_E2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_F2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_G2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_H2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0]\n", + " dst volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + " tip volumes: [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0]\n", + "INFO - [Prep MPH] dispense: resource=plate, wells=['A4', 'B4', 'C4', 'D4', 'E4', 'F4', 'G4', 'H4'], volume=15.000, flow_rate=100.0\n", + "after dispense8\n", + " tip spots have tip: [False, False, False, False, False, False, False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_C2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_D2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_E2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_F2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_G2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_H2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0]\n", + " dst volumes: [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0]\n", + " tip volumes: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]\n", + "INFO - [Prep MPH] drop_tips: dest=ntr_50, resources=['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2']\n", + "after drop8\n", + " tip spots have tip: [True, True, True, True, True, True, True, True]\n", + " mounted tips: [None, None, None, None, None, None, None, None]\n", + " src volumes: [85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0]\n", + " dst volumes: [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0]\n", + " tip volumes: [None, None, None, None, None, None, None, None]\n" + ] + } + ], + "source": [ + "if prep.head8 is None:\n", + " print(\"head8 not present (has_mph=False); skipping 8MPH transfer\")\n", + "else:\n", + " tip_spots8 = tip_rack[\"A2:H2\"]\n", + " src8 = plate[\"A2:H2\"]\n", + " dst8 = plate[\"A4:H4\"]\n", + " vol8 = 15.0\n", + " for well in src8:\n", + " well.tracker.set_volume(100.0)\n", + "\n", + " def _status8(label: str) -> None:\n", + " print(label)\n", + " print(f\" tip spots have tip: {[s.has_tip() for s in tip_spots8]}\")\n", + " print(f\" mounted tips: {prep.head8.get_mounted_tips()}\")\n", + " print(f\" src volumes: {[w.tracker.get_used_volume() for w in src8]}\")\n", + " print(f\" dst volumes: {[w.tracker.get_used_volume() for w in dst8]}\")\n", + " tips = [\n", + " prep.head8.head[i].get_tip() if prep.head8.head[i].has_tip else None\n", + " for i in range(8)\n", + " ]\n", + " print(f\" tip volumes: {[t.tracker.get_used_volume() if t is not None else None for t in tips]}\")\n", + "\n", + " _status8(\"before pick8\")\n", + " await prep.head8.pick_up_tips8(tip_spots8)\n", + " _status8(\"after pick8\")\n", + "\n", + " await prep.head8.aspirate8(wells=src8, volume=vol8, liquid_height=3.0)\n", + " _status8(\"after aspirate8\")\n", + "\n", + " await prep.head8.dispense8(wells=dst8, volume=vol8, liquid_height=3.0)\n", + " _status8(\"after dispense8\")\n", + "\n", + " await prep.head8.drop_tips8(tip_spots8)\n", + " _status8(\"after drop8\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "6100111d", + "metadata": {}, + "source": [ + "## 8. Plate movement with CoRe gripper (deck[4] → deck[2])\n", + "\n", + "`drop_resource` reassigns the plate in the resource tree; the visualizer moves it to deck[2].\n" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "c5f5981d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "plate parent: spot_0_2\n" + ] + } + ], + "source": [ + "arm = await prep.pick_up_core_grippers()\n", + "await arm.pick_up_resource(plate)\n", + "await arm.drop_resource(deck[2])\n", + "await prep.return_core_grippers()\n", + "print(f\"plate parent: {plate.parent.name if plate.parent is not None else None}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "6eafd340", + "metadata": {}, + "source": [ + "## 9. Teardown\n" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "907a9ce2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Closing connection to socket 127.0.0.1:2000\n", + "INFO - Hamilton TCP client stopped\n" + ] + } + ], + "source": [ + "await prep.park()\n", + "#await prep.disco_mode()\n", + "await prep.stop()\n", + "await visualizer.stop()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/hamilton/index.md b/docs/user_guide/hamilton/index.md index b3d980e3e2c..59a1985d6d6 100644 --- a/docs/user_guide/hamilton/index.md +++ b/docs/user_guide/hamilton/index.md @@ -4,4 +4,5 @@ :maxdepth: 1 star/index +prep/index ``` diff --git a/docs/user_guide/hamilton/prep/index.md b/docs/user_guide/hamilton/prep/index.md new file mode 100644 index 00000000000..9868eb077c1 --- /dev/null +++ b/docs/user_guide/hamilton/prep/index.md @@ -0,0 +1,7 @@ +# Prep + +```{toctree} +:maxdepth: 1 + +../../00_liquid-handling/hamilton-prep/prep_basic_demo +``` diff --git a/pylabrobot/hamilton/liquid_class_resolver.py b/pylabrobot/hamilton/liquid_class_resolver.py new file mode 100644 index 00000000000..207dccf84bf --- /dev/null +++ b/pylabrobot/hamilton/liquid_class_resolver.py @@ -0,0 +1,100 @@ +"""Resolve Hamilton liquid classes and corrected volumes for Prep PIP ops. + +Automatic lookup defaults to +:func:`~pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star.get_star_liquid_class` +(STAR calibration tables); pass ``lookup=`` for instrument-specific tables. +""" + +from __future__ import annotations + +from typing import Any, Callable, List, Optional, Sequence, Union + +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.resources.hamilton import HamiltonTip +from pylabrobot.resources.liquid import Liquid + +_Lookup = Callable[..., Optional[HamiltonLiquidClass]] + + +def resolve_hamilton_liquid_classes( + explicit: Optional[List[Optional[HamiltonLiquidClass]]], + ops: list, + *, + jet: Union[bool, List[bool]] = False, + blow_out: Union[bool, List[bool]] = False, + is_aspirate: bool = True, + lookup: Optional[_Lookup] = None, +) -> List[Optional[HamiltonLiquidClass]]: + """Resolve per-op Hamilton liquid classes. + + If ``explicit`` is None, resolve from each op's tip via ``lookup`` (default + :func:`get_star_liquid_class`). Non-``HamiltonTip`` tips yield ``None``. + + If ``explicit`` is a list, it is returned as a shallow copy; ``None`` entries + are preserved (legacy STAR behavior). + + Args: + explicit: Caller-provided liquid classes, or None for automatic lookup. + ops: Aspiration or dispense operations (must have a ``tip`` attribute). + jet: Per-op or scalar flags passed to automatic liquid class lookup. + blow_out: Per-op or scalar flags passed to automatic liquid class lookup. + is_aspirate: Reserved for API compatibility with STAR; unused. + lookup: Optional callable with the same signature as ``get_star_liquid_class``. + """ + del is_aspirate + n = len(ops) + if isinstance(jet, bool): + jet = [jet] * n + if isinstance(blow_out, bool): + blow_out = [blow_out] * n + + if explicit is not None: + return list(explicit) + + if lookup is None: + # Lazy import avoids circular import: star package __init__ may pull in pip_backend, + # which imports this module. + from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star import get_star_liquid_class + + fn = get_star_liquid_class + else: + fn = lookup + result: List[Optional[HamiltonLiquidClass]] = [] + for i, op in enumerate(ops): + tip = op.tip + if not isinstance(tip, HamiltonTip): + result.append(None) + continue + result.append( + fn( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=jet[i], + blow_out=blow_out[i], + ) + ) + + return result + + +def corrected_volumes_for_ops( + ops: Sequence[Any], + hlcs: Sequence[Optional[HamiltonLiquidClass]], + disable_volume_correction: Optional[Sequence[bool]] = None, +) -> List[float]: + """Apply liquid-class volume correction per op when enabled.""" + n = len(ops) + if len(hlcs) != n: + raise ValueError(f"hlcs length must match ops ({n}), got {len(hlcs)}") + dvc = list(disable_volume_correction) if disable_volume_correction is not None else [False] * n + if len(dvc) != n: + raise ValueError(f"disable_volume_correction length must match ops ({n}), got {len(dvc)}") + return [ + float(hlc.compute_corrected_volume(op.volume)) + if hlc is not None and not disabled + else float(op.volume) + for op, hlc, disabled in zip(ops, hlcs, dvc) + ] diff --git a/pylabrobot/hamilton/prep/__init__.py b/pylabrobot/hamilton/prep/__init__.py new file mode 100644 index 00000000000..0e2f21016a0 --- /dev/null +++ b/pylabrobot/hamilton/prep/__init__.py @@ -0,0 +1,13 @@ +"""Hamilton Prep liquid handler.""" + +from pylabrobot.hamilton.prep.calibration import PrepCalibration +from pylabrobot.hamilton.prep.chatterbox import PrepChatterboxClient +from pylabrobot.hamilton.prep.client import PrepClient +from pylabrobot.hamilton.prep.prep import Prep + +__all__ = [ + "Prep", + "PrepCalibration", + "PrepChatterboxClient", + "PrepClient", +] diff --git a/pylabrobot/hamilton/prep/calibration.py b/pylabrobot/hamilton/prep/calibration.py new file mode 100644 index 00000000000..323c36ce37f --- /dev/null +++ b/pylabrobot/hamilton/prep/calibration.py @@ -0,0 +1,587 @@ +"""Prep calibration: MLPrepCalibration commands and session workflows. + +Firmware-path resolution is JIT: each ``PrepCommand`` subclass declares its own +``firmware_path``, and :meth:`PrepClient.send_command` resolves it via the +introspection registry (cache-hot after the first call). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Awaitable, + Callable, + List, + Literal, + Optional, + Tuple, + TypeVar, + Union, +) + +from pylabrobot.resources.tip_rack import TipSpot + +from . import prep_commands as PrepCmd + +if TYPE_CHECKING: + from .client import PrepClient + from .info import PrepInstrumentInfo + +logger = logging.getLogger(__name__) + +_TCalibResult = TypeVar("_TCalibResult") + +# Same mapping as Prep channels for TipPositionParameters / channel indices. +_CHANNEL_INDEX = { + 0: PrepCmd.ChannelIndex.RearChannel, + 1: PrepCmd.ChannelIndex.FrontChannel, +} + + +@dataclass(frozen=True) +class CalibrationCommandReport: + """Structured report for one calibration command execution.""" + + command: str + result: object + before: PrepCmd.CalibrationValues + after: PrepCmd.CalibrationValues + diff: PrepCmd.CalibrationValuesDiff + + @property + def changed_fields_count(self) -> int: + channel_changes = sum( + len(cd.changes) for cd in self.diff.channel_diffs if cd.state == "changed" + ) + return ( + len(self.diff.top_level_changes) + + channel_changes + + sum(1 for cd in self.diff.channel_diffs if cd.state in ("added", "removed")) + ) + + +class PrepCalibration: + """Calibration façade: firmware MLPrepCalibration object + DeckConfiguration site defs.""" + + def __init__(self, *, driver: "PrepClient", info: "PrepInstrumentInfo") -> None: + self._driver = driver + self._info = info + self._calibration_session_active: bool = False + + @property + def client(self) -> "PrepClient": + """Alias for code that uses ``client.send_command`` (driver is the TCP client).""" + return self._driver + + @property + def num_channels(self) -> int: + n = self._info.config.num_channels + if n is None: + raise RuntimeError("Instrument config has no num_channels (finish Prep.setup first).") + return n + + @property + def has_mph(self) -> bool: + h = self._info.config.has_mph + if h is None: + raise RuntimeError("Instrument config has no has_mph (finish Prep.setup first).") + return h + + def _set_calibration_session_active(self, active: bool) -> None: + self._calibration_session_active = active + + def calibration_session( + self, + *, + float_tol: float = 1e-6, + report_after_command: bool = True, + report_scope: Literal["related", "full"] = "related", + session_read_timeout: Optional[float] = None, + ) -> PrepCalibrationSession: + """Create a managed calibration session bound to this façade.""" + return PrepCalibrationSession( + self, + float_tol=float_tol, + report_after_command=report_after_command, + report_scope=report_scope, + session_read_timeout=session_read_timeout, + ) + + async def get_calibration_site_definitions(self) -> Tuple[PrepCmd.CalibrationSiteInfo, ...]: + """Return calibration site definitions from DeckConfiguration (GetCalibrationSiteDefinitions, cmd=3).""" + result = await self._driver.send_command(PrepCmd.PrepGetCalibrationSiteDefinitions()) + if result is None or not getattr(result, "sites", None): + return () + return tuple( + PrepCmd.CalibrationSiteInfo( + id=int(s.id), + left_bottom_front_x=float(s.left_bottom_front_x), + left_bottom_front_y=float(s.left_bottom_front_y), + left_bottom_front_z=float(s.left_bottom_front_z), + length=float(s.length), + width=float(s.width), + height=float(s.height), + post=bool(s.post), + ) + for s in result.sites + ) + + async def begin_calibration(self) -> None: + """Enter calibration mode (BeginCalibration, cmd=1).""" + await self._driver.send_command(PrepCmd.PrepBeginCalibration()) + + async def cancel_calibration(self) -> None: + """Cancel an active calibration session (CancelCalibration, cmd=2).""" + await self._driver.send_command(PrepCmd.PrepCancelCalibration()) + + async def end_calibration(self, date_time: Optional[PrepCmd.HoiDateTime] = None) -> None: + """End calibration and store results with timestamp (EndCalibration, cmd=3).""" + if date_time is None: + date_time = PrepCmd.HoiDateTime.now() + await self._driver.send_command(PrepCmd.PrepEndCalibration(date_time=date_time)) + + async def reset_calibration(self, store: bool = False) -> None: + """Reset calibration data (ResetCalibration, cmd=4).""" + await self._driver.send_command(PrepCmd.PrepResetCalibration(store=store)) + + async def calibration_initialize(self) -> None: + """Initialize calibration hardware (CalibrationInitialize, cmd=5).""" + await self._driver.send_command(PrepCmd.PrepCalibrationInitialize()) + + async def read_calibration_values( + self, read_timeout: Optional[float] = None + ) -> PrepCmd.CalibrationValues: + """Read calibration values (GetCalibrationValues, cmd=16).""" + result = await self._driver.send_command( + PrepCmd.PrepGetCalibrationValues(), + read_timeout=read_timeout, + ) + if result is None: + return PrepCmd.CalibrationValues( + independent_offset_x=0.0, + mph_offset_x=0.0, + channel_values=(), + ) + + return PrepCmd.CalibrationValues( + independent_offset_x=float(result.independent_offset_x), + mph_offset_x=float(result.mph_offset_x), + channel_values=tuple( + PrepCmd.ChannelCalibrationValuesInfo( + index=int(cv.index), + y_offset=float(cv.y_offset), + z_offset=float(cv.z_offset), + squeeze_position=int(cv.squeeze_position), + z_touchoff=int(cv.z_touchoff), + pressure_shift=int(cv.pressure_shift), + pressure_monitoring_shift=int(cv.pressure_monitoring_shift), + dispenser_return_distance=float(cv.dispenser_return_distance), + z_tip_height=float(cv.z_tip_height), + core_ii=bool(cv.core_ii), + ) + for cv in (result.channel_values or []) + ), + ) + + +class PrepCalibrationSession: + """Context manager for stateful Prep calibration workflows.""" + + def __init__( + self, + cal: PrepCalibration, + *, + float_tol: float = 1e-6, + report_after_command: bool = True, + report_scope: Literal["related", "full"] = "related", + session_read_timeout: Optional[float] = None, + ) -> None: + self._cal = cal + self.float_tol = float_tol + self.report_after_command = report_after_command + self.report_scope = report_scope + self.session_read_timeout = session_read_timeout + + self._started = False + self._ended = False + self._baseline: Optional[PrepCmd.CalibrationValues] = None + self._last_snapshot: Optional[PrepCmd.CalibrationValues] = None + self.history: List[CalibrationCommandReport] = [] + + if report_scope not in ("related", "full"): + raise ValueError(f"report_scope must be 'related' or 'full', got: {report_scope}") + + @property + def baseline(self) -> PrepCmd.CalibrationValues: + if self._baseline is None: + raise RuntimeError("Session baseline unavailable. Enter the session first.") + return self._baseline + + @property + def last_snapshot(self) -> PrepCmd.CalibrationValues: + if self._last_snapshot is None: + raise RuntimeError("Session snapshot unavailable. Enter the session first.") + return self._last_snapshot + + def _effective_timeout(self, read_timeout: Optional[float]) -> Optional[float]: + return self.session_read_timeout if read_timeout is None else read_timeout + + def _ensure_started(self) -> None: + if not self._started: + raise RuntimeError("Calibration session is not started. Call `await session.start()` first.") + if self._ended: + raise RuntimeError("Calibration session is already ended.") + + def _select_snapshot_scope( + self, + values: PrepCmd.CalibrationValues, + *, + channel: Optional[PrepCmd.ChannelIndex] = None, + ) -> PrepCmd.CalibrationValues: + if self.report_scope == "full" or channel is None: + return values + channel_index = int(channel) + return PrepCmd.CalibrationValues( + independent_offset_x=values.independent_offset_x, + mph_offset_x=values.mph_offset_x, + channel_values=tuple(cv for cv in values.channel_values if cv.index == channel_index), + ) + + def _log_report(self, report: CalibrationCommandReport) -> None: + if report.diff.has_changes: + logger.info( + "Calibration session %s changed %d field(s)", + report.command, + report.changed_fields_count, + ) + else: + logger.info("Calibration session %s produced no calibration changes", report.command) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Calibration report diff for %s:\n%s", + report.command, + PrepCmd.format_calibration_diff(report.diff), + ) + + async def _get_calibration_values( + self, + *, + read_timeout: Optional[float] = None, + ) -> PrepCmd.CalibrationValues: + return await self._cal.read_calibration_values(read_timeout=read_timeout) + + async def _run_with_report( + self, + command_name: str, + op: Callable[[Optional[float]], Awaitable[_TCalibResult]], + *, + channel: Optional[PrepCmd.ChannelIndex] = None, + read_timeout: Optional[float] = None, + ) -> Union[_TCalibResult, CalibrationCommandReport]: + timeout = self._effective_timeout(read_timeout) + if not self.report_after_command: + result = await op(timeout) + self._last_snapshot = await self._get_calibration_values(read_timeout=timeout) + return result + + before_full = await self._get_calibration_values(read_timeout=timeout) + result = await op(timeout) + after_full = await self._get_calibration_values(read_timeout=timeout) + self._last_snapshot = after_full + + before = self._select_snapshot_scope(before_full, channel=channel) + after = self._select_snapshot_scope(after_full, channel=channel) + diff = PrepCmd.diff_calibration_values(before, after, float_tol=self.float_tol) + report = CalibrationCommandReport( + command=command_name, + result=result, + before=before, + after=after, + diff=diff, + ) + self.history.append(report) + self._log_report(report) + return report + + async def __aenter__(self) -> PrepCalibrationSession: + await self.start() + return self + + async def start(self) -> PrepCalibrationSession: + """Start calibration mode and capture baseline snapshot.""" + if self._started: + return self + if self._ended: + raise RuntimeError("Calibration session is already ended; create a new session.") + if self._cal._calibration_session_active: + raise RuntimeError("A calibration session is already active on this PrepCalibration.") + await self._cal.begin_calibration() + await self._cal.calibration_initialize() + self._cal._set_calibration_session_active(True) + try: + snapshot = await self._get_calibration_values(read_timeout=self.session_read_timeout) + except Exception: + self._cal._set_calibration_session_active(False) + raise + self._baseline = snapshot + self._last_snapshot = snapshot + self._started = True + logger.info("Calibration session started") + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + if self._ended: + return False + try: + await self.end(save=False) + except Exception: + logger.exception("Failed to rollback calibration session") + if exc is None: + raise + return False + + async def snapshot(self, *, read_timeout: Optional[float] = None) -> PrepCmd.CalibrationValues: + self._ensure_started() + snapshot = await self._get_calibration_values( + read_timeout=self._effective_timeout(read_timeout) + ) + self._last_snapshot = snapshot + return snapshot + + async def diff_from_start( + self, + *, + float_tol: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> PrepCmd.CalibrationValuesDiff: + self._ensure_started() + current = await self.snapshot(read_timeout=read_timeout) + return PrepCmd.diff_calibration_values( + self.baseline, + current, + float_tol=self.float_tol if float_tol is None else float_tol, + ) + + async def diff_from_last( + self, + *, + float_tol: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> PrepCmd.CalibrationValuesDiff: + self._ensure_started() + previous = self.last_snapshot + current = await self.snapshot(read_timeout=read_timeout) + return PrepCmd.diff_calibration_values( + previous, + current, + float_tol=self.float_tol if float_tol is None else float_tol, + ) + + async def end( + self, *, save: bool = True, date_time: Optional[PrepCmd.HoiDateTime] = None + ) -> None: + """End the calibration session, optionally saving values.""" + if self._ended: + return + self._ensure_started() + if save: + await self._cal.end_calibration(date_time=date_time) + logger.info("Calibration session ended and saved") + else: + await self._cal.cancel_calibration() + logger.info("Calibration session ended without saving") + self._ended = True + self._started = False + self._cal._set_calibration_session_active(False) + + async def rollback(self) -> None: + """End the session without saving (alias for ``end(save=False)``).""" + await self.end(save=False) + + async def commit(self) -> None: + """Save calibration and end the session (alias for ``end(save=True)``).""" + await self.end(save=True) + + async def reset(self, *, store: bool = False) -> None: + """Reset calibration values during an active calibration session.""" + self._ensure_started() + await self._cal.reset_calibration(store=store) + self._last_snapshot = await self._get_calibration_values(read_timeout=self.session_read_timeout) + + async def calibrate_x_axis( + self, + *, + site_index: int, + channel: PrepCmd.ChannelIndex, + read_timeout: Optional[float] = None, + ) -> Union[float, CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> float: + result = await self._cal.client.send_command( + PrepCmd.PrepCalibrateXAxis( + site_index=site_index, + channel=int(channel), + ), + read_timeout=timeout, + ) + return float(result.offset) + + return await self._run_with_report( + f"calibrate_x_axis(channel={channel.name}, site_index={site_index})", + _op, + channel=channel, + read_timeout=read_timeout, + ) + + async def calibrate_y_axis( + self, + *, + site_index: int, + channel: PrepCmd.ChannelIndex, + read_timeout: Optional[float] = None, + ) -> Union[float, CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> float: + result = await self._cal.client.send_command( + PrepCmd.PrepCalibrateYAxis( + site_index=site_index, + channel=int(channel), + ), + read_timeout=timeout, + ) + return float(result.offset) + + return await self._run_with_report( + f"calibrate_y_axis(channel={channel.name}, site_index={site_index})", + _op, + channel=channel, + read_timeout=read_timeout, + ) + + async def calibrate_z_axis( + self, + *, + site_index: int, + channel: PrepCmd.ChannelIndex, + read_timeout: Optional[float] = None, + ) -> Union[float, CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> float: + result = await self._cal.client.send_command( + PrepCmd.PrepCalibrateZAxis( + site_index=site_index, + channel=int(channel), + ), + read_timeout=timeout, + ) + return float(result.offset) + + return await self._run_with_report( + f"calibrate_z_axis(channel={channel.name}, site_index={site_index})", + _op, + channel=channel, + read_timeout=read_timeout, + ) + + async def calibrate_squeeze_tips( + self, + tip_spots: List[TipSpot], + *, + use_channels: Optional[List[int]] = None, + z_seek_offset: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> Union[Tuple[int, ...], CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> Tuple[int, ...]: + channels = use_channels if use_channels is not None else list(range(len(tip_spots))) + assert len(tip_spots) == len(channels) + + indexed_spots = {ch: spot for ch, spot in zip(channels, tip_spots)} + tip_positions: List[PrepCmd.TipPositionParameters] = [] + for ch in range(self._cal.num_channels): + if ch not in indexed_spots: + continue + spot = indexed_spots[ch] + loc = spot.get_absolute_location("c", "c", "t") + tip_positions.append( + PrepCmd.TipPositionParameters.for_op( + _CHANNEL_INDEX[ch], + loc, + spot.get_tip(), + z_seek_offset=z_seek_offset, + ) + ) + + result = await self._cal.client.send_command( + PrepCmd.PrepCalibrateSqueezeTips( + channels=tip_positions, + ), + read_timeout=timeout, + ) + if result is None or not getattr(result, "positions", None): + return () + return tuple(int(p) for p in result.positions) + + return await self._run_with_report( + "calibrate_squeeze_tips", + _op, + read_timeout=read_timeout, + ) + + async def calibrate_squeeze_tips_mph( + self, + tip_spot: Union[TipSpot, List[TipSpot]], + *, + z_seek_offset: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> Union[Tuple[int, ...], CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> Tuple[int, ...]: + if not self._cal.has_mph: + raise RuntimeError( + "Instrument does not have an 8MPH head. Cannot use calibrate_squeeze_tips_mph." + ) + spots = tip_spot if isinstance(tip_spot, list) else [tip_spot] + if not spots: + raise ValueError("calibrate_squeeze_tips_mph: tip_spot list is empty") + + ref_spot = spots[0] + loc = ref_spot.get_absolute_location("c", "c", "t") + tip_position = PrepCmd.TipPositionParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, + loc, + ref_spot.get_tip(), + z_seek_offset=z_seek_offset, + ) + + result = await self._cal.client.send_command( + PrepCmd.PrepCalibrateSqueezeTips( + channels=[tip_position], + ), + read_timeout=timeout, + ) + if result is None or not getattr(result, "positions", None): + return () + return tuple(int(p) for p in result.positions) + + return await self._run_with_report( + "calibrate_squeeze_tips_mph", + _op, + channel=PrepCmd.ChannelIndex.MPHChannel, + read_timeout=read_timeout, + ) + + +__all__ = [ + "CalibrationCommandReport", + "PrepCalibration", + "PrepCalibrationSession", +] diff --git a/pylabrobot/hamilton/prep/channels.py b/pylabrobot/hamilton/prep/channels.py new file mode 100644 index 00000000000..b8c12c5b2ef --- /dev/null +++ b/pylabrobot/hamilton/prep/channels.py @@ -0,0 +1,2554 @@ +"""PrepChannels: dual-channel pipettor ops plus per-channel discovery. + +Channel-scoped topology discovery, bounds parsing, and per-channel firmware +queries live alongside tip pickup/drop and aspirate/dispense orchestration. + +The firmware object tree exposes channel internals as a single template under +``MLPrepRoot.Channel Root.Channel`` (and an analogous ``MLPrepRoot.MPH Channel +Root.Channel`` for MPH). Individual physical channels share that template — +per-channel identity lives in the node-ID component of the Address. We probe +the full object tree and match children by **path prefix** +(``".Channel Root.Channel.Squeeze.SDrive"``) rather than computing node +IDs directly. +""" + +from __future__ import annotations + +import enum +import logging +import math +import struct as _struct +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Generic, + List, + Literal, + NamedTuple, + Optional, + Sequence, + Tuple, + TypedDict, + TypeVar, + Union, +) + +from pylabrobot.hamilton.liquid_class_resolver import ( + corrected_volumes_for_ops, + resolve_hamilton_liquid_classes, +) +from pylabrobot.hamilton.transport.tcp.hoi_error import HoiError +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.resources import Container, Coordinate, Tip +from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers +from pylabrobot.resources.resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + all_channels_succeeded, + finalize_tip_ops, + finalize_volume_ops, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.well import CrossSectionType, Well + +from . import prep_commands as PrepCmd +from .client import PIPETTOR_OBJECT_PATH + +if TYPE_CHECKING: + from pylabrobot.resources.deck import Deck + + from .client import PrepClient + from .info import PrepInstrumentInfo + +logger = logging.getLogger(__name__) + +_T = TypeVar("_T") + + +@dataclass +class _PipetteTransfer: + """Private snapshot for aspirate/dispense resolution (not a public standard op).""" + + resource: Container + tip: Tip + volume: float + offset: Coordinate + liquid_height: Optional[float] = None + flow_rate: Optional[float] = None + blow_out_air_volume: Optional[float] = None + + +_OpT = TypeVar("_OpT", bound=_PipetteTransfer) + + +# ============================================================================= +# Shared pure helpers (also imported by PrepHead8) +# ============================================================================= + + +def fill_in_defaults(val: Optional[List[_T]], default: List[_T]) -> List[_T]: + """Convert optional per-channel overrides into a full list matching ``default`` length.""" + if val is None: + return default + if len(val) != len(default): + raise ValueError(f"Value length must equal num operations ({len(default)}), but is {len(val)}") + return [v if v is not None else d for v, d in zip(val, default)] + + +class LLDMode(enum.Enum): + """Liquid level detection mode. + + Same numbering as STARBackend.LLDMode for cross-backend compatibility. + CAPACITIVE (value=1) is named GAMMA on the STAR — CAPACITIVE is the correct term. + The Prep firmware uses separate command variants for LLD vs no-LLD, so all + channels in a single aspirate/dispense call must use the same mode category + (any LLD mode, or OFF). + """ + + OFF = 0 + CAPACITIVE = 1 # STARBackend.LLDMode.GAMMA — capacitive (cLLD) + PRESSURE = 2 # pressure-based (pLLD) + DUAL = 3 # both capacitive and pressure + + +@dataclass(frozen=True) +class _LldDefaults: + """Resolved pLLD / cLLD parameter pair (shared between aspirate and dispense).""" + + p_lld: PrepCmd.PLldParameters + c_lld: PrepCmd.CLldParameters + + +def default_lld_params( + effective_lld: bool, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, +) -> _LldDefaults: + """Build resolved pLLD / cLLD defaults. + + When LLD is active and no caller override is given, returns non-default + parameters (``default_values=False``) so the firmware actually triggers + detection. Otherwise returns firmware defaults. + """ + if effective_lld: + resolved_p = p_lld or PrepCmd.PLldParameters( + default_values=False, + sensitivity=1, + dispenser_seek_speed=0.0, + lld_height_difference=0.0, + detect_mode=0, + ) + resolved_c = c_lld or PrepCmd.CLldParameters( + default_values=False, + sensitivity=4, + clot_check_enable=False, + z_clot_check=0.0, + detect_mode=0, + ) + else: + resolved_p = p_lld or PrepCmd.PLldParameters.default() + resolved_c = c_lld or PrepCmd.CLldParameters.default() + return _LldDefaults(p_lld=resolved_p, c_lld=resolved_c) + + +def lld_for_well( + effective_lld: bool, lld: Optional[PrepCmd.LldParameters], top_of_well_z: float +) -> PrepCmd.LldParameters: + """Per-channel LLD seek parameters from caller override or well geometry.""" + if effective_lld and lld is None: + return PrepCmd.LldParameters( + default_values=False, + search_start_position=top_of_well_z, + channel_speed=5.0, + z_submerge=2.0, + z_out_of_liquid=0.0, + ) + return lld or PrepCmd.LldParameters.default() + + +def segments_to_cone_geometry( + segments: list[PrepCmd.SegmentDescriptor], fallback_radius: float +) -> Tuple[float, float, float]: + """Convert v2 frustum segments to v1 cone model (tube_radius, cone_height, cone_bottom_radius).""" + if not segments: + return (fallback_radius, 0.0, 0.0) + total_height = sum(s.height for s in segments) + if total_height <= 0: + return (fallback_radius, 0.0, 0.0) + weighted_area = sum(s.height * (s.area_top + s.area_bottom) / 2.0 for s in segments) + avg_area = weighted_area / total_height + tube_radius = math.sqrt(avg_area / math.pi) + bot = segments[0] + if abs(bot.area_bottom - bot.area_top) > 1e-6: + cone_height = bot.height + cone_bottom_radius = math.sqrt(bot.area_bottom / math.pi) + else: + cone_height = 0.0 + cone_bottom_radius = 0.0 + return (tube_radius, cone_height, cone_bottom_radius) + + +def patch_common_with_cone( + common: PrepCmd.CommonParameters, segments: list[PrepCmd.SegmentDescriptor] +) -> PrepCmd.CommonParameters: + """Return CommonParameters with cone geometry derived from segments (v2→v1 downgrade).""" + if len(segments) > 1: + logger.warning( + "v1 command selected: collapsing %d container segments into single cone approximation. " + "Liquid following accuracy may be reduced for complex container geometries.", + len(segments), + ) + tube_r, cone_h, cone_br = segments_to_cone_geometry(segments, common.tube_radius) + return PrepCmd.CommonParameters( + default_values=common.default_values, + empty=common.empty, + z_minimum=common.z_minimum, + z_final=common.z_final, + z_liquid_exit_speed=common.z_liquid_exit_speed, + liquid_volume=common.liquid_volume, + liquid_speed=common.liquid_speed, + transport_air_volume=common.transport_air_volume, + tube_radius=tube_r, + cone_height=cone_h, + cone_bottom_radius=cone_br, + settling_time=common.settling_time, + additional_probes=common.additional_probes, + ) + + +def resolve_command_version( + supports_v2: Optional[bool], + use_v1_flag: bool, + override: Optional[Literal["v1", "v2"]], + *, + v2_error_hint: str = "v2 commands are not supported by this firmware.", +) -> bool: + """Resolve whether to use v2 commands for a pipetting call. Returns True for v2. + + Resolution order: + 1. Per-call ``override`` ("v1" / "v2") — takes precedence. + 2. Backend-level ``use_v1_flag`` / ``supports_v2`` probe result from setup. + """ + if override == "v1": + return False + if override == "v2": + if supports_v2 is False: + raise ValueError(v2_error_hint) + return True + return supports_v2 is True + + +def lld_seek_timeout( + lld_params: PrepCmd.LldParameters, + z_minimum: float, +) -> Optional[float]: + """Compute a read timeout (s) for an LLD seek move, or None if not applicable.""" + if lld_params.channel_speed > 0: + speed: float = float(lld_params.channel_speed) + seek_distance: float = float(lld_params.search_start_position) - z_minimum + if seek_distance > 0: + return seek_distance / speed + 5.0 + return None + + +def _effective_radius(resource) -> float: + """Effective radius for PrepCmd.CommonParameters.tube_radius. + + For circular wells uses the actual radius; for rectangular wells computes the + radius of a circle with equivalent area so tube_radius is meaningful to the + firmware's conical liquid-following model. + """ + if isinstance(resource, Well) and resource.cross_section_type == CrossSectionType.RECTANGLE: + return float(math.sqrt(resource.get_size_x() * resource.get_size_y() / math.pi)) + return float(resource.get_size_x() / 2) + + +def _build_container_segments(resource: object) -> list[PrepCmd.SegmentDescriptor]: + """Derive PrepCmd.SegmentDescriptor list from a Well's geometry for liquid-following. + + Each segment is a frustum. The firmware uses area_bottom/area_top to + interpolate cross-sectional area A(z) within the segment and computes the + Z-axis following speed as dz/dt = Q / A(z), where Q is volumetric flow rate. + + Returns [] when geometry cannot be determined; the firmware then falls back to + the tube_radius / cone model in PrepCmd.CommonParameters. + """ + if not isinstance(resource, Well): + return [] + well: Well = resource + + size_z = well.get_size_z() + + if well.cross_section_type == CrossSectionType.CIRCLE: + area = math.pi * (well.get_size_x() / 2) ** 2 + elif well.cross_section_type == CrossSectionType.RECTANGLE: + area = well.get_size_x() * well.get_size_y() + else: + return [] + + if well.supports_compute_height_volume_functions(): + # Non-linear geometry: approximate with N frustum segments by sampling dV/dh. + n_boundaries = 11 # 10 segments + heights = [size_z * i / (n_boundaries - 1) for i in range(n_boundaries)] + eps = size_z / (n_boundaries - 1) * 0.1 + + def area_at(h: float) -> float: + h_lo = max(0.0, h - eps) + h_hi = min(size_z, h + eps) + dv = well.compute_volume_from_height(h_hi) - well.compute_volume_from_height(h_lo) + return float(dv / (h_hi - h_lo)) + + return [ + PrepCmd.SegmentDescriptor( + area_top=float(area_at(heights[i + 1])), + area_bottom=float(area_at(heights[i])), + height=float(heights[i + 1] - heights[i]), + ) + for i in range(n_boundaries - 1) + ] + + # Simple geometry: single segment with constant cross-section. + return [ + PrepCmd.SegmentDescriptor(area_top=float(area), area_bottom=float(area), height=float(size_z)) + ] + + +class _WellGeometry(NamedTuple): + """Absolute Z positions derived from well geometry.""" + + well_bottom: float + liquid_surface: float + top_of_well: float + z_air: float + + +def _absolute_z_from_well( + resource, + liquid_height: Optional[float] = None, + offset_z: float = 0.0, + z_air_margin_mm: float = 2.0, +) -> _WellGeometry: + """Compute absolute Z values from well/container geometry for aspirate/dispense. + + Args: + resource: Well or Container with get_size_z(). + liquid_height: Distance from well bottom to liquid surface (mm). None = 0. + offset_z: Additional Z applied to the bottom position (e.g. from op.offset.z). + z_air_margin_mm: Clearance above well opening for z_air (approach/exit height). + + Returns: + _WellGeometry with well_bottom, liquid_surface, top_of_well, z_air. + """ + if not hasattr(resource, "get_size_z"): + raise ValueError( + "Resource must have get_size_z() to derive absolute Z (e.g. a Well or Container). " + "Pass z_minimum, z_fluid, z_air explicitly for this operation." + ) + loc = resource.get_absolute_location("c", "c", "cavity_bottom") + well_bottom_z = loc.z + offset_z + liquid_surface_z = well_bottom_z + (liquid_height or 0.0) + top_of_well_z = loc.z + resource.get_size_z() + z_air_z = top_of_well_z + z_air_margin_mm + return _WellGeometry(well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z) + + +_CHANNEL_INDEX = { + 0: PrepCmd.ChannelIndex.RearChannel, + 1: PrepCmd.ChannelIndex.FrontChannel, +} + + +@dataclass(frozen=True) +class ChannelDriveMap: + """Cached channel-drive topology discovered from the firmware tree. + + One entry per discovered channel for the sleeve sensor (``Squeeze.SDrive``), + the Z drive (``ZAxis.ZDrive``), and the per-node ``NodeInformation`` object + (used for firmware-string queries). Lists are parallel and sorted by tree + traversal order (same order the firmware returns Channel Root instances). + """ + + sleeve_sensor_addrs: List[Address] + zdrive_addrs: List[Address] + node_info_addrs: List[Address] + + @property + def num_channels_discovered(self) -> int: + return len(self.sleeve_sensor_addrs) + + def to_dict(self) -> dict: + """Serialize for logs / notebooks that prefer plain dicts.""" + return { + "num_channels_discovered": self.num_channels_discovered, + "sleeve_sensor_addrs": list(self.sleeve_sensor_addrs), + "zdrive_addrs": list(self.zdrive_addrs), + "node_info_addrs": list(self.node_info_addrs), + } + + +# --------------------------------------------------------------------------- +# Firmware-tree discovery — module-level so it can be called independently of +# any PrepChannels instance (used when building channels in Prep.setup, plus by +# diagnostic notebooks that hold only a client). +# --------------------------------------------------------------------------- + + +async def _find_children_by_name( + intro, + parent_addr: Address, + *names: str, +) -> dict: + """Enumerate ``parent_addr``'s subobjects; return ``{name: Address}`` for matches. + + Bounded by ``subobject_count`` on the parent. Returns early once every + requested name has been found. Children that raise on ``get_object`` (e.g. + unknown firmware types) are skipped with a debug log. + """ + parent = await intro.get_object(parent_addr) + wanted = set(names) + found: dict = {} + for i in range(parent.subobject_count): + try: + sub_addr = await intro.get_subobject_address(parent_addr, i) + sub = await intro.get_object(sub_addr) + except Exception as e: + logger.debug("subobject[%d] of %s failed: %s", i, parent_addr, e) + continue + if sub.name in wanted: + found[sub.name] = sub_addr + if len(found) == len(wanted): + break + return found + + +async def discover_channel_drives( + client: "PrepClient", + *, + root_name: str = "Channel Root", +) -> ChannelDriveMap: + """Discover per-channel drive addresses via bounded subobject enumeration. + + MLPrepRoot exposes one ```` child per physical channel (siblings + with identical names, distinguished by the ``node`` component of their + :class:`Address`). For each one we walk: + + - ``.Channel.Squeeze.SDrive`` → sleeve sensor + - ``.Channel.ZAxis.ZDrive`` → Z drive + - ``.NodeInformation`` → per-channel firmware strings + + Uses ``get_subobject_address`` / ``get_object`` along the known path shape — + no full-tree traversal. Pass ``root_name="MPH Channel Root"`` for the 8MPH + head. For a full firmware-tree dump use + :meth:`PrepInstrumentInfo.get_firmware_tree`. + """ + intro = client.introspection + try: + mlprep_root = await client.resolve_path("MLPrepRoot") + root_info = await intro.get_object(mlprep_root) + except (KeyError, RuntimeError) as e: + logger.debug("MLPrepRoot unavailable (%s); skipping channel discovery", e) + return ChannelDriveMap(sleeve_sensor_addrs=[], zdrive_addrs=[], node_info_addrs=[]) + + channel_root_addrs: List[Address] = [] + for i in range(root_info.subobject_count): + try: + sub_addr = await intro.get_subobject_address(mlprep_root, i) + sub = await intro.get_object(sub_addr) + except Exception as e: + logger.debug("MLPrepRoot subobject[%d] failed: %s", i, e) + continue + if sub.name == root_name: + channel_root_addrs.append(sub_addr) + + sleeve: List[Address] = [] + zdrive: List[Address] = [] + node_info: List[Address] = [] + + for ch_root in channel_root_addrs: + top = await _find_children_by_name(intro, ch_root, "Channel", "NodeInformation") + if "NodeInformation" in top: + node_info.append(top["NodeInformation"]) + + channel_addr = top.get("Channel") + if channel_addr is None: + logger.warning("%s @ %s has no 'Channel' child", root_name, ch_root) + continue + + axes = await _find_children_by_name(intro, channel_addr, "Squeeze", "ZAxis") + if (sq_parent := axes.get("Squeeze")) is not None: + sq = await _find_children_by_name(intro, sq_parent, "SDrive") + if "SDrive" in sq: + sleeve.append(sq["SDrive"]) + if (zx_parent := axes.get("ZAxis")) is not None: + zx = await _find_children_by_name(intro, zx_parent, "ZDrive") + if "ZDrive" in zx: + zdrive.append(zx["ZDrive"]) + + logger.info("Discovered %d %s channel drive pair(s)", len(channel_root_addrs), root_name) + return ChannelDriveMap( + sleeve_sensor_addrs=sleeve, + zdrive_addrs=zdrive, + node_info_addrs=node_info, + ) + + +# --------------------------------------------------------------------------- +# Per-channel movement bounds — parses PipettorService.GetChannelBounds. +# --------------------------------------------------------------------------- + + +class PrepChannelBounds(TypedDict): + """Firmware-reported movement limits for one pipettor channel (mm).""" + + x_min: float + x_max: float + y_min: float + y_max: float + z_min: float + z_max: float + + +async def request_channel_bounds(client: "PrepClient") -> List[PrepChannelBounds]: + """Request per-channel movement bounds from the firmware (cmd=10). + + Returns one dict per channel (keys ``x_min``, ``x_max``, ``y_min``, ``y_max``, + ``z_min``, ``z_max`` in mm), ordered by channel index. Returns ``[]`` when + the service cannot be resolved or the response is empty. + + These are the firmware-enforced limits — positions outside these ranges will + be rejected with 0x0F04 (X), 0x0F05 (Y), or 0x0F06 (Z). Z bounds are for + empty channels; with a tip attached the effective Z minimum is higher. + """ + try: + raw = await client.send_query(PrepCmd.PrepGetChannelBounds()) + except RuntimeError: + return [] + if raw is None: + return [] + + # Parse per-channel bounds from raw response. + # Each channel block: channel_enum (u32 at 0x20), then 6× f32 (at 0x28): + # x_min, x_max, y_min, y_max, z_min, z_max + data = raw[0] + _CHANNEL_ENUM_TO_IDX = {v: k for k, v in _CHANNEL_INDEX.items()} + indexed: list[tuple[int, PrepChannelBounds]] = [] + + i = 0 + while i < len(data) - 20: + if data[i] == 0x20 and data[i + 1] == 0x00 and data[i + 2] == 0x04: + ch_val = _struct.unpack_from(" None: + self.index = index + self._client = client + self.sleeve_sensor = sleeve_sensor + self.zdrive = zdrive + self.node_info = node_info + self.bounds = bounds # x_min..z_max from firmware, or None if unavailable + + def __repr__(self) -> str: + return ( + f"PrepPIPChannel(index={self.index}, node_info={self.node_info!r}, " + f"bounds={'set' if self.bounds else 'unset'})" + ) + + async def request_firmware_version(self) -> Optional[str]: + """Per-channel firmware version string (NodeInformation cmd=8). + + Serial number is intentionally not exposed here — NodeInformation's + GetSerialNumber endpoint is unpopulated on shipped instruments, and the + canonical instrument serial (pipettor module) is already surfaced via + :meth:`PrepInstrumentInfo.get_device_serial_number`. + """ + if self.node_info is None: + return None + return await self._client._query_firmware_string(self.node_info, cmd_id=8, iface_id=1) + + +# --------------------------------------------------------------------------- +# Builder called from Prep.setup. +# --------------------------------------------------------------------------- + + +async def build_prep_channels( + client: "PrepClient", + info: "PrepInstrumentInfo", + *, + root_name: str = "Channel Root", + num_channels: Optional[int] = None, +) -> List[PrepPIPChannel]: + """Build per-channel facades, resolve drive addresses, fetch bounds. + + If ``num_channels`` is omitted, uses ``info.config.num_channels``. + """ + drive_map = await discover_channel_drives(client, root_name=root_name) + + if num_channels is None: + try: + num_channels = info.config.num_channels + except RuntimeError: + num_channels = None + if num_channels is None: + num_channels = drive_map.num_channels_discovered + + try: + bounds_list = await request_channel_bounds(client) + except Exception as e: + logger.warning("Failed to query channel bounds: %s", e) + bounds_list = [] + + def _drive_addr(attr: str, i: int) -> Optional[Address]: + if drive_map is None: + return None + seq = getattr(drive_map, attr) + return seq[i] if i < len(seq) else None + + channels: List[PrepPIPChannel] = [] + for i in range(num_channels): + channels.append( + PrepPIPChannel( + index=i, + client=client, + sleeve_sensor=_drive_addr("sleeve_sensor_addrs", i), + zdrive=_drive_addr("zdrive_addrs", i), + node_info=_drive_addr("node_info_addrs", i), + bounds=bounds_list[i] if i < len(bounds_list) else None, + ) + ) + return channels + + +# ============================================================================= +# PrepChannels — channel indices and deck routing +# ============================================================================= + + +def _build_pipettor_gantry_move_parameters( + x: float, + channels: List[int], + y: Union[float, List[float]], + z: Union[float, List[float]], +) -> PrepCmd.GantryMoveXYZParameters: + """Build :class:`~prep_commands.GantryMoveXYZParameters` for PipettorRoot move commands. + + Only ``FrontChannel`` and ``RearChannel`` may appear in ``axis_parameters``. MPH + gantry moves must use :class:`~prep_commands.MphMoveToPosition` instead. + """ + axis_parameters: List[PrepCmd.ChannelYZMoveParameters] = [] + for i, ch in enumerate(channels): + y_i = y[i] if isinstance(y, list) else y + z_i = z[i] if isinstance(z, list) else z + enum_ch = _CHANNEL_INDEX[ch] + if enum_ch not in ( + PrepCmd.ChannelIndex.FrontChannel, + PrepCmd.ChannelIndex.RearChannel, + ): + raise ValueError( + f"Pipettor gantry move does not support channel index {ch} (enum {enum_ch!r}). " + "MPH motion uses PrepHead8 / MphMoveToPosition on MLPrepRoot.MphRoot.MPH." + ) + axis_parameters.append( + PrepCmd.ChannelYZMoveParameters( + default_values=False, channel=enum_ch, y_position=y_i, z_position=z_i + ) + ) + return PrepCmd.GantryMoveXYZParameters( + default_values=False, gantry_x_position=x, axis_parameters=axis_parameters + ) + + +# Channel index -> deck waste resource name (PrepDeck: waste_rear, waste_front, waste_mph) +_CHANNEL_TO_WASTE_NAME = { + 0: "waste_rear", + 1: "waste_front", + 2: "waste_mph", +} + +# Expected root name from discovery; validated at setup(). +_EXPECTED_ROOT = "MLPrepRoot" + + +@dataclass(frozen=True) +class _AspirateChannelKit: + """Pre-resolved per-channel values for one aspirate channel. + + Computed once by ``_resolve_aspirate_channels``; the variant (LLD x monitoring + x v1/v2) only decides which fields get assembled into which wire dataclass. + """ + + channel: int + aspirate: PrepCmd.AspirateParameters + common: PrepCmd.CommonParameters + segments: list[PrepCmd.SegmentDescriptor] + no_lld: PrepCmd.NoLldParameters + lld: PrepCmd.LldParameters + p_lld: PrepCmd.PLldParameters + c_lld: PrepCmd.CLldParameters + monitoring: PrepCmd.AspirateMonitoringParameters + tadm: PrepCmd.TadmParameters + mix: PrepCmd.MixParameters + adc: PrepCmd.AdcParameters + + +@dataclass(frozen=True) +class _DispenseChannelKit: + """Pre-resolved per-channel values for one dispense channel.""" + + channel: int + dispense: PrepCmd.DispenseParameters + common: PrepCmd.CommonParameters + segments: list[PrepCmd.SegmentDescriptor] + no_lld: PrepCmd.NoLldParameters + lld: PrepCmd.LldParameters + c_lld: PrepCmd.CLldParameters + tadm: PrepCmd.TadmParameters + mix: PrepCmd.MixParameters + adc: PrepCmd.AdcParameters + + +@dataclass(frozen=True) +class _ChannelContext(Generic[_OpT]): + """Shared resolved state for aspirate/dispense channel resolution. + + Computed once by ``_resolve_channel_context``; operation-specific resolve + methods add their own parameters on top. + """ + + n: int + hlcs: List[Optional[HamiltonLiquidClass]] + disable_volume_correction: List[bool] + ch_to_idx: dict[int, int] + indexed_ops: dict[int, _OpT] + volumes: List[float] + well_geometry: List[_WellGeometry] + z_minimum: List[float] + z_fluid: List[float] + z_air: List[float] + z_final: List[float] + z_bottom_search_offset: List[float] + ch_segments: dict[int, list[PrepCmd.SegmentDescriptor]] + + +class PrepChannels: + """Dual-channel pipettor for Hamilton Prep. + + Narrow constructor: ``client`` (transport + JIT firmware-path resolve) and + ``info`` (instrument-wide metadata). ``self.channels`` is attached by + :meth:`Prep.setup` before :meth:`_on_setup`. + """ + + # V2 aspirate/dispense command IDs (interface 1 on Pipettor). + _V2_PIPETTING_CMD_IDS = {38, 39, 40, 41, 42, 43} + + def __init__( + self, + *, + client: "PrepClient", + info: "PrepInstrumentInfo", + deck: Optional["Deck"] = None, + default_traverse_height: Optional[float] = None, + use_v1_aspirate_dispense: bool = False, + ) -> None: + self._client = client + self._info = info + self.deck = deck + self._user_traverse_height: Optional[float] = default_traverse_height + self._channel_bounds: list[PrepChannelBounds] = [] + self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense + self._supports_v2_pipetting: Optional[bool] = None + self.setup_finished: bool = False + self.channels: List[PrepPIPChannel] = [] + self.head: dict[int, TipTracker] = {} + + def set_default_traverse_height(self, value: float) -> None: + """Set the default traverse height (mm) used when final_z is not passed to pick_up_tips/drop_tips. + + Use this when the instrument did not report a traverse height at setup, or to override + the probed value. + """ + self._user_traverse_height = value + + async def _probe_v2_support(self) -> bool: + """Probe the pipettor for v2 aspirate/dispense command support. + + Enumerates interface 1 method IDs on the pipettor object and checks whether + all v2 command IDs (38-43) are present. Returns False when the firmware only + exposes v1 commands (1-6). + """ + dest = await self._client.resolve_path(PIPETTOR_OBJECT_PATH) + methods = await self._client.introspection.methods_for_interface(dest, interface_id=1) + iface1_ids = {m.method_id for m in methods} + return self._V2_PIPETTING_CMD_IDS.issubset(iface1_ids) + + def _resolve_command_version(self, override: Optional[Literal["v1", "v2"]] = None) -> bool: + return resolve_command_version( + self._supports_v2_pipetting, + self._use_v1_aspirate_dispense, + override, + v2_error_hint=( + "v2 aspirate/dispense commands (cmd 38-43) are not supported by this firmware. " + "Use command_version='v1' or pass use_v1_aspirate_dispense=True to PrepChannels." + ), + ) + + # --------------------------------------------------------------------------- + # Setup + # --------------------------------------------------------------------------- + + async def _on_setup(self): + """Read config and probe pipettor capabilities. + + Called after ``self.channels`` is populated by :meth:`Prep.setup`. Instrument- + level initialization (``MLPrep.Initialize``) runs earlier in + :meth:`Prep.setup` — the pipettor sees an already-initialized instrument. + """ + cfg = self._info.config + logger.info( + "Hardware config: has_enclosure=%s, safe_speeds=%s, traverse_height=%s, " + "deck_bounds=%s, deck_sites=%d, waste_sites=%d, num_channels=%s, has_mph=%s", + cfg.has_enclosure, + cfg.safe_speeds_enabled, + cfg.default_traverse_height, + cfg.deck_bounds, + len(cfg.deck_sites), + len(cfg.waste_sites), + cfg.num_channels, + cfg.has_mph, + ) + + # Per-channel bounds are attached to ``self.channels`` by build_prep_channels. + # Keep a flat list too for legacy call sites that iterate _channel_bounds. + self._channel_bounds = [c.bounds for c in self.channels if c.bounds is not None] + if self._channel_bounds: + logger.info("Channel bounds: %s", self._channel_bounds) + else: + logger.warning("Channel bounds not available — move_to_position will skip validation") + + # Probe pipettor for v2 aspirate/dispense support (cmd 38-43). + if self._use_v1_aspirate_dispense: + self._supports_v2_pipetting = False + logger.info("V2 aspirate/dispense probe skipped (use_v1_aspirate_dispense=True)") + else: + try: + supported = await self._probe_v2_support() + except Exception as e: + logger.warning("PIP V2 support probe failed: %s", e) + supported = False + if not supported: + raise RuntimeError( + "V2 aspirate/dispense commands (cmd 38-43) are not supported by this firmware. " + "Pass use_v1_aspirate_dispense=True to PrepChannels to use v1 commands (cmd 1-6) instead." + ) + self._supports_v2_pipetting = True + logger.info("V2 aspirate/dispense support: True") + + self._ensure_head() + self.setup_finished = True + + async def _on_stop(self): + for tracker in self.head.values(): + tracker.clear() + + def _ensure_head(self) -> None: + """Ensure pipette-side TipTrackers exist for each dual-channel index.""" + for i in range(self.num_channels): + if i not in self.head: + self.head[i] = TipTracker(thing=f"Channel {i}") + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Tips currently mounted on the dual-channel head (``None`` if empty).""" + self._ensure_head() + return [ + self.head[i].get_tip() if self.head[i].has_tip else None for i in range(self.num_channels) + ] + + async def discover_channel_drives(self) -> ChannelDriveMap: + """Re-walk the firmware tree and return a fresh :class:`ChannelDriveMap`. + + Diagnostic helper — channel drive addresses for normal operation are already + cached on each :attr:`channels` entry at build time. + """ + return await discover_channel_drives(self._client, root_name="Channel Root") + + # --------------------------------------------------------------------------- + # Properties + # --------------------------------------------------------------------------- + + @property + def num_channels(self) -> int: + """Number of independent dual-channel pipettor channels (1 or 2). Read from info.config.""" + n: Optional[int] = self._info.config.num_channels + if n is None: + raise RuntimeError("Instrument config has no num_channels (finish Prep.setup first).") + return n + + @property + def has_mph(self) -> bool: + """True if the 8-channel Multi-Pipetting Head (8MPH) is present. Read from info.config.""" + try: + return bool(self._info.config.has_mph) + except RuntimeError: + return False + + @property + def num_arms(self) -> int: + """Number of resource-handling arms. 1 when deck has core_grippers and 2 channels, else 0.""" + if self.deck is None: + return 0 + try: + cfg = self._info.config + except RuntimeError: + return 0 + if cfg.num_channels != 2: + return 0 + try: + mount = self.deck.get_resource("core_grippers") + return 1 if isinstance(mount, HamiltonCoreGrippers) else 0 + except Exception: + return 0 + + def _resolve_traverse_height(self, final_z: Optional[float] = None) -> float: + """Resolve final_z: explicit arg > user-set default > probed value. Raises if none available.""" + if final_z is not None: + return final_z + if self._user_traverse_height is not None: + return self._user_traverse_height + try: + cfg = self._info.config + except RuntimeError: + height: Optional[float] = None + else: + height = cfg.default_traverse_height + if height is not None: + return height + raise RuntimeError( + "Default traverse height is required for this operation but could not be determined. " + "Either pass final_z explicitly to this call, or set it via " + "PrepChannels(..., default_traverse_height=) or set_default_traverse_height(). " + "If the instrument supports it, the value is also probed during setup(); ensure setup() completed successfully." + ) from None + + # --------------------------------------------------------------------------- + # Tip / aspirate / dispense API + # --------------------------------------------------------------------------- + + def _require_mounted_tips(self, use_channels: List[int]) -> List[Tip]: + self._ensure_head() + tips: List[Tip] = [] + for ch in use_channels: + tracker = self.head[ch] + if not tracker.has_tip: + raise RuntimeError(f"No tip mounted on channel {ch}; call pick_up_tips first.") + tips.append(tracker.get_tip()) + return tips + + async def _finalize_channel_command( + self, + use_channels: Sequence[int], + *, + tip_intents: Optional[Sequence[Union[TipPickupIntent, TipDropIntent]]] = None, + volume_intents: Optional[Sequence[VolumeTransferIntent]] = None, + send: Callable[[], Awaitable[None]], + ) -> None: + """Send a Prep command and commit/rollback queued tip or volume intents.""" + error: Optional[BaseException] = None + try: + await send() + successes = all_channels_succeeded(use_channels) + except ChannelizedError as e: + error = e + successes = successes_from_failed_channels(use_channels, e.errors) + except BaseException as e: + error = e + successes = {ch: False for ch in use_channels} + if tip_intents is not None: + finalize_tip_ops(tip_intents, successes) + if volume_intents is not None: + finalize_volume_ops(volume_intents, successes) + if error is not None: + raise error + + async def pick_up_tips( + self, + tip_spots: Sequence[TipSpot], + use_channels: Optional[List[int]] = None, + *, + offsets: Optional[Sequence[Coordinate]] = None, + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + enable_tadm: bool = False, + dispenser_volume: float = 0.0, + dispenser_speed: float = 250.0, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + pre_position: bool = True, + ): + """Pick up tips from tip spots. + + The arm moves to z_seek during lateral XY approach, then descends to z_position + to engage the tip. Default z_seek = z_position + fitting_depth + 5mm (tip-type- + aware; avoids descending into the rack during approach). + """ + tip_spots = list(tip_spots) + use_channels = use_channels if use_channels is not None else list(range(len(tip_spots))) + if len(tip_spots) != len(use_channels): + raise ValueError( + f"len(tip_spots) must equal len(use_channels): {len(tip_spots)} != {len(use_channels)}" + ) + if use_channels: + assert max(use_channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) + offsets_list = list(offsets) if offsets is not None else [Coordinate.zero()] * len(tip_spots) + if len(offsets_list) != len(tip_spots): + raise ValueError("len(offsets) must equal len(tip_spots)") + + tips = [spot.get_tip() for spot in tip_spots] + resolved_final_z = self._resolve_traverse_height(final_z) + + indexed = { + ch: (spot, tip, off) + for ch, spot, tip, off in zip(use_channels, tip_spots, tips, offsets_list) + } + tip_positions: List[PrepCmd.TipPositionParameters] = [] + for ch in range(self.num_channels): + if ch not in indexed: + continue + spot, tip, off = indexed[ch] + loc = spot.get_absolute_location("c", "c", "t") + off + tip_positions.append( + PrepCmd.TipPositionParameters.for_op( + _CHANNEL_INDEX[ch], loc, tip, z_seek_offset=z_seek_offset + ) + ) + + tip0 = tips[0] + if any( + t.maximal_volume != tip0.maximal_volume + or t.has_filter != tip0.has_filter + or (t.total_tip_length - t.fitting_depth) != (tip0.total_tip_length - tip0.fitting_depth) + for t in tips + ): + raise ValueError("All tip spots must use the same tip type") + tip_definition = PrepCmd.TipPickupParameters( + default_values=False, + volume=tip0.maximal_volume, + length=tip0.total_tip_length - tip0.fitting_depth, + tip_type=PrepCmd.TipTypes.StandardVolume, + has_filter=tip0.has_filter, + is_needle=False, + is_tool=False, + ) + + if pre_position: + traverse_h = minimum_traverse_height_at_beginning_of_a_command or resolved_final_z + locs = [ + indexed[ch][0].get_absolute_location("c", "c", "t") + indexed[ch][2] for ch in use_channels + ] + await self.move_to_position( + x=locs[0].x, + y=[loc.y for loc in locs], + z=traverse_h, + use_channels=use_channels, + ) + + self._ensure_head() + tip_intents = [ + TipPickupIntent( + channel=ch, + tip_spot=spot, + tip=tip, + channel_tracker=self.head[ch], + ) + for ch, spot, tip in zip(use_channels, tip_spots, tips) + ] + queue_tip_pickups(tip_intents) + + async def _send() -> None: + await self._client.send_command( + PrepCmd.PrepPickUpTips( + tip_positions=tip_positions, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_definition=tip_definition, + enable_tadm=enable_tadm, + dispenser_volume=dispenser_volume, + dispenser_speed=dispenser_speed, + ) + ) + + await self._finalize_channel_command(use_channels, tip_intents=tip_intents, send=_send) + + async def drop_tips( + self, + destinations: Sequence[Union[TipSpot, Trash]], + use_channels: Optional[List[int]] = None, + *, + offsets: Optional[Sequence[Coordinate]] = None, + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + drop_type: PrepCmd.TipDropType = PrepCmd.TipDropType.FixedHeight, + tip_roll_off_distance: float = 0.0, + ): + """Drop tips to tip spots or trash. + + The arm moves to z_seek during lateral XY approach (tip is on pipette, so tip + bottom is at z_seek - (total_tip_length - fitting_depth)). z_position uses + fitting depth so the tip bottom lands at the spot surface; default z_seek = + z_position + 10mm so the tip bottom stays above adjacent tips in the rack. + """ + destinations = list(destinations) + use_channels = use_channels if use_channels is not None else list(range(len(destinations))) + if len(destinations) != len(use_channels): + raise ValueError( + f"len(destinations) must equal len(use_channels): " + f"{len(destinations)} != {len(use_channels)}" + ) + if use_channels: + assert max(use_channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) + tips = self._require_mounted_tips(use_channels) + offsets_list = list(offsets) if offsets is not None else [Coordinate.zero()] * len(destinations) + if len(offsets_list) != len(destinations): + raise ValueError("len(offsets) must equal len(destinations)") + + all_trash = all(isinstance(d, Trash) for d in destinations) + all_tip_spots = all(isinstance(d, TipSpot) for d in destinations) + if not (all_trash or all_tip_spots): + raise ValueError("Cannot mix waste (Trash) and tip spots in a single drop_tips call.") + + resolved_final_z = self._resolve_traverse_height(final_z) + roll_off = 3.0 if (all_trash and tip_roll_off_distance == 0.0) else tip_roll_off_distance + resolved_drop_type = PrepCmd.TipDropType.Stall if all_trash else drop_type + + indexed = { + ch: (dest, tip, off) + for ch, dest, tip, off in zip(use_channels, destinations, tips, offsets_list) + } + tip_positions: List[PrepCmd.TipDropParameters] = [] + for ch in range(self.num_channels): + if ch not in indexed: + continue + dest, tip, off = indexed[ch] + if all_trash: + if self.deck is None: + raise ValueError( + "Cannot drop tips to waste: backend has no deck (assign a deck before drop_tips)." + ) + waste_name = _CHANNEL_TO_WASTE_NAME.get(ch, "waste_mph") + if not self.deck.has_resource(waste_name): + raise ValueError( + f"Cannot drop tips to waste: deck has no waste position '{waste_name}'. " + "Use a deck with waste_rear, waste_front (and waste_mph if using MPH)." + ) + loc = self.deck.get_resource(waste_name).get_absolute_location("c", "c", "t") + else: + loc = dest.get_absolute_location("c", "c", "t") + off + tip_positions.append( + PrepCmd.TipDropParameters.for_op( + _CHANNEL_INDEX[ch], loc, tip, z_seek_offset=z_seek_offset, drop_type=resolved_drop_type + ) + ) + + tip_intents = [ + TipDropIntent( + channel=ch, + destination=dest, + tip=tip, + channel_tracker=self.head[ch], + ) + for ch, dest, tip in zip(use_channels, destinations, tips) + ] + queue_tip_drops(tip_intents) + + async def _send() -> None: + await self._client.send_command( + PrepCmd.PrepDropTips( + tip_positions=tip_positions, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=roll_off, + ) + ) + + await self._finalize_channel_command(use_channels, tip_intents=tip_intents, send=_send) + + # --------------------------------------------------------------------------- + # V1/V2 aspirate/dispense dispatch helpers + # --------------------------------------------------------------------------- + + @staticmethod + def _patch_common_with_cone( + common: PrepCmd.CommonParameters, segments: list[PrepCmd.SegmentDescriptor] + ) -> PrepCmd.CommonParameters: + return patch_common_with_cone(common, segments) + + # --------------------------------------------------------------------------- + # Shared LLD / TADM resolution helpers + # --------------------------------------------------------------------------- + + def _resolve_effective_lld( + self, + lld_mode: Optional[List[LLDMode]], + lld: Optional[PrepCmd.LldParameters], + n: int, + *, + allowed_modes: Optional[frozenset[LLDMode]] = None, + ) -> bool: + """Determine whether LLD is active for this pipetting call. + + Validates ``lld_mode`` length, rejects disallowed modes (e.g. PRESSURE for + dispense), enforces all-or-nothing across channels, and returns a single bool. + Falls back to ``lld`` presence when ``lld_mode`` is None. + """ + if lld_mode is not None: + if len(lld_mode) != n: + raise ValueError(f"lld_mode length must match len(ops): {len(lld_mode)} != {n}") + if allowed_modes is not None: + for m in lld_mode: + if m != LLDMode.OFF and m not in allowed_modes: + raise ValueError( + f"Dispense does not support {m.name} LLD — only CAPACITIVE or OFF. " + "Pressure-based LLD requires aspiration (plunger movement)." + ) + lld_on = [m != LLDMode.OFF for m in lld_mode] + if any(lld_on) and not all(lld_on): + raise ValueError( + "Prep firmware requires all channels to use the same LLD mode category. " + "Cannot mix LLDMode.OFF with CAPACITIVE/PRESSURE/DUAL in one call. " + "Split into separate calls for channels with different LLD modes." + ) + return all(lld_on) + return lld is not None + + @staticmethod + def _default_lld_params( + effective_lld: bool, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + ) -> _LldDefaults: + return default_lld_params(effective_lld, p_lld, c_lld) + + @staticmethod + def _lld_for_well( + effective_lld: bool, lld: Optional[PrepCmd.LldParameters], top_of_well_z: float + ) -> PrepCmd.LldParameters: + return lld_for_well(effective_lld, lld, top_of_well_z) + + # --------------------------------------------------------------------------- + # Shared channel resolution + # --------------------------------------------------------------------------- + + def _resolve_channel_context( + self, + ops: Sequence[_OpT], + use_channels: List[int], + *, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + ) -> _ChannelContext[_OpT]: + """Resolve shared per-channel state for aspirate or dispense. + + Validates inputs, resolves HLCs, computes volume corrections, well geometry, + z-parameter defaults, and container segments. Operation-specific defaults + (settling_time, flow_rate, etc.) are left to the caller. + """ + if len(ops) != len(use_channels): + raise ValueError(f"len(ops) must equal len(use_channels): {len(ops)} != {len(use_channels)}") + if use_channels and max(use_channels) >= self.num_channels: + raise ValueError(f"use_channels index out of range (valid: 0..{self.num_channels - 1})") + + n = len(ops) + if hamilton_liquid_classes is not None and len(hamilton_liquid_classes) != n: + raise ValueError( + f"hamilton_liquid_classes length must match len(ops): {len(hamilton_liquid_classes)} != {n}" + ) + hlcs = resolve_hamilton_liquid_classes( + list(hamilton_liquid_classes) if hamilton_liquid_classes is not None else None, + list(ops), + jet=False, + blow_out=False, + ) + dvc = disable_volume_correction if disable_volume_correction is not None else [False] * n + if len(dvc) != n: + raise ValueError(f"disable_volume_correction length must match len(ops): {len(dvc)} != {n}") + ch_to_idx = {ch: i for i, ch in enumerate(use_channels)} + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + + volumes = corrected_volumes_for_ops(ops, hlcs, dvc) + + well_geometry = [ + _absolute_z_from_well(op.resource, op.liquid_height, op.offset.z) for op in ops + ] + raw_traverse = self._resolve_traverse_height(None) + z_minimum = fill_in_defaults(z_minimum, [g.well_bottom for g in well_geometry]) + z_fluid = fill_in_defaults(z_fluid, [g.liquid_surface for g in well_geometry]) + z_air = fill_in_defaults(z_air, [g.z_air for g in well_geometry]) + z_final = fill_in_defaults( + z_final, [raw_traverse - (op.tip.total_tip_length - op.tip.fitting_depth) for op in ops] + ) + z_bottom_search_offset = fill_in_defaults(z_bottom_search_offset, [2.0] * n) + + ch_segments: dict[int, list[PrepCmd.SegmentDescriptor]] = {} + for i, ch in enumerate(use_channels): + if container_segments is not None and i < len(container_segments): + ch_segments[ch] = container_segments[i] + elif auto_container_geometry: + ch_segments[ch] = _build_container_segments(indexed_ops[ch].resource) + else: + ch_segments[ch] = [] + + return _ChannelContext( + n=n, + hlcs=hlcs, + disable_volume_correction=dvc, + ch_to_idx=ch_to_idx, + indexed_ops=indexed_ops, + volumes=volumes, + well_geometry=well_geometry, + z_minimum=z_minimum, + z_fluid=z_fluid, + z_air=z_air, + z_final=z_final, + z_bottom_search_offset=z_bottom_search_offset, + ch_segments=ch_segments, + ) + + # --------------------------------------------------------------------------- + # Aspirate: resolve, assemble, send + # --------------------------------------------------------------------------- + + def _resolve_aspirate_channels( + self, + ops: List[_PipetteTransfer], + use_channels: List[int], + effective_lld: bool, + *, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + z_liquid_exit_speed: Optional[List[float]] = None, + prewet_volume: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + lld: Optional[PrepCmd.LldParameters] = None, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + tadm: Optional[PrepCmd.TadmParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + ) -> list[_AspirateChannelKit]: + """Resolve all per-channel values for aspirate (pure computation, no I/O).""" + ctx = self._resolve_channel_context( + ops, + use_channels, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + # Aspirate-specific HLC defaults + hlcs = ctx.hlcs + settling_time = fill_in_defaults( + settling_time, [hlc.aspiration_settling_time if hlc is not None else 1.0 for hlc in hlcs] + ) + transport_air_volume = fill_in_defaults( + transport_air_volume, + [hlc.aspiration_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + z_liquid_exit_speed = fill_in_defaults( + z_liquid_exit_speed, [hlc.aspiration_swap_speed if hlc is not None else 10.0 for hlc in hlcs] + ) + prewet_volume = fill_in_defaults( + prewet_volume, + [hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + flow_rates = [ + op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100.0) + for op, hlc in zip(ops, hlcs) + ] + blowout_volumes = [ + op.blow_out_air_volume or (hlc.aspiration_blow_out_volume if hlc is not None else 0.0) + for op, hlc in zip(ops, hlcs) + ] + + lld_defaults = self._default_lld_params(effective_lld, p_lld, c_lld) + _tadm = tadm or PrepCmd.TadmParameters.default() + + kits: list[_AspirateChannelKit] = [] + for ch in range(self.num_channels): + if ch not in ctx.indexed_ops: + continue + idx = ctx.ch_to_idx[ch] + asp = ctx.indexed_ops[ch] + loc = asp.resource.get_absolute_location("c", "c", "cavity_bottom") + radius = _effective_radius(asp.resource) + + kits.append( + _AspirateChannelKit( + channel=_CHANNEL_INDEX[ch], + aspirate=PrepCmd.AspirateParameters.from_location( + loc, prewet_volume=prewet_volume[idx], blowout_volume=blowout_volumes[idx] + ), + common=PrepCmd.CommonParameters.for_op( + ctx.volumes[idx], + radius, + flow_rate=flow_rates[idx], + z_minimum=ctx.z_minimum[idx], + z_final=ctx.z_final[idx], + z_liquid_exit_speed=z_liquid_exit_speed[idx], + transport_air_volume=transport_air_volume[idx], + settling_time=settling_time[idx], + ), + segments=ctx.ch_segments[ch], + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + ctx.z_fluid[idx], ctx.z_air[idx], z_bottom_search_offset=ctx.z_bottom_search_offset[idx] + ), + lld=self._lld_for_well(effective_lld, lld, ctx.well_geometry[idx].top_of_well), + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + monitoring=PrepCmd.AspirateMonitoringParameters.default(), + tadm=_tadm, + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + ) + ) + return kits + + @staticmethod + def _assemble_aspirate_v2( + kit: _AspirateChannelKit, effective_lld: bool, is_tadm: bool + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm2, + PrepCmd.AspirateParametersLldAndMonitoring2, + PrepCmd.AspirateParametersNoLldAndTadm2, + PrepCmd.AspirateParametersNoLldAndMonitoring2, + ]: + """Assemble a v2 aspirate parameter struct from pre-resolved kit values.""" + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + tadm=kit.tadm, + adc=kit.adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + aspirate_monitoring=kit.monitoring, + adc=kit.adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + aspirate_monitoring=kit.monitoring, + ) + + def _assemble_aspirate_v1( + self, kit: _AspirateChannelKit, effective_lld: bool, is_tadm: bool + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm, + PrepCmd.AspirateParametersLldAndMonitoring, + PrepCmd.AspirateParametersNoLldAndTadm, + PrepCmd.AspirateParametersNoLldAndMonitoring, + ]: + """Assemble a v1 aspirate parameter struct (cone-patched, no segments).""" + patched = self._patch_common_with_cone(kit.common, kit.segments) + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + tadm=kit.tadm, + adc=kit.adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + aspirate_monitoring=kit.monitoring, + adc=kit.adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + aspirate_monitoring=kit.monitoring, + ) + + # Command dispatch tables: (effective_lld, is_tadm, use_v2) → command class + _ASPIRATE_CMD = { + (True, True, True): PrepCmd.PrepAspirateWithLldTadmV2, + (True, True, False): PrepCmd.PrepAspirateWithLldTadm, + (True, False, True): PrepCmd.PrepAspirateWithLldV2, + (True, False, False): PrepCmd.PrepAspirateWithLld, + (False, True, True): PrepCmd.PrepAspirateTadmV2, + (False, True, False): PrepCmd.PrepAspirateTadm, + (False, False, True): PrepCmd.PrepAspirateNoLldMonitoringV2, + (False, False, False): PrepCmd.PrepAspirateNoLldMonitoring, + } + + async def _send_aspirate( + self, + kits: list[_AspirateChannelKit], + effective_lld: bool, + is_tadm: bool, + use_v2: bool, + read_timeout: Optional[float] = None, + ) -> None: + """Assemble the correct param types and send the aspirate command.""" + cmd_cls = self._ASPIRATE_CMD[(effective_lld, is_tadm, use_v2)] + assembler = self._assemble_aspirate_v2 if use_v2 else self._assemble_aspirate_v1 + params = [assembler(k, effective_lld, is_tadm) for k in kits] + await self._client.send_command( + cmd_cls(aspirate_parameters=params), # type: ignore[arg-type] + read_timeout=read_timeout if effective_lld else None, + ) + + # --------------------------------------------------------------------------- + # Dispense: resolve, assemble, send + # --------------------------------------------------------------------------- + + def _resolve_dispense_channels( + self, + ops: List[_PipetteTransfer], + use_channels: List[int], + effective_lld: bool, + *, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + z_liquid_exit_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + cutoff_speed: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + ) -> list[_DispenseChannelKit]: + """Resolve all per-channel values for dispense (pure computation, no I/O).""" + ctx = self._resolve_channel_context( + ops, + use_channels, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + # Dispense-specific HLC defaults + hlcs = ctx.hlcs + settling_time = fill_in_defaults( + settling_time, [hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hlcs] + ) + transport_air_volume = fill_in_defaults( + transport_air_volume, + [hlc.dispense_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + z_liquid_exit_speed = fill_in_defaults( + z_liquid_exit_speed, [hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hlcs] + ) + stop_back_volume = fill_in_defaults( + stop_back_volume, [hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hlcs] + ) + cutoff_speed = fill_in_defaults( + cutoff_speed, [hlc.dispense_stop_flow_rate if hlc is not None else 100.0 for hlc in hlcs] + ) + flow_rates = [ + op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 100.0) + for op, hlc in zip(ops, hlcs) + ] + + lld_defaults = self._default_lld_params(effective_lld, c_lld=c_lld) + + kits: list[_DispenseChannelKit] = [] + for ch in range(self.num_channels): + if ch not in ctx.indexed_ops: + continue + idx = ctx.ch_to_idx[ch] + op = ctx.indexed_ops[ch] + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + radius = _effective_radius(op.resource) + + kits.append( + _DispenseChannelKit( + channel=_CHANNEL_INDEX[ch], + dispense=PrepCmd.DispenseParameters.for_op( + loc, stop_back_volume=stop_back_volume[idx], cutoff_speed=cutoff_speed[idx] + ), + common=PrepCmd.CommonParameters.for_op( + ctx.volumes[idx], + radius, + flow_rate=flow_rates[idx], + z_minimum=ctx.z_minimum[idx], + z_final=ctx.z_final[idx], + z_liquid_exit_speed=z_liquid_exit_speed[idx], + transport_air_volume=transport_air_volume[idx], + settling_time=settling_time[idx], + ), + segments=ctx.ch_segments[ch], + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + ctx.z_fluid[idx], ctx.z_air[idx], z_bottom_search_offset=ctx.z_bottom_search_offset[idx] + ), + lld=self._lld_for_well(effective_lld, lld, ctx.well_geometry[idx].top_of_well), + c_lld=lld_defaults.c_lld, + tadm=PrepCmd.TadmParameters.default(), + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + ) + ) + return kits + + @staticmethod + def _assemble_dispense_v2( + kit: _DispenseChannelKit, effective_lld: bool + ) -> Union[PrepCmd.DispenseParametersLld2, PrepCmd.DispenseParametersNoLld2]: + """Assemble a v2 dispense parameter struct from pre-resolved kit values.""" + if effective_lld: + return PrepCmd.DispenseParametersLld2( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + container_description=kit.segments, + common=kit.common, + lld=kit.lld, + c_lld=kit.c_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld2( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + container_description=kit.segments, + common=kit.common, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + + def _assemble_dispense_v1( + self, kit: _DispenseChannelKit, effective_lld: bool + ) -> Union[PrepCmd.DispenseParametersLld, PrepCmd.DispenseParametersNoLld]: + """Assemble a v1 dispense parameter struct (cone-patched, no segments).""" + patched = self._patch_common_with_cone(kit.common, kit.segments) + if effective_lld: + return PrepCmd.DispenseParametersLld( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + common=patched, + lld=kit.lld, + c_lld=kit.c_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + common=patched, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + + # Command dispatch table: (effective_lld, use_v2) → command class + _DISPENSE_CMD = { + (True, True): PrepCmd.PrepDispenseWithLldV2, + (True, False): PrepCmd.PrepDispenseWithLld, + (False, True): PrepCmd.PrepDispenseNoLldV2, + (False, False): PrepCmd.PrepDispenseNoLld, + } + + async def _send_dispense( + self, + kits: list[_DispenseChannelKit], + effective_lld: bool, + use_v2: bool, + read_timeout: Optional[float] = None, + ) -> None: + """Assemble the correct param types and send the dispense command.""" + cmd_cls = self._DISPENSE_CMD[(effective_lld, use_v2)] + assembler = self._assemble_dispense_v2 if use_v2 else self._assemble_dispense_v1 + params = [assembler(k, effective_lld) for k in kits] + await self._client.send_command( + cmd_cls(dispense_parameters=params), # type: ignore[arg-type] + read_timeout=read_timeout if effective_lld else None, + ) + + # --------------------------------------------------------------------------- + # Public aspirate / dispense orchestrators + # --------------------------------------------------------------------------- + + def _build_transfers( + self, + resources: Sequence[Container], + vols: Sequence[float], + use_channels: List[int], + *, + offsets: Optional[Sequence[Coordinate]] = None, + liquid_height: Optional[Sequence[Optional[float]]] = None, + flow_rates: Optional[Sequence[Optional[float]]] = None, + blow_out_air_volume: Optional[Sequence[Optional[float]]] = None, + ) -> List[_PipetteTransfer]: + resources = list(resources) + vols = [float(v) for v in vols] + if len(resources) != len(use_channels) or len(vols) != len(use_channels): + raise ValueError("resources, vols, and use_channels must have the same length") + tips = self._require_mounted_tips(use_channels) + n = len(use_channels) + offs = list(offsets) if offsets is not None else [Coordinate.zero()] * n + lhs = list(liquid_height) if liquid_height is not None else [None] * n + frs = list(flow_rates) if flow_rates is not None else [None] * n + bavs = list(blow_out_air_volume) if blow_out_air_volume is not None else [None] * n + for name, seq in ( + ("offsets", offs), + ("liquid_height", lhs), + ("flow_rates", frs), + ("blow_out_air_volume", bavs), + ): + if len(seq) != n: + raise ValueError(f"{name} length must match use_channels ({n})") + return [ + _PipetteTransfer( + resource=r, + tip=t, + volume=v, + offset=o, + liquid_height=lh, + flow_rate=fr, + blow_out_air_volume=bav, + ) + for r, t, v, o, lh, fr, bav in zip(resources, tips, vols, offs, lhs, frs, bavs) + ] + + async def aspirate( + self, + resources: Sequence[Container], + vols: Sequence[float], + use_channels: Optional[List[int]] = None, + *, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + z_liquid_exit_speed: Optional[List[float]] = None, + prewet_volume: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + lld_mode: Optional[List[Any]] = None, + lld: Optional[PrepCmd.LldParameters] = None, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + tadm: Optional[PrepCmd.TadmParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ): + """Aspirate from containers using mounted tips. + + Explicit kwargs override Hamilton liquid-class defaults; HLC supplies + unspecified fields and the volume correction curve unless disabled. + """ + resources = list(resources) + use_channels = use_channels if use_channels is not None else list(range(len(resources))) + ops = self._build_transfers( + resources, + vols, + use_channels, + offsets=offsets, + liquid_height=liquid_height, + flow_rates=flow_rates, + blow_out_air_volume=blow_out_air_volume, + ) + effective_lld = self._resolve_effective_lld(lld_mode, lld, len(ops)) + is_tadm = tadm is not None + use_v2 = self._resolve_command_version(command_version) + + kits = self._resolve_aspirate_channels( + ops, + use_channels, + effective_lld, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + settling_time=settling_time, + transport_air_volume=transport_air_volume, + z_liquid_exit_speed=z_liquid_exit_speed, + prewet_volume=prewet_volume, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + lld=lld, + p_lld=p_lld, + c_lld=c_lld, + tadm=tadm, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + lld_read_timeout = read_timeout + if lld_read_timeout is None and effective_lld and kits: + min_z_min = min(k.common.z_minimum for k in kits) + lld_read_timeout = lld_seek_timeout(kits[0].lld, min_z_min) + + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=op.resource, + tip=op.tip, + volume_ul=next(k.common.liquid_volume for k in kits if k.channel == _CHANNEL_INDEX[ch]), + direction="aspirate", + ) + for ch, op in zip(use_channels, ops) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._send_aspirate(kits, effective_lld, is_tadm, use_v2, lld_read_timeout) + + await self._finalize_channel_command(use_channels, volume_intents=volume_intents, send=_send) + + async def dispense( + self, + resources: Sequence[Container], + vols: Sequence[float], + use_channels: Optional[List[int]] = None, + *, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + z_liquid_exit_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + cutoff_speed: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + lld_mode: Optional[List[Any]] = None, + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ): + """Dispense to containers using mounted tips. + + Explicit kwargs override Hamilton liquid-class defaults; HLC supplies + unspecified fields and the volume correction curve unless disabled. + """ + resources = list(resources) + use_channels = use_channels if use_channels is not None else list(range(len(resources))) + ops = self._build_transfers( + resources, + vols, + use_channels, + offsets=offsets, + liquid_height=liquid_height, + flow_rates=flow_rates, + blow_out_air_volume=blow_out_air_volume, + ) + _DISPENSE_ALLOWED_LLD = frozenset({LLDMode.CAPACITIVE}) + effective_lld = self._resolve_effective_lld( + lld_mode, lld, len(ops), allowed_modes=_DISPENSE_ALLOWED_LLD + ) + use_v2 = self._resolve_command_version(command_version) + + kits = self._resolve_dispense_channels( + ops, + use_channels, + effective_lld, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + settling_time=settling_time, + transport_air_volume=transport_air_volume, + z_liquid_exit_speed=z_liquid_exit_speed, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + lld=lld, + c_lld=c_lld, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + lld_read_timeout = read_timeout + if lld_read_timeout is None and effective_lld and kits: + min_z_min = min(k.common.z_minimum for k in kits) + lld_read_timeout = lld_seek_timeout(kits[0].lld, min_z_min) + + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=op.resource, + tip=op.tip, + volume_ul=next(k.common.liquid_volume for k in kits if k.channel == _CHANNEL_INDEX[ch]), + direction="dispense", + ) + for ch, op in zip(use_channels, ops) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._send_dispense(kits, effective_lld, use_v2, lld_read_timeout) + + await self._finalize_channel_command(use_channels, volume_intents=volume_intents, send=_send) + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + """Check if the tip can be picked up by the specified channel. + + Uses the same logic as Nimbus/STAR: only Hamilton tips, no XL tips, + and channel index must be valid. + """ + if not isinstance(tip, HamiltonTip): + return False + if tip.tip_size in {TipSize.XL}: + return False + try: + n = self._info.config.num_channels + except RuntimeError: + n = None + if n is not None and channel_idx >= n: + return False + return True + + # --------------------------------------------------------------------------- + # Firmware version queries (per-channel; box-level queries live on PrepClient) + # --------------------------------------------------------------------------- + + async def request_pip_channel_version(self, channel: int) -> Optional[str]: + """Firmware version string for pipettor channel (0=rearmost).""" + if channel >= len(self.channels): + return None + return await self.channels[channel].request_firmware_version() + + # --------------------------------------------------------------------------- + # Channel position queries + # --------------------------------------------------------------------------- + + async def request_channel_bounds(self) -> list[PrepChannelBounds]: + """Per-channel movement bounds (PipettorService.GetChannelBounds). + + Thin delegation to :func:`request_channel_bounds`. + Prefer reading cached values via ``self.channels[i].bounds``; use this when a + fresh re-query is required. + """ + return await request_channel_bounds(self._client) + + async def request_channel_positions(self) -> list[Coordinate]: + """Request the current XYZ positions of all pipettor channels. + + Queries Pipettor.GetPositions (cmd=25). Returns one Coordinate per channel, + ordered by channel index (0=rearmost). + + Uses the typed PrepGetPositions command with ChannelXYZPositionParameters + response struct for reliable parsing across firmware versions. + + Returns: + List of Coordinate, one per channel. + """ + try: + resp_obj = await self._client.send_command(PrepCmd.PrepGetPositions()) + except (HoiError, ChannelizedError): + return [] + if not isinstance(resp_obj, PrepCmd.PrepGetPositions.Response): + return [] + resp = resp_obj + if not resp.positions: + return [] + + _CHANNEL_ENUM_TO_IDX = {int(v): k for k, v in _CHANNEL_INDEX.items()} + indexed: list[tuple[int, Coordinate]] = [] + for p in resp.positions: + ch_idx = _CHANNEL_ENUM_TO_IDX.get(p.channel) + if ch_idx is not None: + indexed.append((ch_idx, Coordinate(x=p.position_x, y=p.position_y, z=p.position_z))) + + indexed.sort(key=lambda pair: pair[0]) + return [coord for _, coord in indexed] + + async def request_x_pos_channel_n(self, channel_idx: int = 0) -> float: + """Request X position of pipettor channel n (in mm). + + Analogous to STARBackend.request_x_pos_channel_n(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + X position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + return float(positions[channel_idx].x) + + async def request_y_pos_channel_n(self, channel_idx: int) -> float: + """Request Y position of pipettor channel n (in mm). + + Analogous to STARBackend.request_y_pos_channel_n(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Y position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + return float(positions[channel_idx].y) + + async def request_z_pos_channel_n(self, channel_idx: int) -> float: + """Request Z position of pipettor channel n (in mm). + + Analogous to STARBackend.request_z_pos_channel_n(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Z position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + return float(positions[channel_idx].z) + + async def get_channels_y_positions(self) -> dict[int, float]: + """Request Y positions of all channels. + + Analogous to STARBackend.get_channels_y_positions(). + + Returns: + Dict mapping channel index (0=rearmost) to Y position in mm. + """ + positions = await self.request_channel_positions() + return {i: coord.y for i, coord in enumerate(positions)} + + async def get_channels_z_positions(self) -> dict[int, float]: + """Request Z positions of all channels. + + Analogous to STARBackend.get_channels_z_positions(). + + Returns: + Dict mapping channel index (0=rearmost) to Z position in mm. + """ + positions = await self.request_channel_positions() + return {i: coord.z for i, coord in enumerate(positions)} + + async def request_tip_bottom_z_position(self, channel_idx: int) -> float: + """Request the Z position of the tip bottom on the specified channel. + + GetPositions returns tip-adjusted Z when a tip is mounted — the reported Z + is the tip bottom position, not the channel head. Verified empirically: + channel at traverse (167.5mm) with 50uL NTR tip (extension 42.4mm) reports + Z=125.1mm = 167.5 - 42.4. + + Requires a tip to be mounted (verified via sleeve sensor). + + Analogous to STARBackend.request_tip_bottom_z_position(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Tip bottom Z position in mm. + + Raises: + RuntimeError: If no tip is present on the channel. + """ + tip_presence = await self.sense_tip_presence() + if channel_idx >= len(tip_presence) or not tip_presence[channel_idx]: + raise RuntimeError(f"No tip mounted on channel {channel_idx}") + + return await self.request_z_pos_channel_n(channel_idx) + + async def request_probe_z_position(self, channel_idx: int) -> float: + """Request the Z position of the channel probe/head (excluding tip). + + Since GetPositions returns tip-adjusted Z when a tip is mounted, this + method queries the firmware's held tip definition (GetTipDefinitionHeld, + Pipettor cmd=13) to get the tip length and adds it back. + + When no tip is mounted, returns the same value as request_z_pos_channel_n(). + + Analogous to STARBackend.request_probe_z_position(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Channel head Z position in mm (excluding tip). + """ + z = await self.request_z_pos_channel_n(channel_idx) + tip_presence = await self.sense_tip_presence() + if channel_idx < len(tip_presence) and tip_presence[channel_idx]: + # Query firmware for the held tip definition to get tip length + pipettor_addr = await self._client.resolve_path(PIPETTOR_OBJECT_PATH) + raw = await self._client.send_query( + PrepCmd.PrepProbeRequest(dest=pipettor_addr, command_id=13) + ) + if raw is not None: + import struct as _struct + + data = raw[0] + # TipDefinition struct: default_values, id, volume(F32), length(F32), ... + # The second F32 is the tip extension length + f32_count = 0 + i = 0 + while i < len(data) - 7: + if data[i] == 0x28 and data[i + 1] == 0x00: + f32_count += 1 + if f32_count == 2: # second F32 = length + tip_length = _struct.unpack_from(" 0: + z += tip_length + break + i += 8 + else: + i += 1 + return z + + # --------------------------------------------------------------------------- + # Per-axis channel movement + # --------------------------------------------------------------------------- + + async def move_channel_x(self, channel_idx: int, x: float) -> None: + """Move the gantry X axis to a position (in mm). + + On the Prep, X is shared across all channels (single gantry). The channel_idx + parameter is accepted for STAR API compatibility but does not affect which + channel moves — all channels move together in X. + + Analogous to STARBackend.move_channel_x(). + + Args: + channel_idx: Channel index (0=rearmost). Used to read current Y/Z. + x: Target X position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + await self.move_to_position( + x, positions[channel_idx].y, positions[channel_idx].z, use_channels=channel_idx + ) + + async def move_channel_y(self, channel_idx: int, y: float) -> None: + """Move a channel in the Y direction (in mm). + + Analogous to STARBackend.move_channel_y(). + + Args: + channel_idx: Channel index (0=rearmost). + y: Target Y position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + await self.move_to_position( + positions[channel_idx].x, y, positions[channel_idx].z, use_channels=channel_idx + ) + + async def move_channel_z(self, channel_idx: int, z: float) -> None: + """Move a channel in the Z direction (in mm). + + Analogous to STARBackend.move_channel_z(). + + Args: + channel_idx: Channel index (0=rearmost). + z: Target Z position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + await self.move_to_position( + positions[channel_idx].x, positions[channel_idx].y, z, use_channels=channel_idx + ) + + # --------------------------------------------------------------------------- + # Tip presence sensing + # --------------------------------------------------------------------------- + + async def sense_tip_presence(self) -> list[bool]: + """Sense whether a tip is physically present on each pipettor channel via the sleeve sensor. + + Reads the physical sleeve displacement sensor (GetTipPresent, cmd=15) on each + channel's SDrive sub-object. The sensor responds in real-time to sleeve + displacement — verified by manual sleeve push tests without any tip pickup. + + Note: the firmware exposes this sensor through the SDrive (squeezer drive) object + at object_id 514, but it reads the sleeve displacement sensor independently of + the squeeze motor state. + + Channel addresses are discovered lazily from the object tree and cached in + ``ChannelDriveMap``, so this works regardless of the node IDs + assigned by the firmware on a given instrument. + + Returns: + List of bools, one per channel (index 0=rearmost). True if tip detected. + """ + import struct as _struct + + drive_map = await self.discover_channel_drives() + if not drive_map.sleeve_sensor_addrs: + raise RuntimeError("No channel sleeve sensor addresses discovered.") + + results: list[bool] = [] + for addr in drive_map.sleeve_sensor_addrs: + raw = await self._client.send_query(PrepCmd.PrepProbeRequest(dest=addr, command_id=15)) + if raw is None or len(raw[0]) < 8: + results.append(False) + else: + val = _struct.unpack_from(" List[Optional[bool]]: + pres = await self.sense_tip_presence() + return [bool(x) for x in pres] + + # --------------------------------------------------------------------------- + # Capacitance-based probing (cLLD) + # --------------------------------------------------------------------------- + + async def clld_probe_x_position_using_channel(self, *args, **kwargs): + """Probe X position using capacitive LLD. Not yet implemented for the Prep. + + TODO: Investigate ChannelCoordinator [1:17] MoveChannelAxisAbsolute and + [1:18] MoveChannelAxisRelative for X-axis probing with cLLD feedback. + The ChannelCoordinator also has [1:19] YSeekLldPosition which may have + an X equivalent, though none was found in introspection. + """ + raise NotImplementedError( + "clld_probe_x_position_using_channel is not yet implemented for PrepChannels." + ) + + async def clld_probe_y_position_using_channel(self, *args, **kwargs): + """Probe Y position using capacitive LLD. Not yet implemented for the Prep. + + TODO: Investigate ChannelCoordinator [1:19] YSeekLldPosition(seekParameters) + which takes a YLLDSeekParameters struct and returns SeekResultParameters. + Also Channel [1:11] LeakCheck has ySeekDistance/yPreloadDistance params + which suggest Y-axis seeking capability. + """ + raise NotImplementedError( + "clld_probe_y_position_using_channel is not yet implemented for PrepChannels." + ) + + async def clld_probe_z_height_using_channel(self, *args, **kwargs): + """Probe Z-height using capacitive LLD. Not yet implemented for the Prep. + + TODO: Implement using the standalone ZSeekLldPosition command: + - Pipettor [1:29] ZSeekLldPosition(seekParameters) -> results: SeekResultParameters + - ChannelCoordinator [1:20] ZSeekLldPosition(seekParameters) -> results: SeekResultParameters + Previously returned HC_RESULT=0x0F06 which was assumed to be "LLD not supported". + Now identified as "Z position out of allowed movement range" — the Z parameters + in LLDChannelSeekParameters were out of bounds. Retry with valid Z values + within deck_bounds (min_z=18.03, max_z=167.5). + + Findings from testing: + - cLLD DOES work through the aspirate path (aspirate with + lld_mode=[LLDMode.CAPACITIVE] and default_values=False on both + LldParameters and CLldParameters). + - Standalone ZSeekLldPosition is rejected with 0x0F06 when Z params are out of range. + - The aspirate-based approach is a workaround, not a proper standalone probe. + + Also investigate ZAxis-level alternatives: + - ZAxis.SeekCapacitiveLld [1:12] (returns 0x0207 when called directly) + - ZAxis.SeekCapacitiveLldTip [1:13] (returns 0x0207 when called directly) + - ZAxis.LiquidStatus [1:16] for reading last detection results + - PipettorService.MeasureLldFrequency [1:6] for sensor health checks + """ + raise NotImplementedError( + "clld_probe_z_height_using_channel is not yet implemented for PrepChannels." + ) + + async def ztouch_probe_z_height_using_channel(self, *args, **kwargs): + """Probe Z-height using force/motor stall detection. Not yet implemented for the Prep. + + TODO: Investigate force-based Z probing commands: + - ZAxis.SeekObstacle [1:14] SeekObstacle(startPosition, endPosition, finalPosition, velocity) + Currently returns 0x0207 when called directly — needs coordinator routing. + - Calibration.ZTouchoff [1:8] — runs a Z touchoff calibration (force-based). + - The STAR implements this via a dedicated "ZH" firmware command with PWM-based + force detection. The Prep may have an equivalent through the ChannelCoordinator + but it was not found in introspection. + """ + raise NotImplementedError( + "ztouch_probe_z_height_using_channel is not yet implemented for PrepChannels." + ) + + # --------------------------------------------------------------------------- + # Pipettor convenience methods + # --------------------------------------------------------------------------- + + async def move_channels_to_safe_z(self, channels: Optional[List[int]] = None) -> None: + """Move the given channels' Z axes up to safe (traverse) height (cmd=28). + + Use after picking up a tool or before returning a tool to avoid collisions + during XY moves. The instrument uses its configured safe/traverse height; + no height parameter is sent. + + Args: + channels: Channel indices to move (0=rearmost). None = all channels. + """ + if channels is None: + channels = list(range(self.num_channels)) + else: + channels = sorted(set(channels)) + if not channels: + return + assert max(channels) < self.num_channels, ( + f"channel index out of range (valid: 0..{self.num_channels - 1})" + ) + channel_enums = [_CHANNEL_INDEX[ch] for ch in channels] + await self._client.send_command(PrepCmd.PrepMoveZUpToSafe(channels=channel_enums)) + + async def move_to_position( + self, + x: float, + y: Union[float, List[float]], + z: Union[float, List[float]], + use_channels: Optional[Union[int, List[int]]] = 0, + *, + via_lane: bool = False, + ) -> None: + """Move pipettor to position (cmd=26 or 27). Same (x,y,z) params; via_lane selects cmd 27. + + use_channels defaults to 0 (rear channel). Pass a single channel index (int) or + a list of indices; for all channels use list(range(self.num_channels)). For a + single channel, y and z may be scalars instead of lists. + """ + if use_channels is None: + channels = [0] + elif isinstance(use_channels, list): + channels = list(use_channels) + else: + # int or int-like (e.g. numpy.int64); single channel + channels = [int(use_channels)] + channels = sorted(channels) + if channels: + assert max(channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) + if isinstance(y, list): + assert len(y) == len(channels), "len(y) must equal len(use_channels)" + if isinstance(z, list): + assert len(z) == len(channels), "len(z) must equal len(use_channels)" + + # Validate against per-channel movement bounds (cached from firmware at setup). + y_vals = y if isinstance(y, list) else [y] * len(channels) + z_vals = z if isinstance(z, list) else [z] * len(channels) + for i, (y_i, z_i) in enumerate(zip(y_vals, z_vals)): + ch = channels[i] + if ch < len(self._channel_bounds): + b = self._channel_bounds[ch] + if not b["x_min"] <= x <= b["x_max"]: + raise ValueError(f"x={x} outside channel {ch} range [{b['x_min']:.1f}, {b['x_max']:.1f}]") + if not b["y_min"] <= y_i <= b["y_max"]: + raise ValueError( + f"y={y_i} outside channel {ch} range [{b['y_min']:.1f}, {b['y_max']:.1f}]" + ) + if z_i > b["z_max"]: + raise ValueError(f"z={z_i} above channel {ch} maximum {b['z_max']:.1f}") + + move_parameters = _build_pipettor_gantry_move_parameters(x, channels, y, z) + + if via_lane: + await self._client.send_command( + PrepCmd.PrepMoveToPositionViaLane(move_parameters=move_parameters) + ) + else: + await self._client.send_command(PrepCmd.PrepMoveToPosition(move_parameters=move_parameters)) + + async def stop(self) -> None: + self.setup_finished = False + + def serialize(self) -> dict: + return { + "type": self.__class__.__name__, + "default_traverse_height": self._user_traverse_height, + "use_v1_aspirate_dispense": self._use_v1_aspirate_dispense, + } diff --git a/pylabrobot/hamilton/prep/chatterbox.py b/pylabrobot/hamilton/prep/chatterbox.py new file mode 100644 index 00000000000..4ff61522ba7 --- /dev/null +++ b/pylabrobot/hamilton/prep/chatterbox.py @@ -0,0 +1,183 @@ +"""PrepChatterboxClient: minimal client for tests without TCP hardware.""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, List, Optional, Union + +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.introspection import ( + HamiltonIntrospection, + MethodInfo, + ObjectInfo, +) +from pylabrobot.hamilton.transport.tcp.packets import Address + +from . import prep_commands as PrepCmd +from .client import ( + MLPREP_OBJECT_PATH, + MPH_OBJECT_PATH, + PIPETTOR_OBJECT_PATH, + PrepClient, +) +from .info import PrepInstrumentInfo +from .prep_commands import PrepCommand + +logger = logging.getLogger(__name__) + +# Channel v2 support probe expects pipettor interface 1 to expose these method IDs. +_V2_PIPETTING_METHOD_IDS = frozenset(range(38, 44)) +# PrepHead8._probe_v2_support expects MPH interface 1 to expose these method IDs. +_V2_MPH_METHOD_IDS = frozenset(range(29, 35)) + + +class _PrepChatterboxIntrospection(HamiltonIntrospection): + """Offline introspection: v2 probe succeeds when ``use_v1_aspirate_dispense`` is False.""" + + def __init__( + self, + *args, + stub_methods_fn: Callable[[Address, int], Optional[List[MethodInfo]]], + **kwargs, + ): + super().__init__(*args, **kwargs) + self._stub_methods_fn = stub_methods_fn + + async def methods_for_interface( + self, address: Union[Address, str], interface_id: int + ) -> List[MethodInfo]: + addr = await self._resolve_target_address(address) + stubs = self._stub_methods_fn(addr, interface_id) + if stubs is not None: + return stubs + return await super().methods_for_interface(address, interface_id) + + +class PrepChatterboxInstrumentInfo(PrepInstrumentInfo): + """Offline info: uses canned :class:`~prep_commands.InstrumentConfig` from the chatterbox client.""" + + async def _on_setup(self) -> None: + d = self._driver + assert isinstance(d, PrepChatterboxClient) + self._config = d._canned_config + + +class PrepChatterboxClient(PrepClient): + """Skips TCP; uses canned addresses so Prep channels can be exercised offline. + + Canned firmware state (num_channels, has_mph, traverse height) lives on the + chatterbox client — :class:`PrepChatterboxInstrumentInfo` reads it for ``info.config``. + + Default ``use_v1_aspirate_dispense=False`` matches hardware: introspection stubs + report v2 aspirate/dispense commands on the pipettor. Pass + ``use_v1_aspirate_dispense=True`` for a thinner v1-only offline path. + """ + + def __init__( + self, + num_channels: int = 2, + has_mph: bool = True, + default_traverse_height: float = 180.0, + use_v1_aspirate_dispense: bool = False, + ): + super().__init__(host="chatterbox", port=2000) + self._canned_config = PrepCmd.InstrumentConfig( + deck_bounds=None, + has_enclosure=False, + safe_speeds_enabled=True, + deck_sites=(), + waste_sites=(), + default_traverse_height=default_traverse_height, + num_channels=num_channels, + has_mph=has_mph, + ) + self._pipettor_addr: Optional[Address] = None + self._mph_addr: Optional[Address] = None + self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense + + @property + def introspection(self) -> HamiltonIntrospection: + if self._introspection_impl is None: + + def _stub_methods(addr: Address, interface_id: int) -> Optional[List[MethodInfo]]: + if interface_id == 1 and not self._use_v1_aspirate_dispense: + if self._pipettor_addr is not None and addr == self._pipettor_addr: + return [ + MethodInfo(interface_id=1, call_type=0, method_id=mid, name=f"v2_stub_{mid}") + for mid in sorted(_V2_PIPETTING_METHOD_IDS) + ] + if self._mph_addr is not None and addr == self._mph_addr: + return [ + MethodInfo(interface_id=1, call_type=0, method_id=mid, name=f"v2_mph_stub_{mid}") + for mid in sorted(_V2_MPH_METHOD_IDS) + ] + return None + + self._introspection_impl = _PrepChatterboxIntrospection( + registry=self._registry, + global_object_addresses=self._global_object_addresses, + send_discovery_command=self.send_discovery_command, + send_query=self.send_query, + stub_methods_fn=_stub_methods, + ) + return self._introspection_impl + + async def setup(self): + # Seed the introspection registry with every firmware path the codebase + # may touch. The seed list is derived from the command aggregate + # (PrepCommand._ALL_PATHS) plus PrepInstrumentInfo._paths — new commands + # with new firmware_path values get chatterbox parity for free. Addresses + # are assigned deterministically in sorted-path order so they're stable + # across runs. + seed_paths = sorted(PrepCommand._ALL_PATHS | set(PrepInstrumentInfo._paths.values())) + for idx, path in enumerate(seed_paths): + leaf = path.rsplit(".", 1)[-1] + addr = Address(1, 1, 256 + idx) + self.registry.register( + path, + ObjectInfo(name=leaf, version="", method_count=0, subobject_count=0, address=addr), + ) + self._pipettor_addr = await self.resolve_path(PIPETTOR_OBJECT_PATH) + self._mlprep_address = await self.resolve_path(MLPREP_OBJECT_PATH) + if self._canned_config.has_mph: + self._mph_addr = await self.resolve_path(MPH_OBJECT_PATH) + + async def stop(self): + self._pipettor_addr = None + self._mph_addr = None + self._mlprep_address = None + self._invalidate_introspection_session() + + async def send_command( + self, + command: TCPCommand, + ensure_connection: bool = True, + return_raw: bool = False, + raise_on_error: bool = True, + read_timeout: Optional[float] = None, + ) -> Any: + del ensure_connection, raise_on_error, read_timeout + # Exercise the JIT resolve path so that missing firmware paths surface + # the same error offline as they would against hardware. + from .prep_commands import _UNRESOLVED, PrepCommand + + if isinstance(command, PrepCommand) and command.dest == _UNRESOLVED: + path = type(command).firmware_path + if path is None: + raise RuntimeError( + f"{type(command).__name__} has no firmware_path declared and no " + "explicit dest= supplied at construction." + ) + try: + addr = await self.resolve_path(path) + except KeyError as exc: + raise RuntimeError( + f"Cannot send {type(command).__name__}: firmware path " + f"{path!r} did not resolve on this instrument ({exc})." + ) from exc + command.dest = addr + command.dest_address = addr + logger.info("[Prep chatterbox] %s", command.__class__.__name__) + if return_raw: + return (b"",) + return None diff --git a/pylabrobot/hamilton/prep/client.py b/pylabrobot/hamilton/prep/client.py new file mode 100644 index 00000000000..6c673a94c29 --- /dev/null +++ b/pylabrobot/hamilton/prep/client.py @@ -0,0 +1,195 @@ +"""PrepClient: Hamilton TCP client for Hamilton Prep liquid handlers (Nimbus-style layout). + +Transport-only: opens TCP, discovers the firmware root, and resolves one bootstrap +handle — :attr:`PrepClient.mlprep_address` (``MLPrepRoot.MLPrep``). Everything +else uses :meth:`HamiltonTCPClient.resolve_path`, which consults the introspection +registry (cache-hot after the first hit). + +**JIT command targets.** Concrete :class:`~pylabrobot.hamilton.prep.prep_commands.PrepCommand` +subclasses declare ``firmware_path``; :meth:`PrepClient._send_raw` resolves +that path when ``dest`` is the unresolved sentinel. No parallel path tables on +backends. + +**Bootstrap info.** :class:`~pylabrobot.hamilton.prep.info.PrepInstrumentInfo` +resolves a small set of diagnostic paths (see ``PrepInstrumentInfo._paths``) +during setup via the same ``resolve_path`` cache. + +**Channel topology** (per-channel drive addresses) is discovered in +:mod:`~pylabrobot.hamilton.prep.channels` by walking the tree +from ``MLPrepRoot``, not via a separate registry. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.error_tables import PREP_ERROR_CODES +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.tcp import HamiltonTCPClient + +from . import prep_commands as PrepCmd +from .prep_commands import _UNRESOLVED, PrepCommand + +logger = logging.getLogger(__name__) + +_EXPECTED_ROOT = "MLPrepRoot" + +# Canonical firmware path strings (single source for client, chatterbox, probes). +MLPREP_OBJECT_PATH = "MLPrepRoot.MLPrep" +PIPETTOR_OBJECT_PATH = "MLPrepRoot.PipettorRoot.Pipettor" +MPH_OBJECT_PATH = "MLPrepRoot.MphRoot.MPH" + + +class PrepClient(HamiltonTCPClient): + """Hamilton TCP client for Prep: connection, MLPrep bootstrap, firmware string decode. + + Instrument-wide motion, power, and deck-light entry points live on + :class:`~pylabrobot.hamilton.prep.prep.Prep` and + :class:`~pylabrobot.hamilton.prep.method.PrepMethodLifecycle`. + Pipettor, calibration, and MPH traffic goes through :class:`PrepCommand` plus + :meth:`send_command` / :meth:`resolve_path`, or through peers that build those + commands. + """ + + _ERROR_CODES = PREP_ERROR_CODES + + def __init__( + self, + host: str, + port: int = 2000, + read_timeout: float = 300.0, + write_timeout: float = 30.0, + auto_reconnect: bool = True, + max_reconnect_attempts: int = 3, + connection_timeout: int = 600, + ): + super().__init__( + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + auto_reconnect=auto_reconnect, + max_reconnect_attempts=max_reconnect_attempts, + connection_timeout=connection_timeout, + ) + self._mlprep_address: Optional[Address] = None + + # --------------------------------------------------------------------------- + # Lifecycle + # --------------------------------------------------------------------------- + + async def setup(self): + await super().setup() + + root = await self.discovered_root_name() + if root != _EXPECTED_ROOT: + raise RuntimeError( + f"Expected root '{_EXPECTED_ROOT}' (Prep), but discovered '{root}'. Wrong instrument?" + ) + + self._mlprep_address = await self.resolve_path(MLPREP_OBJECT_PATH) + + async def stop(self) -> None: + await super().stop() + self._mlprep_address = None + + # --------------------------------------------------------------------------- + # MLPrep root handle (resolved in :meth:`setup`) + # --------------------------------------------------------------------------- + + @property + def mlprep_address(self) -> Address: + """Address of ``MLPrepRoot.MLPrep``. Raises if :meth:`setup` has not run.""" + if self._mlprep_address is None: + raise RuntimeError("MLPrep address not resolved. Call setup() first.") + return self._mlprep_address + + # --------------------------------------------------------------------------- + # JIT firmware-path resolution for PrepCommand.dest + # --------------------------------------------------------------------------- + + async def _send_raw( + self, + command: TCPCommand, + *, + ensure_connection: bool, + return_raw: bool, + raise_on_error: bool, + read_timeout: Optional[float] = None, + ) -> Any: + if isinstance(command, PrepCommand) and command.dest == _UNRESOLVED: + path = type(command).firmware_path + if path is None: + raise RuntimeError( + f"{type(command).__name__} has no firmware_path declared and no " + "explicit dest= supplied at construction. Polymorphic-dest commands " + "must pass dest= to send_query or send_command." + ) + try: + addr = await self.resolve_path(path) + except KeyError as exc: + raise RuntimeError( + f"Cannot send {type(command).__name__}: firmware path " + f"{path!r} did not resolve on this instrument ({exc})." + ) from exc + command.dest = addr + command.dest_address = addr + return await super()._send_raw( + command, + ensure_connection=ensure_connection, + return_raw=return_raw, + raise_on_error=raise_on_error, + read_timeout=read_timeout, + ) + + # --------------------------------------------------------------------------- + # Discovery + # --------------------------------------------------------------------------- + + async def discovered_root_name(self) -> str: + roots = self.get_root_object_addresses() + if not roots: + raise RuntimeError("No root objects discovered. Call setup() first.") + info = await self.introspection.get_object(roots[0]) + name = info.name + if not isinstance(name, str): + raise RuntimeError(f"Unexpected root name type: {type(name).__name__}") + return name + + # --------------------------------------------------------------------------- + # Firmware string queries (transport: raw HOI decode + status query) + # --------------------------------------------------------------------------- + + @staticmethod + def _decode_firmware_string(raw: Optional[tuple]) -> Optional[str]: + """Decode a string from a raw HOI response (Hamilton string wire format).""" + if raw is None: + return None + data: bytes = raw[0] + i = 0 + while i < len(data) - 3: + if data[i] == 0x0F and data[i + 1] in (0x00, 0x01): + slen = int.from_bytes(data[i + 2 : i + 4], "little") + if slen > 0 and i + 4 + slen <= len(data): + return data[i + 4 : i + 4 + slen].decode("utf-8", errors="replace").rstrip("\x00") + i += 1 + return None + + async def _query_firmware_string( + self, addr: Address, cmd_id: int, iface_id: int = 3 + ) -> Optional[str]: + """Send a status query and decode the string response.""" + ns: dict[str, Any] = { + "command_id": cmd_id, + "interface_id": iface_id, + "__annotations__": {"dest": Address}, + } + Cmd = type("_FWQuery", (PrepCmd.PrepStatusRequest,), ns) + raw_resp: object = await self.send_query(Cmd(dest=addr)) + if raw_resp is None: + return self._decode_firmware_string(None) + if not isinstance(raw_resp, tuple): + return None + return self._decode_firmware_string(raw_resp) diff --git a/pylabrobot/hamilton/prep/gripper.py b/pylabrobot/hamilton/prep/gripper.py new file mode 100644 index 00000000000..3703b8fdf4d --- /dev/null +++ b/pylabrobot/hamilton/prep/gripper.py @@ -0,0 +1,403 @@ +"""Hamilton Prep CoRe gripper and PrepGripperArm frontend helper.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Literal, Optional + +from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.resource_state import place_resource + +from . import prep_commands as PrepCmd + +if TYPE_CHECKING: + from .channels import PrepChannels + from .client import PrepClient + +logger = logging.getLogger(__name__) + + +class PrepGripper: + """CoRe gripper for Prep — translates plate/tool ops to PrepCmd firmware commands. + + Tool management (pick_up_tool / drop_tool) is handled by the + :meth:`Prep.core_grippers` context manager. + """ + + def __init__(self, *, client: "PrepClient", channels: "PrepChannels") -> None: + self._client = client + self._channels = channels + + @property + def client(self) -> "PrepClient": + return self._client + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + *, + resource_length: float, + resource_height: float, + plate_top_z_offset: float, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + """Pick up a plate at the specified location. + + Args: + location: Plate center at grip height (x, y, grip_z) in deck coordinates. + resource_width: Plate width along the grip axis (Y) in mm. + resource_length: Plate length (X) in mm. + resource_height: Plate height (Z) in mm. + plate_top_z_offset: Offset from grip Z to plate top center Z. + clearance_y: Approach clearance along the grip axis (mm). + grip_speed_y: Grip speed (mm/s). + squeeze_mm: Additional squeeze distance beyond clearance (mm). + """ + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=location.x, + y_position=location.y, + z_position=location.z + plate_top_z_offset, + ) + plate_dims = PrepCmd.PlateDimensions( + default_values=False, + length=resource_length, + width=resource_width, + height=resource_height, + ) + grip_distance = clearance_y + squeeze_mm + + await self._client.send_command( + PrepCmd.PrepPickUpPlate( + plate_top_center=plate_top_center, + plate=plate_dims, + clearance_y=clearance_y, + grip_speed_y=grip_speed_y, + grip_distance=grip_distance, + grip_height=location.z, + ) + ) + + async def drop_at_location( + self, + location: Coordinate, + resource_width: float, + *, + clearance_y: float = 3.0, + acceleration_scale_x: int = 1, + ) -> None: + """Drop a plate at the specified location. + + Args: + location: Plate center at place height in deck coordinates. + resource_width: Plate width along the grip axis (Y) in mm (unused by firmware). + clearance_y: Release clearance along the grip axis (mm). + acceleration_scale_x: X-axis acceleration scale. + """ + del resource_width + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=location.x, + y_position=location.y, + z_position=location.z, + ) + await self._client.send_command( + PrepCmd.PrepDropPlate( + plate_top_center=plate_top_center, + clearance_y=clearance_y, + acceleration_scale_x=acceleration_scale_x, + ) + ) + + async def move_to_location( + self, + location: Coordinate, + *, + acceleration_scale_x: int = 1, + ) -> None: + """Move a held plate to a new position without releasing it. + + Args: + location: Target plate center position in deck coordinates. + acceleration_scale_x: X-axis acceleration scale. + """ + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=location.x, + y_position=location.y, + z_position=location.z, + ) + await self._client.send_command( + PrepCmd.PrepMovePlate( + plate_top_center=plate_top_center, + acceleration_scale_x=acceleration_scale_x, + ) + ) + + async def release_plate(self) -> None: + """Open the CoRe gripper and release whatever is held (PrepReleasePlate, cmd=21).""" + await self._client.send_command(PrepCmd.PrepReleasePlate()) + + async def pick_up_tool( + self, + tool_position_x: float, + tool_position_z: float, + front_channel_position_y: float, + rear_channel_position_y: float, + *, + tool_seek: Optional[float] = None, + tool_x_radius: float = 2.0, + tool_y_radius: float = 2.0, + tip_definition: Optional[PrepCmd.TipPickupParameters] = None, + pre_position: bool = True, + ) -> None: + """Pick up CoRe gripper tool (PrepPickUpTool, cmd=15). + + When ``pre_position`` is True (default), moves both channels to the tool XY at + traverse height before the firmware pickup (same pattern as tip pickup). + After pickup, moves channels to safe Z. + """ + if tool_seek is None: + tool_seek = tool_position_z + 10.0 + if tip_definition is None: + tip_definition = PrepCmd.CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS + if pre_position: + traverse_h = self._channels._resolve_traverse_height() + await self._channels.move_to_position( + x=tool_position_x, + y=[rear_channel_position_y, front_channel_position_y], + z=traverse_h, + use_channels=[0, 1], + ) + await self._client.send_command( + PrepCmd.PrepPickUpTool( + tip_definition=tip_definition, + tool_position_x=tool_position_x, + tool_position_z=tool_position_z, + front_channel_position_y=front_channel_position_y, + rear_channel_position_y=rear_channel_position_y, + tool_seek=tool_seek, + tool_x_radius=tool_x_radius, + tool_y_radius=tool_y_radius, + ) + ) + await self._channels.move_channels_to_safe_z() + + async def drop_tool(self, *, move_to_safe_z_first: bool = True) -> None: + """Drop CoRe gripper tool (PrepDropTool, cmd=16).""" + if move_to_safe_z_first: + await self._channels.move_channels_to_safe_z() + await self._client.send_command(PrepCmd.PrepDropTool()) + + +class PrepGripperArm: + """Resource-aware helper over :class:`PrepGripper` pose commands. + + Resource path: ``pick_up_resource`` / ``drop_resource`` resolve geometry from the + resource tree (with optional ``offset``) and reassign the held resource on drop. + + Coordinate path: ``pick_up_at_location`` / ``drop_at_location`` take explicit deck + coordinates (escape hatch for taught points). Prep has no grip-force field; + squeeze is controlled via ``clearance_y``, ``squeeze_mm``, and ``grip_speed_y``. + """ + + def __init__( + self, + backend: PrepGripper, + reference_resource: Resource, + grip_axis: Literal["x", "y"] = "y", + ) -> None: + self.backend = backend + self._reference_resource = reference_resource + self._grip_axis = grip_axis + self._pickup_distance_from_bottom: Optional[float] = None + self._holding_resource_width: Optional[float] = None + self._held_resource: Optional[Resource] = None + + def _resolve_pickup_distance( + self, resource: Resource, pickup_distance_from_bottom: Optional[float] + ) -> float: + if pickup_distance_from_bottom is not None: + return pickup_distance_from_bottom + if resource.preferred_pickup_location is not None: + logger.debug( + "Using preferred pickup location for resource %s as pickup_distance_from_bottom was " + "not specified.", + resource.name, + ) + return resource.preferred_pickup_location.z + logger.debug( + "No preferred pickup location for resource %s. Using default pickup distance of 5mm " + "from top (= size_z - 5).", + resource.name, + ) + return resource.get_size_z() - 5.0 + + def _pickup_location( + self, + resource: Resource, + offset: Coordinate, + pickup_distance_from_bottom: float, + ) -> Coordinate: + center = resource.center().rotated(resource.get_absolute_rotation()) + if resource.is_in_subtree_of(self._reference_resource): + loc = resource.get_location_wrt(self._reference_resource, "l", "f", "b") + center + offset + else: + loc = center + offset + return Coordinate(loc.x, loc.y, loc.z + pickup_distance_from_bottom) + + def _drop_location(self, destination: Resource, offset: Coordinate) -> Coordinate: + if self._held_resource is None or self._pickup_distance_from_bottom is None: + raise RuntimeError( + "drop_resource requires a prior pick_up_resource (held resource and grip height)." + ) + held = self._held_resource + pdfb = self._pickup_distance_from_bottom + if isinstance(destination, ResourceHolder): + child = destination.get_default_child_location(held) + else: + child = Coordinate.zero() + center = held.center().rotated(held.get_absolute_rotation()) + plate_lfb = destination.get_location_wrt(self._reference_resource, "l", "f", "b") + child + loc = plate_lfb + center + offset + return Coordinate(loc.x, loc.y, loc.z + pdfb) + + def _resource_width(self, resource: Resource) -> float: + if self._grip_axis == "y": + return resource.get_absolute_size_y() + return resource.get_absolute_size_x() + + def _clear_held_state(self) -> None: + self._holding_resource_width = None + self._pickup_distance_from_bottom = None + self._held_resource = None + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_bottom: Optional[float] = None, + *, + resource_width: Optional[float] = None, + resource_length: Optional[float] = None, + resource_height: Optional[float] = None, + plate_top_z_offset: Optional[float] = None, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + pdfb = self._resolve_pickup_distance(resource, pickup_distance_from_bottom) + if resource_width is None: + resource_width = self._resource_width(resource) + if resource_length is None: + resource_length = resource.get_absolute_size_x() + if resource_height is None: + resource_height = resource.get_absolute_size_z() + if plate_top_z_offset is None: + plate_top_z_offset = resource.get_absolute_size_z() - pdfb + + location = self._pickup_location(resource, offset, pdfb) + await self.backend.pick_up_at_location( + location, + resource_width, + resource_length=resource_length, + resource_height=resource_height, + plate_top_z_offset=plate_top_z_offset, + clearance_y=clearance_y, + grip_speed_y=grip_speed_y, + squeeze_mm=squeeze_mm, + ) + self._pickup_distance_from_bottom = pdfb + self._holding_resource_width = resource_width + self._held_resource = resource + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + *, + resource_length: float, + resource_height: float, + plate_top_z_offset: float, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + """Pick up at an explicit grip-point coordinate (no resource-tree geometry). + + Sets held width so ``drop_at_location`` works. Does not set a held + :class:`Resource`; use ``drop_resource`` only after ``pick_up_resource``. + """ + await self.backend.pick_up_at_location( + location, + resource_width, + resource_length=resource_length, + resource_height=resource_height, + plate_top_z_offset=plate_top_z_offset, + clearance_y=clearance_y, + grip_speed_y=grip_speed_y, + squeeze_mm=squeeze_mm, + ) + self._holding_resource_width = resource_width + self._pickup_distance_from_bottom = None + self._held_resource = None + + async def drop_resource( + self, + destination: Resource, + offset: Coordinate = Coordinate.zero(), + *, + clearance_y: float = 3.0, + acceleration_scale_x: int = 1, + ) -> None: + """Drop the held resource onto a destination resource (e.g. a PrepDeck spot). + + Resolves place geometry from the destination holder + held plate, then + reassigns the resource tree after a successful firmware drop. + """ + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + if self._held_resource is None or self._pickup_distance_from_bottom is None: + raise RuntimeError( + "drop_resource requires a prior pick_up_resource (held resource and grip height)." + ) + held = self._held_resource + destination.check_can_drop_resource_here(held) + location = self._drop_location(destination, offset) + await self.backend.drop_at_location( + location, + self._holding_resource_width, + clearance_y=clearance_y, + acceleration_scale_x=acceleration_scale_x, + ) + self._clear_held_state() + place_resource(held, destination) + + async def drop_at_location( + self, + location: Coordinate, + *, + clearance_y: float = 3.0, + acceleration_scale_x: int = 1, + ) -> None: + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + await self.backend.drop_at_location( + location, + self._holding_resource_width, + clearance_y=clearance_y, + acceleration_scale_x=acceleration_scale_x, + ) + self._clear_held_state() + + async def move_to_location( + self, + location: Coordinate, + *, + acceleration_scale_x: int = 1, + ) -> None: + await self.backend.move_to_location(location, acceleration_scale_x=acceleration_scale_x) diff --git a/pylabrobot/hamilton/prep/head8.py b/pylabrobot/hamilton/prep/head8.py new file mode 100644 index 00000000000..936b462ed40 --- /dev/null +++ b/pylabrobot/hamilton/prep/head8.py @@ -0,0 +1,1304 @@ +"""PrepHead8 — 8MPH head for the Hamilton Prep. + +The 8MPH is a ganged head: a single X/Y/Z gantry and a single dispenser piston +drive all 8 probes together. Individual sleeves are mechanically coupled — partial +sleeve engagement produces insufficient grip force and tips fall off. All +operations therefore require all 8 channels simultaneously. + +------------------------------ +- PickupTips / DropTips: single TipPositionParameters struct; Y = probe-0 reference. + PickupTips has tipMask (0xFF default) for Hamilton service tooling; DropTips + has NO tip mask — all probes drop together unconditionally. +- Aspirate / Dispense: StructArray with exactly ONE entry. The gantry moves to + the probe-0 (row A) reference position and all 8 probes operate simultaneously. + Channel field = ChannelIndex.MPHChannel. + +Physical arrangement +-------------------- +Probes are ordered by Y (highest Y = probe 0 = row A). Pitch = PROBE_PITCH_MM. +""" + +from __future__ import annotations + +import logging +import struct as _struct +from typing import TYPE_CHECKING, Awaitable, Callable, List, Literal, Optional, Sequence, Union + +from pylabrobot.hamilton.liquid_class_resolver import ( + corrected_volumes_for_ops, + resolve_hamilton_liquid_classes, +) +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.resources import Container, Coordinate, Tip, Trash +from pylabrobot.resources.resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + all_channels_succeeded, + finalize_tip_ops, + finalize_volume_ops, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker +from pylabrobot.resources.well import Well + +from . import prep_commands as PrepCmd +from .channels import ( + LLDMode, + _absolute_z_from_well, + _build_container_segments, + _effective_radius, + _LldDefaults, +) +from .channels import ( + default_lld_params as _default_lld_params_fn, +) +from .channels import ( + lld_for_well as _lld_for_well_fn, +) +from .channels import ( + lld_seek_timeout as _lld_seek_timeout, +) +from .channels import ( + patch_common_with_cone as _patch_common_with_cone_fn, +) +from .channels import ( + resolve_command_version as _resolve_command_version_fn, +) +from .client import MPH_OBJECT_PATH + +if TYPE_CHECKING: + from .client import PrepClient + from .info import PrepInstrumentInfo + +logger = logging.getLogger(__name__) + +PROBE_PITCH_MM: float = 9.0 +NUM_PROBES: int = 8 +_FULL_TIP_MASK: int = 0xFF +_V2_MPH_CMD_IDS: frozenset = frozenset({29, 30, 31, 32, 33, 34}) +_PROBE_POS_TOLERANCE_MM: float = 1.0 # max deviation from expected 9mm pitch before raising + + +class PrepHead8: + """8-channel Multi-Pipetting Head for the Hamilton Prep. + + All 8 probes must participate in every operation. Partial channel selection + is rejected at this layer because the head is physically ganged (single drive + per axis, single piston) and partial sleeve engagement produces insufficient + grip force. + """ + + # Command dispatch tables: (effective_lld, is_tadm, use_v2) → command class + _ASPIRATE_CMD = { + (True, True, True): PrepCmd.MphAspirateWithLldTadm2, + (True, True, False): PrepCmd.MphAspirateWithLldTadm, + (True, False, True): PrepCmd.MphAspirateWithLld2, + (True, False, False): PrepCmd.MphAspirateWithLld, + (False, True, True): PrepCmd.MphAspirateTadm2, + (False, True, False): PrepCmd.MphAspirateTadm, + (False, False, True): PrepCmd.MphAspirateNoLldMonitoring2, + (False, False, False): PrepCmd.MphAspirateNoLldMonitoring, + } + + # Command dispatch tables: (effective_lld, use_v2) → command class + _DISPENSE_CMD = { + (True, True): PrepCmd.MphDispenseWithLld2, + (True, False): PrepCmd.MphDispenseWithLld, + (False, True): PrepCmd.MphDispenseNoLld2, + (False, False): PrepCmd.MphDispenseNoLld, + } + + def __init__( + self, + *, + client: "PrepClient", + info: "PrepInstrumentInfo", + default_traverse_height: Optional[float] = None, + use_v1_aspirate_dispense: bool = False, + ) -> None: + self._client = client + self._info = info + self._user_traverse_height = default_traverse_height + self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense + self.channels: list = [] # populated by build_prep_channels after construction + self._supports_v2_pipetting: Optional[bool] = None + self.head: dict[int, TipTracker] = { + i: TipTracker(thing=f"Head8 channel {i}") for i in range(NUM_PROBES) + } + + # --------------------------------------------------------------------------- + # Setup / V2 probing + # --------------------------------------------------------------------------- + + async def _probe_v2_support(self) -> bool: + """Return True if the MPH firmware exposes V2 aspirate/dispense (cmds 29-34).""" + dest = await self._client.resolve_path(MPH_OBJECT_PATH) + methods = await self._client.introspection.methods_for_interface(dest, interface_id=1) + iface1_ids = {m.method_id for m in methods} + return _V2_MPH_CMD_IDS.issubset(iface1_ids) + + async def _on_setup(self) -> None: + if self._use_v1_aspirate_dispense: + self._supports_v2_pipetting = False + logger.info("MPH V2 aspirate/dispense probe skipped (use_v1_aspirate_dispense=True)") + else: + try: + supported = await self._probe_v2_support() + except Exception as e: + logger.warning("MPH V2 support probe failed: %s", e) + supported = False + if not supported: + raise RuntimeError( + "V2 aspirate/dispense commands (cmd 29-34) are not supported by this MPH firmware. " + "Pass use_v1_aspirate_dispense=True to PrepHead8 to use v1 commands instead." + ) + self._supports_v2_pipetting = True + logger.info("MPH V2 aspirate/dispense support: True") + + async def _on_stop(self) -> None: + self._supports_v2_pipetting = None + for tracker in self.head.values(): + tracker.clear() + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Tips currently mounted on the 8MPH (``None`` if empty).""" + return [self.head[i].get_tip() if self.head[i].has_tip else None for i in range(NUM_PROBES)] + + async def _finalize_head8_command( + self, + use_channels: Sequence[int], + *, + tip_intents: Optional[Sequence[Union[TipPickupIntent, TipDropIntent]]] = None, + volume_intents: Optional[Sequence[VolumeTransferIntent]] = None, + send: Callable[[], Awaitable[None]], + ) -> None: + error: Optional[BaseException] = None + try: + await send() + successes = all_channels_succeeded(use_channels) + except ChannelizedError as e: + error = e + successes = successes_from_failed_channels(use_channels, e.errors) + except BaseException as e: + error = e + successes = {ch: False for ch in use_channels} + if tip_intents is not None: + finalize_tip_ops(tip_intents, successes) + if volume_intents is not None: + finalize_volume_ops(volume_intents, successes) + if error is not None: + raise error + + # --------------------------------------------------------------------------- + # Internal helpers + # --------------------------------------------------------------------------- + + def _resolve_command_version(self, override: Optional[Literal["v1", "v2"]] = None) -> bool: + return _resolve_command_version_fn( + self._supports_v2_pipetting, + self._use_v1_aspirate_dispense, + override, + v2_error_hint=( + "v2 aspirate/dispense commands (cmd 29-34) are not supported by this firmware. " + "Use command_version='v1' or pass use_v1_aspirate_dispense=True to PrepHead8." + ), + ) + + def _resolve_traverse_height(self, final_z: Optional[float] = None) -> float: + if final_z is not None: + return final_z + if self._user_traverse_height is not None: + return self._user_traverse_height + height: Optional[float] = self._info.config.default_traverse_height + if height is None: + raise RuntimeError("No traverse height available; set default_traverse_height") + return height + + def _resolve_probe_positions(self, wells) -> List[float]: + """Compute expected probe Y positions and validate actual well Ys match. + + Probe 0 = row A = highest Y. Expected position for probe i: + wells[0].y - i * PROBE_PITCH_MM + + Works for any labware at 9mm pitch: standard 96-well columns, or + interleaved 384-well selections (every other row = 2 × 4.5mm = 9mm). + + Returns the expected Y values (one per probe) for logging/accounting. + Raises ValueError if any well deviates beyond _PROBE_POS_TOLERANCE_MM. + """ + ref_y = wells[0].get_absolute_location("c", "c", "cavity_bottom").y + expected_ys = [ref_y - i * PROBE_PITCH_MM for i in range(len(wells))] + + mismatches = [] + for i, (well, exp_y) in enumerate(zip(wells, expected_ys)): + actual_y = well.get_absolute_location("c", "c", "cavity_bottom").y + if abs(actual_y - exp_y) > _PROBE_POS_TOLERANCE_MM: + mismatches.append( + f" probe {i} ({well.name}): expected y={exp_y:.2f}, actual y={actual_y:.2f}" + ) + + if mismatches: + actual_ys = [round(w.get_absolute_location("c", "c", "cavity_bottom").y, 2) for w in wells] + raise ValueError( + f"Wells are not at {PROBE_PITCH_MM} mm probe pitch from wells[0]. " + f"Pass wells in row-A-first order at {PROBE_PITCH_MM} mm spacing " + f"(for 384-well plates: every other row).\n" + + "\n".join(mismatches) + + f"\nActual Y values: {actual_ys}" + ) + + return expected_ys + + def _validate_container_span(self, container) -> None: + """Raise ValueError if the container is too narrow for all 8 probes. + + Minimum Y span = (NUM_PROBES - 1) * PROBE_PITCH_MM = 63 mm. + """ + min_span = (NUM_PROBES - 1) * PROBE_PITCH_MM + span = container.get_size_y() + if span < min_span: + raise ValueError( + f"Container '{container.name}' Y span ({span:.1f} mm) is too narrow for " + f"{NUM_PROBES} probes at {PROBE_PITCH_MM} mm pitch " + f"(minimum {min_span:.1f} mm required)." + ) + + def _require_all_channels(self, use_channels: List[int], op: str) -> None: + """Raise ValueError unless use_channels is exactly [0..7]. + + The 8MPH is a ganged head — all 8 probes must participate in every operation. + Partial channel selection produces insufficient tip grip force (physical + constraint confirmed via firmware/hardware inspection). + """ + if list(use_channels) != list(range(NUM_PROBES)): + raise ValueError( + f"PrepHead8.{op}: the 8MPH is a fully-ganged head — all {NUM_PROBES} " + f"channels must participate. Received use_channels={use_channels}. " + "Partial tip pickup/drop/aspirate/dispense is not physically supported." + ) + + def _resolve_effective_lld( + self, + lld_mode: Optional[LLDMode], + lld: Optional[PrepCmd.LldParameters], + *, + allowed_modes: Optional[frozenset] = None, + ) -> bool: + """Determine whether LLD is active for this MPH pipetting call. + + Unlike the PIP backend (which takes a per-channel list), the MPH accepts a + single LLDMode because the ganged head operates as one unit. + """ + if lld_mode is not None: + if lld_mode != LLDMode.OFF: + if allowed_modes is not None and lld_mode not in allowed_modes: + raise ValueError( + f"Dispense does not support {lld_mode.name} LLD — only CAPACITIVE or OFF. " + "Pressure-based LLD requires aspiration (plunger movement)." + ) + return True + return False + return lld is not None + + # --------------------------------------------------------------------------- + # Aspirate assembly helpers + # --------------------------------------------------------------------------- + + def _assemble_aspirate_v2( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + prewet_volume: float, + blowout_volume: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + is_tadm: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + tadm: PrepCmd.TadmParameters, + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm2, + PrepCmd.AspirateParametersLldAndMonitoring2, + PrepCmd.AspirateParametersNoLldAndTadm2, + PrepCmd.AspirateParametersNoLldAndMonitoring2, + ]: + aspirate = PrepCmd.AspirateParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + prewet_volume=prewet_volume, + blowout_volume=blowout_volume, + ) + common = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + no_lld = PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + tadm=tadm, + adc=adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + adc=adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + ) + + def _assemble_aspirate_v1( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + prewet_volume: float, + blowout_volume: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + is_tadm: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + tadm: PrepCmd.TadmParameters, + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm, + PrepCmd.AspirateParametersLldAndMonitoring, + PrepCmd.AspirateParametersNoLldAndTadm, + PrepCmd.AspirateParametersNoLldAndMonitoring, + ]: + aspirate = PrepCmd.AspirateParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + prewet_volume=prewet_volume, + blowout_volume=blowout_volume, + ) + common_v2 = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + common = _patch_common_with_cone_fn(common_v2, segments) + no_lld = PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + tadm=tadm, + adc=adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + adc=adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + ) + + # --------------------------------------------------------------------------- + # Dispense assembly helpers + # --------------------------------------------------------------------------- + + def _assemble_dispense_v2( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + stop_back_volume: float, + cutoff_speed: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + ) -> Union[PrepCmd.DispenseParametersLld2, PrepCmd.DispenseParametersNoLld2]: + dispense = PrepCmd.DispenseParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + common = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + tadm = PrepCmd.TadmParameters.default() + + if effective_lld: + return PrepCmd.DispenseParametersLld2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + container_description=segments, + common=common, + lld=lld_params, + c_lld=lld_defaults.c_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + container_description=segments, + common=common, + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ), + mix=mix, + adc=adc, + tadm=tadm, + ) + + def _assemble_dispense_v1( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + stop_back_volume: float, + cutoff_speed: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + ) -> Union[PrepCmd.DispenseParametersLld, PrepCmd.DispenseParametersNoLld]: + dispense = PrepCmd.DispenseParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + common_v2 = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + common = _patch_common_with_cone_fn(common_v2, segments) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + tadm = PrepCmd.TadmParameters.default() + + if effective_lld: + return PrepCmd.DispenseParametersLld( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + common=common, + lld=lld_params, + c_lld=lld_defaults.c_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + common=common, + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ), + mix=mix, + adc=adc, + tadm=tadm, + ) + + # --------------------------------------------------------------------------- + # MPH gantry (IMph MoveToPosition) + # --------------------------------------------------------------------------- + + async def move_to_position( + self, + x: float, + y: float, + z: float, + *, + via_lane: bool = False, + ) -> None: + """Move the ganged 8-channel head to absolute deck ``(x, y, z)`` (mm). + + Sends :class:`~prep_commands.MphMoveToPosition` or + :class:`~prep_commands.MphMoveToPositionViaLane` on ``MLPrepRoot.MphRoot.MPH``. + One pose for the whole head — unlike independent-channel ``move_to_position``, + there are no per-channel ``y``/``z`` lists. + + Args: + x: Gantry X. + y: Gantry Y at the probe-0 (row A) reference. + z: Z height (e.g. traverse). + via_lane: Use lane-aware move when True. + """ + if via_lane: + await self._client.send_command( + PrepCmd.MphMoveToPositionViaLane(x_position=x, y_position=y, z_position=z) + ) + else: + await self._client.send_command( + PrepCmd.MphMoveToPosition(x_position=x, y_position=y, z_position=z) + ) + + # --------------------------------------------------------------------------- + # Tip / aspirate / dispense + # --------------------------------------------------------------------------- + + def _require_mounted_tips(self) -> List[Tip]: + tips: List[Tip] = [] + for i in range(NUM_PROBES): + tracker = self.head[i] + if not tracker.has_tip: + raise RuntimeError("No tips mounted on head8; call pick_up_tips8 first.") + tips.append(tracker.get_tip()) + return tips + + def _require_mounted_tip(self) -> Tip: + return self._require_mounted_tips()[0] + + async def pick_up_tips8( + self, + tip_spots: Sequence[TipSpot], + use_channels: Optional[Sequence[int]] = None, + *, + offset: Coordinate = Coordinate.zero(), + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + enable_tadm: bool = False, + dispenser_volume: float = 0.0, + dispenser_speed: float = 250.0, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + pre_position: bool = True, + ) -> None: + tip_spots = list(tip_spots) + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) + self._require_all_channels(use_channels, "pick_up_tips8") + if len(tip_spots) != NUM_PROBES: + raise ValueError(f"pick_up_tips8 requires {NUM_PROBES} tip spots, got {len(tip_spots)}") + resolved_final_z = self._resolve_traverse_height(final_z) + + tips = [s.get_tip() for s in tip_spots] + ref_spot = tip_spots[0] + tip = tips[0] + rack = ref_spot.parent + logger.info( + "[Prep MPH] pick_up_tips: rack=%s, tip_spots=%s", + rack.name if rack is not None else ref_spot.name, + [s.name.rsplit("_", 1)[-1] for s in tip_spots], + ) + loc = ref_spot.get_absolute_location("c", "c", "t") + offset + + if pre_position: + traverse_h = minimum_traverse_height_at_beginning_of_a_command or resolved_final_z + await self.move_to_position(loc.x, loc.y, traverse_h) + + tip_position = PrepCmd.TipPositionParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, loc, tip, z_seek_offset=z_seek_offset + ) + tip_definition = PrepCmd.TipPickupParameters( + default_values=False, + volume=tip.maximal_volume, + length=tip.total_tip_length - tip.fitting_depth, + tip_type=PrepCmd.TipTypes.StandardVolume, + has_filter=tip.has_filter, + is_needle=False, + is_tool=False, + ) + tip_intents = [ + TipPickupIntent( + channel=ch, + tip_spot=spot, + tip=t, + channel_tracker=self.head[ch], + ) + for ch, spot, t in zip(use_channels, tip_spots, tips) + ] + queue_tip_pickups(tip_intents) + + async def _send() -> None: + await self._client.send_command( + PrepCmd.MphPickupTips( + tip_position=tip_position, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_definition=tip_definition, + enable_tadm=enable_tadm, + dispenser_volume=dispenser_volume, + dispenser_speed=dispenser_speed, + tip_mask=_FULL_TIP_MASK, + ) + ) + + await self._finalize_head8_command(use_channels, tip_intents=tip_intents, send=_send) + + async def drop_tips8( + self, + destinations: Sequence[Union[TipSpot, Trash]], + use_channels: Optional[Sequence[int]] = None, + *, + offset: Coordinate = Coordinate.zero(), + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + tip_roll_off_distance: float = 0.0, + ) -> None: + destinations = list(destinations) + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) + self._require_all_channels(use_channels, "drop_tips8") + if len(destinations) != NUM_PROBES: + raise ValueError(f"drop_tips8 requires {NUM_PROBES} destinations, got {len(destinations)}") + tip = self._require_mounted_tip() + resolved_final_z = self._resolve_traverse_height(final_z) + + ref_spot = destinations[0] + is_trash = isinstance(ref_spot, Trash) + dest = ref_spot if is_trash else ref_spot.parent + logger.info( + "[Prep MPH] drop_tips: dest=%s, resources=%s", + dest.name if dest is not None else ref_spot.name, + [s.name.rsplit("_", 1)[-1] for s in destinations], + ) + + loc = ref_spot.get_absolute_location("c", "c", "t") + if not is_trash: + loc = loc + offset + drop_type = PrepCmd.TipDropType.Stall if is_trash else PrepCmd.TipDropType.FixedHeight + + tip_position = PrepCmd.TipDropParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, + loc, + tip, + z_seek_offset=z_seek_offset, + drop_type=drop_type, + ) + roll_off = 3.0 if (is_trash and tip_roll_off_distance == 0.0) else tip_roll_off_distance + mounted = self._require_mounted_tips() + tip_intents = [ + TipDropIntent( + channel=ch, + destination=dest, + tip=mounted[ch], + channel_tracker=self.head[ch], + ) + for ch, dest in zip(use_channels, destinations) + ] + queue_tip_drops(tip_intents) + + async def _send() -> None: + await self._client.send_command( + PrepCmd.MphDropTips( + tip_position=tip_position, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=roll_off, + ) + ) + + await self._finalize_head8_command(use_channels, tip_intents=tip_intents, send=_send) + + async def aspirate8( + self, + wells: Optional[Sequence[Well]] = None, + *, + container: Optional[Container] = None, + volume: float, + use_channels: Optional[Sequence[int]] = None, + offset: Coordinate = Coordinate.zero(), + liquid_height: Optional[float] = None, + flow_rate: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + z_final: Optional[float] = None, + z_fluid: Optional[float] = None, + z_air: Optional[float] = None, + z_minimum: Optional[float] = None, + settling_time: Optional[float] = None, + transport_air_volume: Optional[float] = None, + z_liquid_exit_speed: Optional[float] = None, + prewet_volume: Optional[float] = None, + z_bottom_search_offset: Optional[float] = None, + lld_mode: Optional[LLDMode] = None, + lld: Optional[PrepCmd.LldParameters] = None, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + tadm: Optional[PrepCmd.TadmParameters] = None, + container_segments: Optional[List[PrepCmd.SegmentDescriptor]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[ + Union[HamiltonLiquidClass, List[Optional[HamiltonLiquidClass]]] + ] = None, + disable_volume_correction: bool = False, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ) -> None: + del offset # geometry uses well/container absolute locations + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) + self._require_all_channels(use_channels, "aspirate8") + if (wells is None) == (container is None): + raise ValueError("aspirate8 requires exactly one of wells= or container=") + tip = self._require_mounted_tip() + + explicit: Optional[List[Optional[HamiltonLiquidClass]]] + if isinstance(hamilton_liquid_classes, HamiltonLiquidClass) or hamilton_liquid_classes is None: + explicit = None if hamilton_liquid_classes is None else [hamilton_liquid_classes] + else: + explicit = list(hamilton_liquid_classes) + if len(explicit) == NUM_PROBES: + explicit = [explicit[0]] + elif len(explicit) != 1: + raise ValueError("hamilton_liquid_classes must be a single HLC or length-8 list") + + class _TipVol: + def __init__(self, tip: Tip, volume: float): + self.tip = tip + self.volume = volume + + tip_vol = _TipVol(tip, float(volume)) + hlcs = resolve_hamilton_liquid_classes(explicit, [tip_vol], jet=False, blow_out=False) + hlc = hlcs[0] + corrected = corrected_volumes_for_ops([tip_vol], hlcs, [disable_volume_correction])[0] + + traverse_z = self._resolve_traverse_height() + final_z_resolved = ( + z_final if z_final is not None else traverse_z - (tip.total_tip_length - tip.fitting_depth) + ) + + if container is not None: + self._validate_container_span(container) + resource_name = container.parent.name if container.parent is not None else container.name + op_targets: Union[str, List[str]] = container.name + loc = container.get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = loc.x, loc.y + 3.5 * PROBE_PITCH_MM + wg = _absolute_z_from_well(container, liquid_height) + ref_segments = container_segments or ( + _build_container_segments(container) if auto_container_geometry else [] + ) + ref_resource = container + else: + wells_list = list(wells) # type: ignore[arg-type] + if len(wells_list) != NUM_PROBES: + raise ValueError(f"aspirate8 requires {NUM_PROBES} wells, got {len(wells_list)}") + self._resolve_probe_positions(wells_list) + resource_name = ( + wells_list[0].parent.name if wells_list[0].parent is not None else wells_list[0].name + ) + op_targets = [w.name.rsplit("_", 1)[-1] for w in wells_list] + ref_loc = wells_list[0].get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = ref_loc.x, ref_loc.y + wg = _absolute_z_from_well(wells_list[0], liquid_height) + ref_segments = container_segments or ( + _build_container_segments(wells_list[0]) if auto_container_geometry else [] + ) + ref_resource = wells_list[0] + + resolved_z_fluid = z_fluid if z_fluid is not None else wg.liquid_surface + resolved_z_air = z_air if z_air is not None else wg.z_air + resolved_z_minimum = z_minimum if z_minimum is not None else wg.well_bottom + resolved_z_bottom_search_offset = ( + z_bottom_search_offset if z_bottom_search_offset is not None else 2.0 + ) + resolved_settling_time = ( + settling_time + if settling_time is not None + else (hlc.aspiration_settling_time if hlc is not None else 1.0) + ) + resolved_transport_air_volume = ( + transport_air_volume + if transport_air_volume is not None + else (hlc.aspiration_air_transport_volume if hlc is not None else 0.0) + ) + resolved_z_liquid_exit_speed = ( + z_liquid_exit_speed + if z_liquid_exit_speed is not None + else (hlc.aspiration_swap_speed if hlc is not None else 10.0) + ) + resolved_prewet_volume = ( + prewet_volume + if prewet_volume is not None + else (hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0) + ) + resolved_flow = ( + flow_rate + if flow_rate is not None + else (hlc.aspiration_flow_rate if hlc is not None else 100.0) + ) + blowout_volume = ( + blow_out_air_volume + if blow_out_air_volume is not None + else (hlc.aspiration_blow_out_volume if hlc is not None else 0.0) + ) + + logger.info( + "[Prep MPH] aspirate: resource=%s, wells=%s, volume=%.3f, flow_rate=%s", + resource_name, + op_targets, + corrected, + round(resolved_flow, 3), + ) + + tube_radius = _effective_radius(ref_resource) + effective_lld = self._resolve_effective_lld(lld_mode, lld) + is_tadm = tadm is not None + use_v2 = self._resolve_command_version(command_version) + + lld_defaults = _default_lld_params_fn(effective_lld, p_lld, c_lld) + lld_params = _lld_for_well_fn(effective_lld, lld, wg.top_of_well) + resolved_tadm = tadm or PrepCmd.TadmParameters.default() + + assemble = self._assemble_aspirate_v2 if use_v2 else self._assemble_aspirate_v1 + param_struct = assemble( + ref_x=ref_x, + ref_y=ref_y, + volume=corrected, + tube_radius=tube_radius, + final_z=final_z_resolved, + z_minimum=resolved_z_minimum, + z_fluid=resolved_z_fluid, + z_air=resolved_z_air, + z_bottom_search_offset=resolved_z_bottom_search_offset, + settling_time=resolved_settling_time, + transport_air_volume=resolved_transport_air_volume, + z_liquid_exit_speed=resolved_z_liquid_exit_speed, + prewet_volume=resolved_prewet_volume, + blowout_volume=blowout_volume, + flow_rate=resolved_flow, + segments=ref_segments, + effective_lld=effective_lld, + is_tadm=is_tadm, + lld_params=lld_params, + lld_defaults=lld_defaults, + tadm=resolved_tadm, + ) + + cmd_cls = self._ASPIRATE_CMD[(effective_lld, is_tadm, use_v2)] + + resolved_read_timeout = read_timeout + if resolved_read_timeout is None and effective_lld: + resolved_read_timeout = _lld_seek_timeout(lld_params, resolved_z_minimum) + + mounted = self._require_mounted_tips() + if container is not None: + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=container, + tip=mounted[ch], + volume_ul=corrected, + direction="aspirate", + ) + for ch in use_channels + ] + else: + wells_list = list(wells) # type: ignore[arg-type] + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=well, + tip=mounted[ch], + volume_ul=corrected, + direction="aspirate", + ) + for ch, well in zip(use_channels, wells_list) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._client.send_command( + cmd_cls(aspirate_parameters=[param_struct]), # type: ignore[arg-type] + read_timeout=resolved_read_timeout if effective_lld else None, + ) + + await self._finalize_head8_command(use_channels, volume_intents=volume_intents, send=_send) + + async def dispense8( + self, + wells: Optional[Sequence[Well]] = None, + *, + container: Optional[Container] = None, + volume: float, + use_channels: Optional[Sequence[int]] = None, + offset: Coordinate = Coordinate.zero(), + liquid_height: Optional[float] = None, + flow_rate: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + z_final: Optional[float] = None, + z_fluid: Optional[float] = None, + z_air: Optional[float] = None, + z_minimum: Optional[float] = None, + settling_time: Optional[float] = None, + transport_air_volume: Optional[float] = None, + z_liquid_exit_speed: Optional[float] = None, + stop_back_volume: Optional[float] = None, + cutoff_speed: Optional[float] = None, + z_bottom_search_offset: Optional[float] = None, + lld_mode: Optional[LLDMode] = None, + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[PrepCmd.SegmentDescriptor]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[ + Union[HamiltonLiquidClass, List[Optional[HamiltonLiquidClass]]] + ] = None, + disable_volume_correction: bool = False, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ) -> None: + del offset + del blow_out_air_volume # dispense blowout not on Prep dispense wire path today + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) + self._require_all_channels(use_channels, "dispense8") + if (wells is None) == (container is None): + raise ValueError("dispense8 requires exactly one of wells= or container=") + tip = self._require_mounted_tip() + + explicit: Optional[List[Optional[HamiltonLiquidClass]]] + if isinstance(hamilton_liquid_classes, HamiltonLiquidClass) or hamilton_liquid_classes is None: + explicit = None if hamilton_liquid_classes is None else [hamilton_liquid_classes] + else: + explicit = list(hamilton_liquid_classes) + if len(explicit) == NUM_PROBES: + explicit = [explicit[0]] + elif len(explicit) != 1: + raise ValueError("hamilton_liquid_classes must be a single HLC or length-8 list") + + class _TipVol: + def __init__(self, tip: Tip, volume: float): + self.tip = tip + self.volume = volume + + tip_vol = _TipVol(tip, float(volume)) + hlcs = resolve_hamilton_liquid_classes(explicit, [tip_vol], jet=False, blow_out=False) + hlc = hlcs[0] + corrected = corrected_volumes_for_ops([tip_vol], hlcs, [disable_volume_correction])[0] + + traverse_z = self._resolve_traverse_height() + final_z_resolved = ( + z_final if z_final is not None else traverse_z - (tip.total_tip_length - tip.fitting_depth) + ) + + if container is not None: + self._validate_container_span(container) + resource_name = container.parent.name if container.parent is not None else container.name + op_targets: Union[str, List[str]] = container.name + loc = container.get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = loc.x, loc.y + 3.5 * PROBE_PITCH_MM + wg = _absolute_z_from_well(container, liquid_height) + ref_segments = container_segments or ( + _build_container_segments(container) if auto_container_geometry else [] + ) + ref_resource = container + else: + wells_list = list(wells) # type: ignore[arg-type] + if len(wells_list) != NUM_PROBES: + raise ValueError(f"dispense8 requires {NUM_PROBES} wells, got {len(wells_list)}") + self._resolve_probe_positions(wells_list) + resource_name = ( + wells_list[0].parent.name if wells_list[0].parent is not None else wells_list[0].name + ) + op_targets = [w.name.rsplit("_", 1)[-1] for w in wells_list] + ref_loc = wells_list[0].get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = ref_loc.x, ref_loc.y + wg = _absolute_z_from_well(wells_list[0], liquid_height) + ref_segments = container_segments or ( + _build_container_segments(wells_list[0]) if auto_container_geometry else [] + ) + ref_resource = wells_list[0] + + resolved_z_fluid = z_fluid if z_fluid is not None else wg.liquid_surface + resolved_z_air = z_air if z_air is not None else wg.z_air + resolved_z_minimum = z_minimum if z_minimum is not None else wg.well_bottom + resolved_z_bottom_search_offset = ( + z_bottom_search_offset if z_bottom_search_offset is not None else 2.0 + ) + resolved_settling_time = ( + settling_time + if settling_time is not None + else (hlc.dispense_settling_time if hlc is not None else 0.0) + ) + resolved_transport_air_volume = ( + transport_air_volume + if transport_air_volume is not None + else (hlc.dispense_air_transport_volume if hlc is not None else 0.0) + ) + resolved_z_liquid_exit_speed = ( + z_liquid_exit_speed + if z_liquid_exit_speed is not None + else (hlc.dispense_swap_speed if hlc is not None else 10.0) + ) + resolved_stop_back_volume = ( + stop_back_volume + if stop_back_volume is not None + else (hlc.dispense_stop_back_volume if hlc is not None else 0.0) + ) + resolved_cutoff_speed = ( + cutoff_speed + if cutoff_speed is not None + else (hlc.dispense_stop_flow_rate if hlc is not None else 100.0) + ) + resolved_flow = ( + flow_rate if flow_rate is not None else (hlc.dispense_flow_rate if hlc is not None else 100.0) + ) + + logger.info( + "[Prep MPH] dispense: resource=%s, wells=%s, volume=%.3f, flow_rate=%s", + resource_name, + op_targets, + corrected, + round(resolved_flow, 3), + ) + + tube_radius = _effective_radius(ref_resource) + _DISPENSE_ALLOWED_LLD = frozenset({LLDMode.CAPACITIVE}) + effective_lld = self._resolve_effective_lld(lld_mode, lld, allowed_modes=_DISPENSE_ALLOWED_LLD) + use_v2 = self._resolve_command_version(command_version) + + lld_defaults = _default_lld_params_fn(effective_lld, c_lld=c_lld) + lld_params = _lld_for_well_fn(effective_lld, lld, wg.top_of_well) + + assemble = self._assemble_dispense_v2 if use_v2 else self._assemble_dispense_v1 + param_struct = assemble( + ref_x=ref_x, + ref_y=ref_y, + volume=corrected, + tube_radius=tube_radius, + final_z=final_z_resolved, + z_minimum=resolved_z_minimum, + z_fluid=resolved_z_fluid, + z_air=resolved_z_air, + z_bottom_search_offset=resolved_z_bottom_search_offset, + settling_time=resolved_settling_time, + transport_air_volume=resolved_transport_air_volume, + z_liquid_exit_speed=resolved_z_liquid_exit_speed, + stop_back_volume=resolved_stop_back_volume, + cutoff_speed=resolved_cutoff_speed, + flow_rate=resolved_flow, + segments=ref_segments, + effective_lld=effective_lld, + lld_params=lld_params, + lld_defaults=lld_defaults, + ) + + cmd_cls = self._DISPENSE_CMD[(effective_lld, use_v2)] + + resolved_read_timeout = read_timeout + if resolved_read_timeout is None and effective_lld: + resolved_read_timeout = _lld_seek_timeout(lld_params, resolved_z_minimum) + + mounted = self._require_mounted_tips() + if container is not None: + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=container, + tip=mounted[ch], + volume_ul=corrected, + direction="dispense", + ) + for ch in use_channels + ] + else: + wells_list = list(wells) # type: ignore[arg-type] + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=well, + tip=mounted[ch], + volume_ul=corrected, + direction="dispense", + ) + for ch, well in zip(use_channels, wells_list) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._client.send_command( + cmd_cls(dispense_parameters=[param_struct]), # type: ignore[arg-type] + read_timeout=resolved_read_timeout if effective_lld else None, + ) + + await self._finalize_head8_command(use_channels, volume_intents=volume_intents, send=_send) + + # --------------------------------------------------------------------------- + # Tip presence sensing + # --------------------------------------------------------------------------- + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Sense whether tips are present on the 8MPH head via the sleeve sensor (cmd=15). + + The 8MPH is a single ganged controller — the firmware tree exposes one sleeve + sensor node (on the probe-0 / channel-0 entry). The result is broadcast across + all 8 positions since the head picks up and drops all probes together. + + Returns: + 8-element list. True=tips detected, False=no tips, None=sensor unavailable. + """ + if not self.channels: + raise RuntimeError("MPH channels not populated; call build_prep_channels first.") + + addr = getattr(self.channels[0], "sleeve_sensor", None) + if addr is None: + return [None] * NUM_PROBES + + raw = await self._client.send_query(PrepCmd.PrepProbeRequest(dest=addr, command_id=15)) + if raw is None or len(raw[0]) < 8: + result = False + else: + val = _struct.unpack_from(" Address: + """Resolve a diagnostic path alias; raises if absent.""" + if key not in self._paths: + raise KeyError(f"unknown info path key: {key!r}") + return await self._driver.resolve_path(self._paths[key]) + + async def _try_require(self, key: str) -> Optional[Address]: + """Resolve a diagnostic path alias; returns ``None`` if the path is absent.""" + try: + return await self._require(key) + except (KeyError, RuntimeError, TypeError): + return None + + # -- Lifecycle -------------------------------------------------------------- + + async def _on_setup(self) -> None: + """Fetch and cache the instrument config. Called from :meth:`Prep.setup`.""" + self._config = await self._load_instrument_config() + + async def _on_stop(self) -> None: + self._config = None + + # -- Cached config ---------------------------------------------------------- + + @property + def config(self) -> PrepCmd.InstrumentConfig: + """Cached ``InstrumentConfig``. Raises if ``_on_setup`` has not run.""" + if self._config is None: + raise RuntimeError("Instrument config not available. Call Prep.setup() first.") + return self._config + + @property + def num_channels(self) -> int: + n = self.config.num_channels + if n is None: + raise RuntimeError("Instrument config has no num_channels (finish Prep.setup first).") + return n + + @property + def has_mph(self) -> bool: + h = self.config.has_mph + if h is None: + raise RuntimeError("Instrument config has no has_mph (finish Prep.setup first).") + return h + + @property + def deck_bounds(self) -> Optional[PrepCmd.DeckBounds]: + return self.config.deck_bounds + + @property + def deck_sites(self) -> Tuple[PrepCmd.DeckSiteInfo, ...]: + return self.config.deck_sites + + @property + def waste_sites(self) -> Tuple[PrepCmd.WasteSiteInfo, ...]: + return self.config.waste_sites + + @property + def default_traverse_height(self) -> Optional[float]: + return self.config.default_traverse_height + + @property + def has_enclosure(self) -> bool: + return self.config.has_enclosure + + @property + def safe_speeds_enabled(self) -> bool: + return self.config.safe_speeds_enabled + + async def refresh(self) -> PrepCmd.InstrumentConfig: + """Re-query instrument config and update the cached snapshot.""" + self._config = await self._load_instrument_config() + return self._config + + # -- Instrument config (MLPrep / deck / service) ---------------------------- + + async def get_present_channels(self) -> Optional[Tuple[PrepCmd.ChannelIndex, ...]]: + """Query which channels are present (GetPresentChannels on MLPrepService).""" + d = self._driver + service_addr = await self._try_require("mlprep_service") + if service_addr is None: + return None + try: + resp = await d.send_command(PrepCmd.PrepGetPresentChannels(dest=service_addr)) + if resp is None or not getattr(resp, "channels", None): + return None + return tuple( + PrepCmd.ChannelIndex(v) if v in (0, 1, 2, 3) else PrepCmd.ChannelIndex.InvalidIndex + for v in resp.channels + ) + except ( + TimeoutError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + BrokenPipeError, + OSError, + ): + raise + except Exception as e: + logger.warning("Failed to query present channels: %s", e) + return None + + async def _load_instrument_config(self) -> PrepCmd.InstrumentConfig: + """Aggregate MLPrep, DeckConfiguration, and MLPrepService into ``InstrumentConfig``.""" + d = self._driver + mlprep = d.mlprep_address + enc_resp = await d.send_command(PrepCmd.PrepGetIsEnclosurePresent(dest=mlprep)) + safe_resp = await d.send_command(PrepCmd.PrepGetSafeSpeedsEnabled(dest=mlprep)) + height_resp = await d.send_command(PrepCmd.PrepGetDefaultTraverseHeight(dest=mlprep)) + has_enclosure = bool(enc_resp.value) if enc_resp else False + safe_speeds_enabled = bool(safe_resp.value) if safe_resp else False + default_traverse_height = float(height_resp.value) if height_resp else None + + deck_bounds: Optional[PrepCmd.DeckBounds] = None + deck_sites: Tuple[PrepCmd.DeckSiteInfo, ...] = () + waste_sites: Tuple[PrepCmd.WasteSiteInfo, ...] = () + deck_addr = await self._try_require("deck_config") + if deck_addr is None: + raise RuntimeError("DeckConfiguration path did not resolve — cannot load instrument config") + + bounds_resp = await d.send_command(PrepCmd.PrepGetDeckBounds(dest=deck_addr)) + if bounds_resp: + deck_bounds = PrepCmd.DeckBounds( + min_x=bounds_resp.min_x, + max_x=bounds_resp.max_x, + min_y=bounds_resp.min_y, + max_y=bounds_resp.max_y, + min_z=bounds_resp.min_z, + max_z=bounds_resp.max_z, + ) + + sites_resp = await d.send_command(PrepCmd.PrepGetDeckSiteDefinitions(dest=deck_addr)) + if sites_resp and sites_resp.sites: + deck_sites = tuple( + PrepCmd.DeckSiteInfo( + id=int(s.id), + left_bottom_front_x=float(s.left_bottom_front_x), + left_bottom_front_y=float(s.left_bottom_front_y), + left_bottom_front_z=float(s.left_bottom_front_z), + length=float(s.length), + width=float(s.width), + height=float(s.height), + ) + for s in sites_resp.sites + ) + logger.debug("Discovered %d deck sites", len(deck_sites)) + + waste_resp = await d.send_command(PrepCmd.PrepGetWasteSiteDefinitions(dest=deck_addr)) + if waste_resp and waste_resp.sites: + waste_sites = tuple( + PrepCmd.WasteSiteInfo( + index=int(s.index), + x_position=float(s.x_position), + y_position=float(s.y_position), + z_position=float(s.z_position), + z_seek=float(s.z_seek), + ) + for s in waste_resp.sites + ) + logger.debug("Discovered %d waste sites: %s", len(waste_sites), waste_sites) + + present = await self.get_present_channels() + if present is not None: + dual = [ + c + for c in present + if c in (PrepCmd.ChannelIndex.FrontChannel, PrepCmd.ChannelIndex.RearChannel) + ] + num_channels = len(dual) + has_mph = PrepCmd.ChannelIndex.MPHChannel in present + else: + num_channels = 2 + has_mph = False + + return PrepCmd.InstrumentConfig( + deck_bounds=deck_bounds, + has_enclosure=has_enclosure, + safe_speeds_enabled=safe_speeds_enabled, + deck_sites=deck_sites, + waste_sites=waste_sites, + default_traverse_height=default_traverse_height, + num_channels=num_channels, + has_mph=has_mph, + ) + + async def is_initialized(self) -> bool: + """Whether MLPrep reports as initialized (GetIsInitialized, cmd=2).""" + result = await self._driver.send_command( + PrepCmd.PrepGetIsInitialized(dest=self._driver.mlprep_address) + ) + if result is None: + return False + return bool(result.value) + + async def get_tip_and_needle_definitions(self) -> Tuple[PrepCmd.TipDefinition, ...]: + """Tip/needle definitions (GetTipAndNeedleDefinitions, cmd=11).""" + result = await self._driver.send_command( + PrepCmd.PrepGetTipAndNeedleDefinitions(dest=self._driver.mlprep_address) + ) + if result is None or not getattr(result, "definitions", None): + return () + return tuple(result.definitions) + + # -- Firmware string queries (orchestration; decode on PrepClient) ---------- + + async def get_firmware_version(self) -> Optional[str]: + addr = await self._try_require("mlprep_cpu") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=8) + + async def get_device_serial_number(self) -> Optional[str]: + addr = await self._try_require("mlprep_cpu") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=9) + + async def get_bootloader_version(self) -> Optional[str]: + addr = await self._try_require("mlprep_cpu") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=2, iface_id=2) + + async def get_module_part_number(self) -> Optional[str]: + addr = await self._try_require("module_information") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=5) + + async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTreeNode: + """Firmware object tree. ``print(await info.get_firmware_tree())`` for a diagnostic dump.""" + return await self._driver.introspection.get_firmware_tree(refresh=refresh) diff --git a/pylabrobot/hamilton/prep/method.py b/pylabrobot/hamilton/prep/method.py new file mode 100644 index 00000000000..c338becf657 --- /dev/null +++ b/pylabrobot/hamilton/prep/method.py @@ -0,0 +1,55 @@ +"""Prep method lifecycle service. + +Owns MLPrep method commands (``PrepMethodBegin`` / ``PrepMethodEnd`` / ``PrepMethodAbort``) +via ``PrepClient`` transport, and exposes an async context manager +(:meth:`PrepMethodLifecycle.run`) that calls ``abort`` on exception and ``end`` on +clean exit — mirrors the ``Prep.core_grippers()`` pattern in ``prep.py``. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, AsyncIterator + +from . import prep_commands as PrepCmd + +if TYPE_CHECKING: + from .client import PrepClient + + +class PrepMethodLifecycle: + """Method begin/end/abort + ``async with`` safety net.""" + + def __init__(self, driver: "PrepClient"): + self._driver = driver + + async def begin(self, automatic_pause: bool = False) -> None: + """Signal the start of a liquid-handling method.""" + await self._driver.send_command(PrepCmd.PrepMethodBegin(automatic_pause=automatic_pause)) + + async def end(self) -> None: + """Signal the end of a liquid-handling method.""" + await self._driver.send_command(PrepCmd.PrepMethodEnd()) + + async def abort(self) -> None: + """Abort the current method.""" + await self._driver.send_command(PrepCmd.PrepMethodAbort()) + + @asynccontextmanager + async def run(self, automatic_pause: bool = False) -> AsyncIterator["PrepMethodLifecycle"]: + """Bracket a liquid-handling block with ``begin`` / ``end``; ``abort`` on exception. + + Usage:: + + async with prep.method.run(): + await prep.channels.pick_up_tips(...) + await prep.channels.aspirate(...) + """ + await self.begin(automatic_pause=automatic_pause) + try: + yield self + except BaseException: + await self.abort() + raise + else: + await self.end() diff --git a/pylabrobot/hamilton/prep/prep.py b/pylabrobot/hamilton/prep/prep.py new file mode 100644 index 00000000000..5e62ccc5b48 --- /dev/null +++ b/pylabrobot/hamilton/prep/prep.py @@ -0,0 +1,269 @@ +"""Prep device: orchestrates transport, instrument info, and peer construction.""" + +from __future__ import annotations + +import asyncio +import logging +import random +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional, Tuple + +from pylabrobot.resources.deck import Deck +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers + +from . import prep_commands as PrepCmd +from .calibration import PrepCalibration +from .channels import PrepChannels, build_prep_channels +from .chatterbox import PrepChatterboxClient, PrepChatterboxInstrumentInfo +from .client import PrepClient +from .gripper import PrepGripper, PrepGripperArm +from .head8 import PrepHead8 +from .info import PrepInstrumentInfo +from .method import PrepMethodLifecycle + +logger = logging.getLogger(__name__) + + +class Prep: + """Hamilton Prep liquid handler. + + Setup constructs peers (``channels``, ``head8``, ``method``, ``calibration``, + gripper factory) directly. Firmware paths live on each :class:`PrepCommand` + subclass and are resolved JIT by :meth:`PrepClient.send_command`. + """ + + def __init__( + self, + deck: Deck, + chatterbox: bool = False, + host: Optional[str] = None, + port: int = 2000, + ): + if chatterbox: + client: PrepClient = PrepChatterboxClient() + else: + if not host: + raise ValueError("host must be provided when chatterbox is False.") + client = PrepClient(host=host, port=port) + self.client: PrepClient = client + self.deck = deck + self.info = PrepChatterboxInstrumentInfo(client) if chatterbox else PrepInstrumentInfo(client) + self._core_gripper_arm: Optional[PrepGripperArm] = None + self.channels: Optional[PrepChannels] = None + self.head8: Optional[PrepHead8] = None + self.gripper: Optional[PrepGripper] = None + self.method: Optional[PrepMethodLifecycle] = None + self.calibration: Optional[PrepCalibration] = None + self._setup_finished: bool = False + + async def setup( + self, + *, + smart: bool = True, + force_initialize: bool = False, + default_traverse_height: Optional[float] = None, + use_v1_aspirate_dispense: bool = False, + ): + """Connect, bootstrap info, initialize MLPrep, construct peers.""" + try: + await self.client.setup() + await self.info._on_setup() + await self._initialize_instrument(smart=smart, force_initialize=force_initialize) + + self.method = PrepMethodLifecycle(self.client) + self.calibration = PrepCalibration(driver=self.client, info=self.info) + channels = PrepChannels( + client=self.client, + info=self.info, + deck=self.deck, + default_traverse_height=default_traverse_height, + use_v1_aspirate_dispense=use_v1_aspirate_dispense, + ) + channels.channels = await build_prep_channels(self.client, self.info) + self.channels = channels + await channels._on_setup() + + if channels.has_mph: + head8 = PrepHead8( + client=self.client, + info=self.info, + default_traverse_height=default_traverse_height, + use_v1_aspirate_dispense=use_v1_aspirate_dispense, + ) + head8.channels = await build_prep_channels( + self.client, self.info, root_name="MPH Channel Root", num_channels=8 + ) + self.head8 = head8 + await head8._on_setup() + + self.gripper = PrepGripper(client=self.client, channels=channels) + self._setup_finished = True + except Exception: + await self.info._on_stop() + await self.client.stop() + raise + + async def _initialize_instrument(self, *, smart: bool, force_initialize: bool) -> None: + """Send ``MLPrep.Initialize`` when needed.""" + if not force_initialize: + try: + already = await self.info.is_initialized() + except Exception as e: + logger.error("GetIsInitialized failed; cannot decide whether to init: %s", e) + raise + if already: + logger.info("MLPrep already initialized, skipping Initialize") + return + + await self.client.send_command( + PrepCmd.PrepInitialize( + smart=smart, + tip_drop_params=PrepCmd.InitTipDropParameters( + default_values=True, + x_position=287.0, + rolloff_distance=3, + channel_parameters=[], + ), + ) + ) + logger.info( + "Prep initialization complete%s", + " (force_initialize=True)" if force_initialize else "", + ) + + async def stop(self): + if not self._setup_finished: + return + if self._core_gripper_arm is not None: + logger.warning( + "Prep.stop() called with CoRe grippers still mounted. " + "stop() only manages connection teardown and will NOT move the instrument. " + "Call `await prep.return_core_grippers()` first if you want the tools returned." + ) + self._core_gripper_arm = None + if self.channels is not None: + await self.channels._on_stop() + if self.head8 is not None: + await self.head8._on_stop() + await self.client.stop() + await self.info._on_stop() + self.channels = None + self.head8 = None + self.gripper = None + self.method = None + self.calibration = None + self._setup_finished = False + + # -- CoRe grippers ----------------------------------------------------------- + + @property + def core_gripper_arm(self) -> PrepGripperArm: + """The mounted CoRe gripper arm. Raises if grippers are not currently picked up.""" + if self._core_gripper_arm is None: + raise RuntimeError( + "CoRe grippers not mounted. Call `await prep.pick_up_core_grippers()` first, " + "or use `async with prep.core_grippers() as arm:`." + ) + return self._core_gripper_arm + + @property + def core_grippers_mounted(self) -> bool: + return self._core_gripper_arm is not None + + async def pick_up_core_grippers(self) -> PrepGripperArm: + """Pick up the CoRe gripper tools and return the mounted arm.""" + if self._core_gripper_arm is not None: + raise RuntimeError("CoRe grippers already mounted") + if self.channels is None or self.gripper is None: + raise RuntimeError("Prep.setup() has not run.") + + mount = self.deck.get_resource("core_grippers") + if not isinstance(mount, HamiltonCoreGrippers): + raise TypeError( + "deck must have a resource named 'core_grippers' of type HamiltonCoreGrippers" + ) + + loc = mount.get_location_wrt(self.deck) + await self.gripper.pick_up_tool( + tool_position_x=loc.x, + tool_position_z=loc.z, + front_channel_position_y=loc.y + mount.front_channel_y_center, + rear_channel_position_y=loc.y + mount.back_channel_y_center, + tool_seek=loc.z + 10.0, + ) + + self._core_gripper_arm = PrepGripperArm( + backend=self.gripper, reference_resource=self.deck, grip_axis="y" + ) + return self._core_gripper_arm + + async def return_core_grippers(self) -> None: + if self._core_gripper_arm is None: + return + try: + await self._core_gripper_arm.backend.drop_tool() + finally: + self._core_gripper_arm = None + + @asynccontextmanager + async def core_grippers(self) -> AsyncIterator[PrepGripperArm]: + arm = await self.pick_up_core_grippers() + try: + yield arm + finally: + await self.return_core_grippers() + + # -- Motion, power, lights (MLPrep via client transport) -------------------- + + async def park(self) -> None: + await self.client.send_command(PrepCmd.PrepPark()) + + async def spread(self) -> None: + await self.client.send_command(PrepCmd.PrepSpread()) + + async def is_parked(self) -> bool: + result = await self.client.send_command(PrepCmd.PrepIsParked()) + if result is None: + return False + return bool(result.value) + + async def is_spread(self) -> bool: + result = await self.client.send_command(PrepCmd.PrepIsSpread()) + if result is None: + return False + return bool(result.value) + + async def power_down_request(self) -> None: + await self.client.send_command(PrepCmd.PrepPowerDownRequest()) + + async def confirm_power_down(self) -> None: + await self.client.send_command(PrepCmd.PrepConfirmPowerDown()) + + async def cancel_power_down(self) -> None: + await self.client.send_command(PrepCmd.PrepCancelPowerDown()) + + async def get_deck_light(self) -> Tuple[int, int, int, int]: + result = await self.client.send_command(PrepCmd.PrepGetDeckLight()) + if result is None: + raise ValueError("No response from GetDeckLight.") + return (result.white, result.red, result.green, result.blue) + + async def set_deck_light(self, white: int, red: int, green: int, blue: int) -> None: + await self.client.send_command( + PrepCmd.PrepSetDeckLight(white=white, red=red, green=green, blue=blue) + ) + + async def disco_mode(self) -> None: + """Easter egg: cycle deck lights then restore previous state.""" + white, red, green, blue = await self.get_deck_light() + try: + for _ in range(69): + await self.set_deck_light( + white=random.randint(1, 255), + red=random.randint(1, 255), + green=random.randint(1, 255), + blue=random.randint(1, 255), + ) + await asyncio.sleep(0.1) + finally: + await self.set_deck_light(white=white, red=red, green=green, blue=blue) diff --git a/pylabrobot/hamilton/prep/prep_commands.py b/pylabrobot/hamilton/prep/prep_commands.py new file mode 100644 index 00000000000..4725b604fe2 --- /dev/null +++ b/pylabrobot/hamilton/prep/prep_commands.py @@ -0,0 +1,2632 @@ +"""Prep command dataclasses and wire-type parameter structs. + +Pure data definitions for the Hamilton Prep protocol — enums, hardware config, +wire-type annotated parameter structs, and PrepCommand subclasses. No business +logic; used by Prep channels / head8 peers for command construction and serialization. + +Moved from prep_backend.py to separate protocol contracts from domain logic. +""" + +from __future__ import annotations + +import datetime +import math +from dataclasses import dataclass, field, fields +from enum import IntEnum +from typing import Annotated, ClassVar, Optional, Set, Tuple + +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.protocol import HamiltonProtocol, Hoi2Action +from pylabrobot.hamilton.transport.tcp.wire_types import ( + F32, + I8, + I16, + U16, + U32, + EnumArray, + HcResultEntry, + I16Array, + PaddedBool, + PaddedU8, + Str, + Struct, + StructArray, + U8Array, + U32Array, +) +from pylabrobot.hamilton.transport.tcp.wire_types import ( + Enum as WEnum, +) + +# ============================================================================= +# Enums (mirrored from Prep protocol spec) +# ============================================================================= + + +class ChannelIndex(IntEnum): + InvalidIndex = 0 + FrontChannel = 1 + RearChannel = 2 + MPHChannel = 3 + + +class TipDropType(IntEnum): + FixedHeight = 0 + Stall = 1 + CLLDSeek = 2 + + +class TipTypes(IntEnum): + None_ = 0 + LowVolume = 1 + StandardVolume = 2 + HighVolume = 3 + + +class TadmRecordingModes(IntEnum): + NoRecording = 0 + Errors = 1 + All = 2 + + +# ============================================================================= +# Hardware config (probed from instrument, immutable) +# ============================================================================= + + +@dataclass(frozen=True) +class DeckBounds: + """Deck axis bounds in mm (from GetDeckBounds / DeckConfiguration).""" + + min_x: float + max_x: float + min_y: float + max_y: float + min_z: float + max_z: float + + +@dataclass(frozen=True) +class DeckSiteInfo: + """A deck slot read from DeckConfiguration.GetDeckSiteDefinitions.""" + + id: int + left_bottom_front_x: float + left_bottom_front_y: float + left_bottom_front_z: float + length: float + width: float + height: float + + +@dataclass(frozen=True) +class WasteSiteInfo: + """A waste position read from DeckConfiguration.GetWasteSiteDefinitions.""" + + index: int + x_position: float + y_position: float + z_position: float + z_seek: float + + +@dataclass +class HoiDateTime: + """Hamilton network/built-in dateTime struct (source_id=3, ref_id=3). + + Wire format: 7 DataFragments — year(U16), month(PaddedU8), day(PaddedU8), + hour(PaddedU8), minute(PaddedU8), second(PaddedU8), millisecond(U16). + + Used by EndCalibration and SetChannelHardwareConfiguration to timestamp + calibration data. Construct from ``datetime.datetime`` via ``from_datetime()``. + """ + + year: U16 + month: PaddedU8 + day: PaddedU8 + hour: PaddedU8 + minute: PaddedU8 + second: PaddedU8 + millisecond: U16 + + @classmethod + def from_datetime(cls, dt: datetime.datetime) -> "HoiDateTime": + """Create from a Python datetime (microseconds truncated to milliseconds).""" + return cls( + year=dt.year, + month=dt.month, + day=dt.day, + hour=dt.hour, + minute=dt.minute, + second=dt.second, + millisecond=dt.microsecond // 1000, + ) + + @classmethod + def now(cls) -> "HoiDateTime": + """Create from the current local time.""" + return cls.from_datetime(datetime.datetime.now()) + + def to_datetime(self) -> datetime.datetime: + """Convert to a Python datetime.""" + return datetime.datetime( + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.millisecond * 1000, + ) + + +@dataclass(frozen=True) +class CalibrationSiteInfo: + """A calibration site from DeckConfiguration.GetCalibrationSiteDefinitions.""" + + id: int + left_bottom_front_x: float + left_bottom_front_y: float + left_bottom_front_z: float + length: float + width: float + height: float + post: bool + + +@dataclass(frozen=True) +class ChannelHardwareConfigInfo: + """Per-channel hardware config from MLPrepCalibration.GetChannelHardwareConfiguration.""" + + channel: int # ChannelIndex enum value + hardware: int # Hardware type enum value + + +@dataclass(frozen=True) +class ChannelCalibrationValuesInfo: + """Per-channel calibration values from MLPrepCalibration.GetCalibrationValues.""" + + index: int # ChannelIndex enum value + y_offset: float + z_offset: float + squeeze_position: int + z_touchoff: int + pressure_shift: int + pressure_monitoring_shift: int + dispenser_return_distance: float + z_tip_height: float + core_ii: bool + + def to_pretty_string(self) -> str: + """Return a stable one-line representation for logging/reporting.""" + return ( + f"index={self.index}, y_offset={self.y_offset}, z_offset={self.z_offset}, " + f"squeeze_position={self.squeeze_position}, z_touchoff={self.z_touchoff}, " + f"pressure_shift={self.pressure_shift}, " + f"pressure_monitoring_shift={self.pressure_monitoring_shift}, " + f"dispenser_return_distance={self.dispenser_return_distance}, " + f"z_tip_height={self.z_tip_height}, core_ii={self.core_ii}" + ) + + +@dataclass(frozen=True) +class CalibrationValues: + """Full calibration values from MLPrepCalibration.GetCalibrationValues.""" + + independent_offset_x: float + mph_offset_x: float + channel_values: Tuple["ChannelCalibrationValuesInfo", ...] + + def to_pretty_string(self, sort_by_index: bool = True) -> str: + """Return deterministic, human-readable calibration output.""" + channels = self.channel_values + if sort_by_index: + channels = tuple(sorted(channels, key=lambda cv: cv.index)) + + lines = [ + f"Independent offset X: {self.independent_offset_x}", + f"MPH offset X: {self.mph_offset_x}", + "Per-channel calibration values:", + ] + for cv in channels: + lines.append(f" {cv.to_pretty_string()}") + return "\n".join(lines) + + def __str__(self) -> str: + return self.to_pretty_string() + + +@dataclass(frozen=True) +class CalibrationFieldChange: + field: str + old: object + new: object + + +@dataclass(frozen=True) +class ChannelCalibrationDiff: + index: int + state: str # "added" | "removed" | "changed" + changes: Tuple[CalibrationFieldChange, ...] + old: Optional[ChannelCalibrationValuesInfo] + new: Optional[ChannelCalibrationValuesInfo] + + +@dataclass(frozen=True) +class CalibrationValuesDiff: + top_level_changes: Tuple[CalibrationFieldChange, ...] + channel_diffs: Tuple[ChannelCalibrationDiff, ...] + + @property + def has_changes(self) -> bool: + return bool(self.top_level_changes or self.channel_diffs) + + +def _calibration_value_equal(old: object, new: object, float_tol: float) -> bool: + if isinstance(old, float) and isinstance(new, float): + return math.isclose(old, new, rel_tol=0.0, abs_tol=float_tol) + return old == new + + +def diff_calibration_values( + old: CalibrationValues, + new: CalibrationValues, + float_tol: float = 1e-6, +) -> CalibrationValuesDiff: + """Return structured diff between two calibration snapshots.""" + + top_level_changes = [] + for field_name in ("independent_offset_x", "mph_offset_x"): + old_value = getattr(old, field_name) + new_value = getattr(new, field_name) + if not _calibration_value_equal(old_value, new_value, float_tol=float_tol): + top_level_changes.append( + CalibrationFieldChange(field=field_name, old=old_value, new=new_value) + ) + + old_channels = {cv.index: cv for cv in old.channel_values} + new_channels = {cv.index: cv for cv in new.channel_values} + channel_diffs = [] + for idx in sorted(set(old_channels) | set(new_channels)): + old_cv = old_channels.get(idx) + new_cv = new_channels.get(idx) + if old_cv is None and new_cv is not None: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="added", + changes=(), + old=None, + new=new_cv, + ) + ) + continue + if old_cv is not None and new_cv is None: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="removed", + changes=(), + old=old_cv, + new=None, + ) + ) + continue + assert old_cv is not None and new_cv is not None + + field_changes = [] + for f in fields(ChannelCalibrationValuesInfo): + field_name = f.name + old_value = getattr(old_cv, field_name) + new_value = getattr(new_cv, field_name) + if not _calibration_value_equal(old_value, new_value, float_tol=float_tol): + field_changes.append(CalibrationFieldChange(field=field_name, old=old_value, new=new_value)) + if field_changes: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="changed", + changes=tuple(field_changes), + old=old_cv, + new=new_cv, + ) + ) + + return CalibrationValuesDiff( + top_level_changes=tuple(top_level_changes), + channel_diffs=tuple(channel_diffs), + ) + + +def format_calibration_diff(diff: CalibrationValuesDiff) -> str: + """Return a concise, human-readable diff summary.""" + if not diff.has_changes: + return "No calibration differences." + + lines = ["Calibration differences:"] + if diff.top_level_changes: + lines.append("Top-level:") + for change in diff.top_level_changes: + lines.append(f" {change.field}: {change.old} -> {change.new}") + + if diff.channel_diffs: + lines.append("Per-channel:") + for channel_diff in diff.channel_diffs: + if channel_diff.state == "added": + assert channel_diff.new is not None + lines.append(f" index={channel_diff.index}: added ({channel_diff.new.to_pretty_string()})") + continue + if channel_diff.state == "removed": + assert channel_diff.old is not None + lines.append( + f" index={channel_diff.index}: removed ({channel_diff.old.to_pretty_string()})" + ) + continue + changed_fields = ", ".join( + f"{change.field}: {change.old} -> {change.new}" for change in channel_diff.changes + ) + lines.append(f" index={channel_diff.index}: {changed_fields}") + + return "\n".join(lines) + + +@dataclass(frozen=True) +class InstrumentConfig: + """Instrument hardware configuration probed at setup.""" + + deck_bounds: Optional[DeckBounds] + has_enclosure: bool + safe_speeds_enabled: bool + deck_sites: Tuple[DeckSiteInfo, ...] + waste_sites: Tuple[WasteSiteInfo, ...] + default_traverse_height: Optional[float] = ( + None # None if probe failed; user can set via set_default_traverse_height + ) + num_channels: Optional[int] = None # 1 or 2 dual-channel pipettor; from GetPresentChannels + has_mph: Optional[bool] = None # True if 8MPH present; from GetPresentChannels + + +# ============================================================================= +# Inner parameter dataclasses (wire-type annotated, serialized via from_struct) +# ============================================================================= + + +@dataclass +class SeekParameters: + x_start: F32 + y_start: F32 + z_start: F32 + distance: F32 + expected_position: F32 + + +@dataclass +class XYZCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class XYCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + + +@dataclass +class ChannelYZMoveParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_position: F32 + + +@dataclass +class GantryMoveXYZParameters: + default_values: PaddedBool + gantry_x_position: F32 + axis_parameters: Annotated[list[ChannelYZMoveParameters], StructArray()] + + +@dataclass +class PlateDimensions: + default_values: PaddedBool + length: F32 + width: F32 + height: F32 + + +@dataclass +class TipDefinition: + default_values: PaddedBool + id: PaddedU8 + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + label: Str + + +@dataclass +class TipPickupParameters: + default_values: PaddedBool + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + + +@dataclass +class AspirateParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + prewet_volume: F32 + blowout_volume: F32 + + @classmethod + def from_location( + cls, + loc, + *, + prewet_volume: float = 0.0, + blowout_volume: float = 0.0, + ) -> AspirateParameters: + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + prewet_volume=prewet_volume, + blowout_volume=blowout_volume, + ) + + +@dataclass +class DispenseParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + stop_back_volume: F32 + cutoff_speed: F32 + + @classmethod + def for_op( + cls, + loc, + stop_back_volume: float = 0.0, + cutoff_speed: float = 100.0, + ) -> DispenseParameters: + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + + +@dataclass +class CommonParameters: + default_values: PaddedBool + empty: PaddedBool + z_minimum: F32 + z_final: F32 + z_liquid_exit_speed: F32 + liquid_volume: F32 + liquid_speed: F32 + transport_air_volume: F32 + tube_radius: F32 + cone_height: F32 + cone_bottom_radius: F32 + settling_time: F32 + additional_probes: U32 + + @classmethod + def for_op( + cls, + volume: float, + radius: float, + *, + flow_rate: Optional[float] = None, + empty: bool = True, + z_minimum: float = 5.0, + z_final: float = 96.97, + z_liquid_exit_speed: float = 10.0, + transport_air_volume: float = 0.0, + cone_height: float = 0.0, + cone_bottom_radius: float = 0.0, + settling_time: float = 1.0, + additional_probes: int = 0, + ) -> CommonParameters: + """Build CommonParameters for a single aspirate/dispense op. + + z_minimum is in mm; default 5.0 keeps the head above the deck surface (deck has + its own size_z). High-level aspirate()/dispense() override with well bottom when None. + z_liquid_exit_speed is in mm/s; default 10.0 aligns with STAR swap speed. + """ + return cls( + default_values=False, + empty=empty, + z_minimum=z_minimum, + z_final=z_final, + z_liquid_exit_speed=z_liquid_exit_speed, + liquid_volume=volume, + liquid_speed=flow_rate or 100.0, + transport_air_volume=transport_air_volume, + tube_radius=radius, + cone_height=cone_height, + cone_bottom_radius=cone_bottom_radius, + settling_time=settling_time, + additional_probes=additional_probes, + ) + + +@dataclass +class NoLldParameters: + default_values: PaddedBool + z_fluid: F32 + z_air: F32 + bottom_search: PaddedBool + z_bottom_search_offset: F32 + z_bottom_offset: F32 + + @classmethod + def for_fixed_z( + cls, + z_fluid: float = 94.97, + z_air: float = 96.97, + *, + z_bottom_search_offset: float = 2.0, + z_bottom_offset: float = 0.0, + ) -> NoLldParameters: + return cls( + default_values=False, + z_fluid=z_fluid, + z_air=z_air, + bottom_search=False, + z_bottom_search_offset=z_bottom_search_offset, + z_bottom_offset=z_bottom_offset, + ) + + +@dataclass +class LldParameters: + default_values: PaddedBool + search_start_position: F32 + channel_speed: F32 + z_submerge: F32 + z_out_of_liquid: F32 + + @classmethod + def default(cls) -> LldParameters: + return cls( + default_values=True, + search_start_position=0.0, + channel_speed=0.0, + z_submerge=0.0, + z_out_of_liquid=0.0, + ) + + +@dataclass +class CLldParameters: + default_values: PaddedBool + sensitivity: WEnum + clot_check_enable: PaddedBool + z_clot_check: F32 + detect_mode: WEnum + + @classmethod + def default(cls) -> CLldParameters: + return cls( + default_values=True, sensitivity=1, clot_check_enable=False, z_clot_check=0.0, detect_mode=0 + ) + + +@dataclass +class PLldParameters: + default_values: PaddedBool + sensitivity: WEnum + dispenser_seek_speed: F32 + lld_height_difference: F32 + detect_mode: WEnum + + @classmethod + def default(cls) -> PLldParameters: + return cls( + default_values=True, + sensitivity=1, + dispenser_seek_speed=0.0, + lld_height_difference=0.0, + detect_mode=0, + ) + + +@dataclass +class TadmReturnParameters: + default_values: PaddedBool + channel: WEnum + entries: U32 + error: PaddedBool + data: I16Array + + +@dataclass +class TadmParameters: + default_values: PaddedBool + limit_curve_index: U16 + recording_mode: WEnum + + @classmethod + def default(cls) -> TadmParameters: + return cls( + default_values=True, + limit_curve_index=0, + recording_mode=TadmRecordingModes.Errors, + ) + + +@dataclass +class AspirateMonitoringParameters: + default_values: PaddedBool + c_lld_enable: PaddedBool + p_lld_enable: PaddedBool + minimum_differential: U16 + maximum_differential: U16 + clot_threshold: U16 + + @classmethod + def default(cls) -> AspirateMonitoringParameters: + return cls( + default_values=True, + c_lld_enable=False, + p_lld_enable=False, + minimum_differential=30, + maximum_differential=30, + clot_threshold=20, + ) + + +@dataclass +class MixParameters: + default_values: PaddedBool + z_offset: F32 + volume: F32 + cycles: PaddedU8 + speed: F32 + + @classmethod + def default(cls) -> MixParameters: + return cls( + default_values=True, + z_offset=0.0, + volume=0.0, + cycles=0, + speed=250.0, + ) + + +@dataclass +class AdcParameters: + default_values: PaddedBool + errors: PaddedBool + maximum_volume: F32 + + @classmethod + def default(cls) -> AdcParameters: + return cls( + default_values=True, + errors=True, + maximum_volume=4.5, + ) + + +@dataclass +class ChannelBoundsParameters: + """Per-channel movement bounds returned by PipettorService.GetChannelBounds.""" + + default_values: PaddedBool + channel: WEnum + x_min: F32 + x_max: F32 + y_min: F32 + y_max: F32 + z_min: F32 + z_max: F32 + + +@dataclass +class ChannelXYZPositionParameters: + default_values: PaddedBool + channel: WEnum + position_x: F32 + position_y: F32 + position_z: F32 + + +@dataclass +class PressureReturnParameters: + default_values: PaddedBool + channel: WEnum + pressure: U16 + + +@dataclass +class LiquidHeightReturnParameters: + default_values: PaddedBool + channel: WEnum + c_lld_detected: PaddedBool + c_lld_liquid_height: F32 + p_lld_detected: PaddedBool + p_lld_liquid_height: F32 + + +@dataclass +class DispenserVolumeReturnParameters: + default_values: PaddedBool + channel: WEnum + volume: F32 + + +@dataclass +class PotentiometerParameters: + default_values: PaddedBool + channel: WEnum + gain: PaddedU8 + offset: PaddedU8 + + +@dataclass +class YLLDSeekParameters: + default_values: PaddedBool + channel: WEnum + start_position_x: F32 + start_position_y: F32 + start_position_z: F32 + seek_position_y: F32 + seek_velocity_y: F32 + lld_sensitivity: WEnum + detect_mode: WEnum + + +@dataclass +class ChannelSeekParameters: + default_values: PaddedBool + channel: WEnum + seek_position_x: F32 + seek_position_y: F32 + seek_height: F32 + min_seek_height: F32 + final_position_z: F32 + + +@dataclass +class LLDChannelSeekParameters: + default_values: PaddedBool + channel: WEnum + seek_position_x: F32 + seek_position_y: F32 + seek_velocity_z: F32 + seek_height: F32 + min_seek_height: F32 + final_position_z: F32 + lld_sensitivity: WEnum + detect_mode: WEnum + + +@dataclass +class SeekResultParameters: + default_values: PaddedBool + channel: WEnum + detected: PaddedBool + position: F32 + + +@dataclass +class ChannelCounterParameters: + default_values: PaddedBool + channel: WEnum + tip_pickup_counter: U32 + tip_eject_counter: U32 + aspirate_counter: U32 + dispense_counter: U32 + + +@dataclass +class ChannelCalibrationParameters: + default_values: PaddedBool + channel: WEnum + dispenser_return_steps: U32 + squeeze_position: F32 + z_touchoff: F32 + z_tip_height: F32 + pressure_monitoring_shift: U32 + + +@dataclass +class LeakCheckSimpleParameters: + default_values: PaddedBool + channel: WEnum + time: F32 + high_pressure: PaddedBool + + +@dataclass +class LeakCheckParameters: + default_values: PaddedBool + channel: WEnum + start_position_x: F32 + start_position_y: F32 + start_position_z: F32 + seek_distance_y: F32 + pre_load_distance_y: F32 + final_z: F32 + tip_definition_id: PaddedU8 + test_time: F32 + high_pressure: PaddedBool + + +@dataclass +class DriveStatus: + initialized: PaddedBool + position: F32 + encoder_position: F32 + in_home_sensor: PaddedBool + + +@dataclass +class ChannelDriveStatus: + default_values: PaddedBool + channel: WEnum + y_axis_drive_status: Annotated[DriveStatus, Struct()] + z_axis_drive_status: Annotated[DriveStatus, Struct()] + dispenser_drive_status: Annotated[DriveStatus, Struct()] + squeeze_drive_status: Annotated[DriveStatus, Struct()] + + +@dataclass +class AspirateParametersNoLldAndMonitoring: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + + +@dataclass +class AspirateParametersNoLldAndTadm: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndMonitoring: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndTadm: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class DispenseParametersNoLld: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DispenseParametersLld: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DropTipParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_seek: F32 + z_tip: F32 + z_final: F32 + z_seek_speed: F32 + drop_type: WEnum + + +@dataclass +class InitTipDropParameters: + default_values: PaddedBool + x_position: F32 + rolloff_distance: F32 + channel_parameters: Annotated[list[DropTipParameters], StructArray()] + + +@dataclass +class DispenseInitToWasteParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class MoveAxisAbsoluteParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + position: F32 + delay: U32 + + +@dataclass +class MoveAxisRelativeParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + distance: F32 + delay: U32 + + +@dataclass +class LimitCurveEntry: + default_values: PaddedBool + sample: U16 + pressure: I16 + + +@dataclass +class TipPositionParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + z_seek: F32 + + @classmethod + def for_op( + cls, + channel: WEnum, + loc, + tip, + *, + z_seek_offset: Optional[float] = None, + ) -> TipPositionParameters: + """Build from an op location and tip (pickup). + + z_seek default: z_position + fitting_depth + 5mm guard (tip-type-aware, + comparable to Nimbus/Vantage). z_seek_offset: additive mm on top of + computed default (None = 0). + """ + z = loc.z + tip.total_tip_length - tip.fitting_depth + z_seek = z + tip.fitting_depth + 5.0 + (z_seek_offset or 0.0) + return cls( + default_values=False, + channel=channel, + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z_seek, + ) + + +@dataclass +class TipDropParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + z_seek: F32 + drop_type: WEnum + + @classmethod + def for_op( + cls, + channel: WEnum, + loc, + tip, + *, + z_seek_offset: Optional[float] = None, + drop_type: Optional[TipDropType] = None, + ) -> TipDropParameters: + """Build from an op location and tip (drop). + + z_position uses (total_tip_length - fitting_depth) so the tip bottom lands + at the spot surface (consistent with STAR and with pickup). + z_seek default: loc.z + total_tip_length + 5mm so tip bottom clears adjacent tips during + lateral approach. z_seek_offset: additive mm on top of computed default + (None = 0). + """ + z = loc.z + (tip.total_tip_length - tip.fitting_depth) + z_seek = loc.z + tip.total_tip_length + 2.0 + (z_seek_offset or 0.0) + return cls( + default_values=False, + channel=channel, + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z_seek, + drop_type=drop_type if drop_type is not None else TipDropType.FixedHeight, + ) + + +@dataclass +class TipHeightCalibrationParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_start: F32 + z_stop: F32 + z_final: F32 + volume: F32 + tip_type: WEnum + + +@dataclass +class DispenserVolumeEntry: + default_values: PaddedBool + type: WEnum + volume: F32 + + +@dataclass +class DispenserVolumeStackReturnParameters: + default_values: PaddedBool + channel: WEnum + total_volume: F32 + volumes: Annotated[list[DispenserVolumeEntry], StructArray()] + + +@dataclass +class SegmentDescriptor: + area_top: F32 + area_bottom: F32 + height: F32 + + +@dataclass +class AspirateParametersNoLldAndMonitoring2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + + +@dataclass +class AspirateParametersNoLldAndTadm2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndMonitoring2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class AspirateParametersLldAndTadm2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + +@dataclass +class DispenseParametersNoLld2: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +@dataclass +class DispenseParametersLld2: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + +# ============================================================================= +# PrepCommand base class +# ============================================================================= + + +# Sentinel meaning "dest not supplied — resolve firmware_path JIT at send time." +# PrepClient.send_command detects this and replaces it with the resolved Address +# before delegating to the base TCP layer. Using a real Address sentinel (rather +# than None) keeps TCPCommand.__init__ happy without any additional branching. +_UNRESOLVED = Address(-1, -1, -1) + + +@dataclass +class PrepCommand(TCPCommand): + """Base for all Prep instrument commands. + + Subclasses are dataclasses with ``Annotated`` payload fields. + ``build_parameters()`` is inherited from ``TCPCommand`` and serialises only + ``Annotated`` fields via ``HoiParams.from_struct``. + + Destination defaults to :data:`_UNRESOLVED` in :meth:`__post_init__` (not a + dataclass field — ``field(kw_only=...)`` needs Python 3.10+ and this package + supports 3.9). Callers that need ``dest=`` at construction declare + ``dest: Address`` (required or defaulted) on that concrete subclass. + ``PrepClient.send_command`` resolves ``_UNRESOLVED`` from ``firmware_path``. + """ + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + + # Declared by each concrete subclass. None means "caller must supply dest=". + firmware_path: ClassVar[Optional[str]] = None + + # Aggregates populated by ``__init_subclass__`` at import time (unique paths for chatterbox seeding). + _ALL_PATHS: ClassVar[Set[str]] = set() + + # Instance field so dataclasses generate ``__init__`` (shadowing TCPCommand's + # ``__init__(dest)`` for typecheckers). ``init=False`` keeps it out of + # constructors and avoids Python 3.9 field-ordering errors on subclasses. + _prep_command_base: None = field(default=None, init=False, repr=False, compare=False) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + path = cls.__dict__.get("firmware_path") + if path is None: + return + PrepCommand._ALL_PATHS.add(path) + + def __post_init__(self): + if not hasattr(self, "dest"): + self.dest = _UNRESOLVED + super().__init__(self.dest) + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map HoiResult entry → 0-indexed channel via the first per-channel struct-array field. + + Prep commands carry a ``StructArray`` of per-channel parameters whose + elements have a ``channel`` attribute (e.g. ``aspirate_parameters[i].channel``). + Entry N maps to the ``channel`` of element N. Commands without such a field + (``PrepGetPositions``, ``PrepIsParked``, …) fall back to the entry index. + """ + for f in fields(self): + value = getattr(self, f.name, None) + if not isinstance(value, list) or not value: + continue + if entry_index >= len(value): + continue + elem = value[entry_index] + channel = getattr(elem, "channel", None) + if channel is None: + continue + try: + return int(channel) + except (TypeError, ValueError): + continue + return entry_index + + +@dataclass +class PrepStatusRequest(PrepCommand): + """Base for Prep commands that use HOI STATUS_REQUEST (``action_code == Hoi2Action.STATUS_REQUEST``). + + Subclasses target various firmware objects (Pipettor, MLPrep, MLPrepService, + DeckConfiguration, calibration, etc.). Responses still use the default + ``response_required=True`` in :meth:`TCPCommand.build`. + """ + + action_code = Hoi2Action.STATUS_REQUEST + + +@dataclass +class PrepProbeRequest(PrepCommand): + """Ad-hoc STATUS_REQUEST with runtime command_id and interface_id. + + Use with :meth:`~PrepClient.send_query` when the target command_id is only + known at runtime. Always supply ``dest=`` explicitly; the JIT firmware-path + resolver is bypassed because ``firmware_path = None``. + + ``command_id`` and ``interface_id`` are dataclass instance fields that shadow + the class-level defaults in :class:`~pylabrobot.hamilton.transport.tcp.commands.TCPCommand`, + so :meth:`TCPCommand.build` picks up the per-instance values correctly. + """ + + action_code = Hoi2Action.STATUS_REQUEST + firmware_path = None + dest: Address + command_id: int + interface_id: int = 3 + + +# ============================================================================= +# Pipettor / ChannelCoordinator command classes +# ============================================================================= + + +@dataclass +class PrepAspirateNoLldMonitoring(PrepCommand): + """Aspirate without LLD or monitoring (cmd=1, dest=Pipettor).""" + + command_id = 1 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateTadm(PrepCommand): + """Aspirate with TADM, no LLD (cmd=2, dest=Pipettor).""" + + command_id = 2 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] + + +@dataclass +class PrepAspirateWithLld(PrepCommand): + """Aspirate with LLD and monitoring (cmd=3, dest=Pipettor).""" + + command_id = 3 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadm(PrepCommand): + """Aspirate with LLD and TADM (cmd=4, dest=Pipettor).""" + + command_id = 4 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] + + +@dataclass +class PrepDispenseNoLld(PrepCommand): + """Dispense without LLD (cmd=5, dest=Pipettor).""" + + command_id = 5 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] + + +@dataclass +class PrepDispenseWithLld(PrepCommand): + """Dispense with LLD (cmd=6, dest=Pipettor).""" + + command_id = 6 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] + + +@dataclass +class PrepDispenseInitToWaste(PrepCommand): + """Dispense initialize to waste (cmd=7, dest=Pipettor).""" + + command_id = 7 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + waste_parameters: Annotated[list[DispenseInitToWasteParameters], StructArray()] + + +@dataclass +class PrepPickUpTipsById(PrepCommand): + """Pick up tips by tip-definition ID (cmd=8, dest=Pipettor).""" + + command_id = 8 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition_id: PaddedU8 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpTips(PrepCommand): + """Pick up tips by tip-definition struct (cmd=9, dest=Pipettor).""" + + command_id = 9 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpNeedlesById(PrepCommand): + """Pick up needles by tip-definition ID (cmd=10, dest=Pipettor).""" + + command_id = 10 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition_id: PaddedU8 + blowout_offset: F32 + blowout_speed: F32 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepPickUpNeedles(PrepCommand): + """Pick up needles by tip-definition struct (cmd=11, dest=Pipettor).""" + + command_id = 11 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + blowout_offset: F32 + blowout_speed: F32 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + +@dataclass +class PrepDropTips(PrepCommand): + """Drop tips (cmd=12, dest=Pipettor).""" + + command_id = 12 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_positions: Annotated[list[TipDropParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + +@dataclass +class MphPickupTips(PrepCommand): + """Pick up tips via MPH coordinator (iface=1 id=9, dest=MphRoot.MPH). + + Resolved introspection signature: + PickupTips(tipParameters: struct(iface=1), finalZ: f32, + tipDefinition: struct(iface=1), tadm: bool, + dispenserVolume: f32, dispenserSpeed: f32, + tipMask: u32) -> { seekSpeed: List[u16] } + + The MPH takes a SINGLE struct (type_57) for tip_position, not a + StructArray (type_61) like the Pipettor. All 8 probes move as one unit; + tip_mask selects which channels engage. + """ + + command_id = 9 + firmware_path = "MLPrepRoot.MphRoot.MPH" + tip_position: Annotated[TipPositionParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + tip_mask: U32 + + +@dataclass +class MphMoveToPosition(PrepCommand): + """Move MPH gantry to absolute XYZ on IMph (cmd=17, dest=MphRoot.MPH). + + Wire matches vendor ``MoveToPosition(positionX, positionY, positionZ)`` as three + plain ``f32`` scalars (see mph.yaml) — not :class:`GantryMoveXYZParameters`, which + is Pipettor-only. Use :class:`MphMoveToPosition` / :class:`MphMoveToPositionViaLane` + for MPH motion; :class:`PrepMoveToPosition` targets PipettorRoot only. + """ + + command_id = 17 + firmware_path = "MLPrepRoot.MphRoot.MPH" + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class MphMoveToPositionViaLane(PrepCommand): + """Move MPH gantry to absolute XYZ via lane (cmd=18, dest=MphRoot.MPH). + + Same payload as :class:`MphMoveToPosition`; vendor ``MoveToPositionViaLane``. + """ + + command_id = 18 + firmware_path = "MLPrepRoot.MphRoot.MPH" + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class MphDropTips(PrepCommand): + """Drop tips via MPH coordinator (iface=1 id=12, dest=MphRoot.MPH). + + Resolved introspection signature: + DropTips(dropTipParameters: struct(iface=1), finalZ: f32, + tipRollOffDistance: f32) -> seekSpeed: List[u16] + + Single struct (type_57) for drop position — all probes drop together. + """ + + command_id = 12 + firmware_path = "MLPrepRoot.MphRoot.MPH" + tip_position: Annotated[TipDropParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + +@dataclass +class MphAspirateNoLldMonitoring(PrepCommand): + """Aspirate without LLD via MPH coordinator (cmd=1, dest=MphRoot.MPH). + + One AspirateParametersNoLldAndMonitoring struct per active probe — each with + its own explicit x/y position. ``channel`` is ChannelIndex.MPHChannel for all + entries. The array length equals the number of active probes (not necessarily 8). + """ + + command_id = 1 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] + + +@dataclass +class MphDispenseNoLld(PrepCommand): + """Dispense without LLD via MPH coordinator (cmd=5, dest=MphRoot.MPH). + + One DispenseParametersNoLld struct per active probe — each with its own + explicit x/y position. ``channel`` is ChannelIndex.MPHChannel for all entries. + """ + + command_id = 5 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] + + +@dataclass +class MphAspirateNoLldMonitoring2(PrepCommand): + """Aspirate V2 with liquid-following via MPH coordinator (cmd=29, dest=MphRoot.MPH). + + Uses ``AspirateParametersNoLldAndMonitoring2`` which includes a + ``ContainerDescription`` frustum-segment array for Z-axis liquid-following. + One entry per active probe; array length equals the number of active probes. + """ + + command_id = 29 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] + + +@dataclass +class MphDispenseNoLld2(PrepCommand): + """Dispense V2 without LLD via MPH coordinator (cmd=33, dest=MphRoot.MPH). + + Uses ``DispenseParametersNoLld2`` which includes a ``ContainerDescription`` + frustum-segment array for Z-axis liquid-following. + One entry per active probe; array length equals the number of active probes. + """ + + command_id = 33 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] + + +@dataclass +class MphAspirateTadm(PrepCommand): + """Aspirate with TADM, no LLD via MPH coordinator (cmd=2, dest=MphRoot.MPH).""" + + command_id = 2 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] + + +@dataclass +class MphAspirateWithLld(PrepCommand): + """Aspirate with LLD and monitoring via MPH coordinator (cmd=3, dest=MphRoot.MPH).""" + + command_id = 3 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] + + +@dataclass +class MphAspirateWithLldTadm(PrepCommand): + """Aspirate with LLD and TADM via MPH coordinator (cmd=4, dest=MphRoot.MPH).""" + + command_id = 4 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] + + +@dataclass +class MphDispenseWithLld(PrepCommand): + """Dispense with LLD via MPH coordinator (cmd=6, dest=MphRoot.MPH).""" + + command_id = 6 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] + + +@dataclass +class MphAspirateTadm2(PrepCommand): + """Aspirate V2 with TADM, no LLD via MPH coordinator (cmd=30, dest=MphRoot.MPH).""" + + command_id = 30 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] + + +@dataclass +class MphAspirateWithLld2(PrepCommand): + """Aspirate V2 with LLD and monitoring via MPH coordinator (cmd=31, dest=MphRoot.MPH).""" + + command_id = 31 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] + + +@dataclass +class MphAspirateWithLldTadm2(PrepCommand): + """Aspirate V2 with LLD and TADM via MPH coordinator (cmd=32, dest=MphRoot.MPH).""" + + command_id = 32 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] + + +@dataclass +class MphDispenseWithLld2(PrepCommand): + """Dispense V2 with LLD via MPH coordinator (cmd=34, dest=MphRoot.MPH).""" + + command_id = 34 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] + + +@dataclass +class PrepPickUpToolById(PrepCommand): + """Pick up tool by tip-definition ID (cmd=14, dest=Pipettor).""" + + command_id = 14 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_definition_id: PaddedU8 + tool_position_x: F32 + tool_position_z: F32 + front_channel_position_y: F32 + rear_channel_position_y: F32 + tool_seek: F32 + tool_x_radius: F32 + tool_y_radius: F32 + + +@dataclass +class PrepPickUpTool(PrepCommand): + """Pick up tool by tip-definition struct (cmd=15, dest=Pipettor).""" + + command_id = 15 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_definition: Annotated[TipPickupParameters, Struct()] + tool_position_x: F32 + tool_position_z: F32 + front_channel_position_y: F32 + rear_channel_position_y: F32 + tool_seek: F32 + tool_x_radius: F32 + tool_y_radius: F32 + + +@dataclass +class PrepDropTool(PrepCommand): + """Drop tool (cmd=16, dest=Pipettor).""" + + command_id = 16 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + + +@dataclass +class PrepPickUpPlate(PrepCommand): + """Pick up plate (cmd=17, dest=Pipettor).""" + + command_id = 17 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + plate_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + + +@dataclass +class PrepDropPlate(PrepCommand): + """Drop plate (cmd=18, dest=Pipettor).""" + + command_id = 18 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + plate_top_center: Annotated[XYZCoord, Struct()] + clearance_y: F32 + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepMovePlate(PrepCommand): + """Move plate to position (cmd=19, dest=Pipettor).""" + + command_id = 19 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + plate_top_center: Annotated[XYZCoord, Struct()] + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepTransferPlate(PrepCommand): + """Transfer plate from source to destination (cmd=20, dest=Pipettor).""" + + command_id = 20 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + plate_source_top_center: Annotated[XYZCoord, Struct()] + plate_destination_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepReleasePlate(PrepCommand): + """Release plate / open gripper (cmd=21, dest=Pipettor).""" + + command_id = 21 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + + +# CORE gripper tool definition for PrepPickUpTool (struct); matches instrument id=11. +CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS = TipPickupParameters( + default_values=False, + volume=1.0, + length=22.9, + tip_type=TipTypes.None_, + has_filter=False, + is_needle=False, + is_tool=True, +) + + +@dataclass +class PrepEmptyDispenser(PrepCommand): + """Empty dispenser (cmd=23, dest=Pipettor).""" + + command_id = 23 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channels: EnumArray + + +@dataclass +class PrepMoveToPosition(PrepCommand): + """Move pipettor gantry to position (cmd=26, dest=PipettorRoot only). + + Payload is :class:`GantryMoveXYZParameters` with ``FrontChannel`` / ``RearChannel`` + only in ``axis_parameters``. MPH motion must use :class:`MphMoveToPosition` on + ``MLPrepRoot.MphRoot.MPH``, not this command. + """ + + command_id = 26 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMoveToPositionViaLane(PrepCommand): + """Move pipettor gantry via lane (cmd=27, dest=PipettorRoot only). + + Same constraints as :class:`PrepMoveToPosition`. MPH: use + :class:`MphMoveToPositionViaLane`. + """ + + command_id = 27 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepGetPositions(PrepStatusRequest): + """GetPositions (cmd=25, dest=Pipettor). + + Returns the current XYZ position of each channel as a StructArray of + ChannelXYZPositionParameters. + """ + + command_id = 25 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + + @dataclass(frozen=True) + class Response: + positions: Annotated[list[ChannelXYZPositionParameters], StructArray()] + + +@dataclass +class PrepMoveZUpToSafe(PrepCommand): + """Move Z axes up to safe height (cmd=28, dest=Pipettor).""" + + command_id = 28 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channels: EnumArray + + +@dataclass +class PrepZSeekLldPosition(PrepCommand): + """Z-seek LLD position (cmd=29, dest=Pipettor).""" + + command_id = 29 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + seek_parameters: Annotated[list[LLDChannelSeekParameters], StructArray()] + + +@dataclass +class PrepCreateTadmLimitCurve(PrepCommand): + """Create TADM limit curve (cmd=31, dest=Pipettor).""" + + command_id = 31 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + name: Str + lower_limit: Annotated[list[LimitCurveEntry], StructArray()] + upper_limit: Annotated[list[LimitCurveEntry], StructArray()] + + +@dataclass +class PrepEraseTadmLimitCurves(PrepCommand): + """Erase TADM limit curves for a channel (cmd=32, dest=Pipettor).""" + + command_id = 32 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveNames(PrepCommand): + """Get TADM limit curve names for a channel (cmd=33, dest=Pipettor).""" + + command_id = 33 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveInfo(PrepCommand): + """Get TADM limit curve info (cmd=34, dest=Pipettor).""" + + command_id = 34 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + name: Str + + +@dataclass +class PrepRetrieveTadmData(PrepCommand): + """Retrieve TADM data for a channel (cmd=35, dest=Pipettor).""" + + command_id = 35 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + + +@dataclass +class PrepResetTadmFifo(PrepCommand): + """Reset TADM FIFO (cmd=36, dest=Pipettor).""" + + command_id = 36 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channels: EnumArray + + +@dataclass +class PrepAspirateNoLldMonitoringV2(PrepCommand): + """Aspirate v2 without LLD or monitoring (cmd=38, dest=Pipettor).""" + + command_id = 38 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateTadmV2(PrepCommand): + """Aspirate v2 with TADM, no LLD (cmd=39, dest=Pipettor).""" + + command_id = 39 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] + + +@dataclass +class PrepAspirateWithLldV2(PrepCommand): + """Aspirate v2 with LLD and monitoring (cmd=40, dest=Pipettor).""" + + command_id = 40 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadmV2(PrepCommand): + """Aspirate v2 with LLD and TADM (cmd=41, dest=Pipettor).""" + + command_id = 41 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] + + +@dataclass +class PrepDispenseNoLldV2(PrepCommand): + """Dispense v2 without LLD (cmd=42, dest=Pipettor).""" + + command_id = 42 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] + + +@dataclass +class PrepDispenseWithLldV2(PrepCommand): + """Dispense v2 with LLD (cmd=43, dest=Pipettor).""" + + command_id = 43 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] + + +# ============================================================================= +# MLPrep command classes +# ============================================================================= + + +@dataclass +class PrepInitialize(PrepCommand): + """Initialize MLPrep (cmd=1, dest=MLPrep).""" + + command_id = 1 + firmware_path = "MLPrepRoot.MLPrep" + smart: PaddedBool + tip_drop_params: Annotated[InitTipDropParameters, Struct()] + + +@dataclass +class PrepGetIsInitialized(PrepStatusRequest): + """Query whether MLPrep is initialized. Firmware yaml: [1:2] GetIsInitialized(void) -> value: bool.""" + + command_id = 2 + firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepPark(PrepCommand): + """Park MLPrep (cmd=3, dest=MLPrep).""" + + command_id = 3 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepSpread(PrepCommand): + """Spread channels (cmd=4, dest=MLPrep).""" + + command_id = 4 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepAddTipAndNeedleDefinition(PrepCommand): + """Add tip/needle definition (cmd=12, dest=MLPrep).""" + + command_id = 12 + firmware_path = "MLPrepRoot.MLPrep" + tip_definition: Annotated[TipDefinition, Struct()] + + +@dataclass +class PrepRemoveTipAndNeedleDefinition(PrepCommand): + """Remove tip/needle definition by ID (cmd=13, dest=MLPrep).""" + + command_id = 13 + firmware_path = "MLPrepRoot.MLPrep" + id_: WEnum + + +@dataclass +class PrepReadStorage(PrepCommand): + """Read from instrument storage (cmd=14, dest=MLPrep).""" + + command_id = 14 + firmware_path = "MLPrepRoot.MLPrep" + offset: U32 + length: U32 + + +@dataclass +class PrepWriteStorage(PrepCommand): + """Write to instrument storage (cmd=15, dest=MLPrep).""" + + command_id = 15 + firmware_path = "MLPrepRoot.MLPrep" + offset: U32 + data: U8Array + + +@dataclass +class PrepPowerDownRequest(PrepCommand): + """Request power down (cmd=17, dest=MLPrep).""" + + command_id = 17 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepConfirmPowerDown(PrepCommand): + """Confirm power down (cmd=18, dest=MLPrep).""" + + command_id = 18 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepCancelPowerDown(PrepCommand): + """Cancel power down (cmd=19, dest=MLPrep).""" + + command_id = 19 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepRemoveChannelPower(PrepCommand): + """Remove channel power for head swap (cmd=23, dest=MLPrep).""" + + command_id = 23 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepRestoreChannelPower(PrepCommand): + """Restore channel power after head swap (cmd=24, dest=MLPrep).""" + + command_id = 24 + firmware_path = "MLPrepRoot.MLPrep" + delay_ms: U32 + + +@dataclass +class PrepSetDeckLight(PrepCommand): + """Set deck LED colour (cmd=25, dest=MLPrep).""" + + command_id = 25 + firmware_path = "MLPrepRoot.MLPrep" + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + +@dataclass +class PrepGetDeckLight(PrepStatusRequest): + """Get deck LED colour (cmd=26, dest=MLPrep).""" + + command_id = 26 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + +@dataclass +class PrepSuspendedPark(PrepCommand): + """Suspended park / move to load position (cmd=29, dest=MLPrep). + + Reuses :class:`GantryMoveXYZParameters` on the **MLPrep** coordinator, not + PipettorRoot — distinct from :class:`PrepMoveToPosition` and from MPH moves + (:class:`MphMoveToPosition`). + """ + + command_id = 29 + firmware_path = "MLPrepRoot.MLPrep" + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMethodBegin(PrepCommand): + """Begin method (cmd=30, dest=MLPrep).""" + + command_id = 30 + firmware_path = "MLPrepRoot.MLPrep" + automatic_pause: PaddedBool + + +@dataclass +class PrepMethodEnd(PrepCommand): + """End method (cmd=31, dest=MLPrep).""" + + command_id = 31 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepMethodAbort(PrepCommand): + """Abort method (cmd=33, dest=MLPrep).""" + + command_id = 33 + firmware_path = "MLPrepRoot.MLPrep" + + +@dataclass +class PrepIsParked(PrepStatusRequest): + """Query parked status (cmd=34, dest=MLPrep). Firmware yaml: IsParked(void) -> parked: bool.""" + + command_id = 34 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepIsSpread(PrepStatusRequest): + """Query spread status (cmd=35, dest=MLPrep). Same HOI pattern as :class:`PrepIsParked`.""" + + command_id = 35 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +# ----------------------------------------------------------------------------- +# Wire structs for config responses (used by nested Response and InstrumentConfig) +# ----------------------------------------------------------------------------- + + +@dataclass +class _DeckSiteDefinitionWire: + """Wire shape for one DeckSiteDefinition (GetDeckSiteDefinitions element).""" + + default_values: PaddedBool + id: U32 + left_bottom_front_x: F32 + left_bottom_front_y: F32 + left_bottom_front_z: F32 + length: F32 + width: F32 + height: F32 + + +@dataclass +class _CalibrationSiteDefinitionWire: + """Wire shape for one CalibrationSiteDefinition (GetCalibrationSiteDefinitions element). + + Same fields as DeckSiteDefinition plus trailing Post (BOOL). + """ + + default_values: PaddedBool + id: U32 + left_bottom_front_x: F32 + left_bottom_front_y: F32 + left_bottom_front_z: F32 + length: F32 + width: F32 + height: F32 + post: PaddedBool + + +@dataclass +class _ChannelHardwareConfigWire: + """Wire shape for ChannelHardwareConfig (GetChannelHardwareConfiguration element).""" + + channel: WEnum # ChannelIndex + hardware: WEnum # Hardware type enum (interface 2, id 1) + + +@dataclass +class _ChannelCalibrationValuesWire: + """Wire shape for ChannelCalibrationValues (GetCalibrationValues element).""" + + index: WEnum # ChannelIndex + y_offset: F32 + z_offset: F32 + squeeze_position: U32 + z_touchoff: U32 + pressure_shift: U32 + pressure_monitoring_shift: U32 + dispenser_return_distance: F32 + z_tip_height: F32 + core_ii: PaddedBool + + +@dataclass +class _WasteSiteDefinitionWire: + """Wire shape for one WasteSiteDefinition (GetWasteSiteDefinitions element).""" + + default_values: PaddedBool + index: WEnum + x_position: I8 + y_position: U16 + z_position: F32 + z_seek: F32 + + +# ----------------------------------------------------------------------------- +# Config queries (MLPrep / DeckConfiguration) for _get_hardware_config +# (inherit :class:`PrepStatusRequest`, defined above) +# ----------------------------------------------------------------------------- + + +@dataclass +class PrepGetIsEnclosurePresent(PrepStatusRequest): + """GetIsEnclosurePresent (cmd=21, dest=MLPrep). Firmware yaml: -> value: bool.""" + + command_id = 21 + firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepGetSafeSpeedsEnabled(PrepStatusRequest): + """GetSafeSpeedsEnabled (cmd=28, dest=MLPrep). Firmware yaml: -> value: bool.""" + + command_id = 28 + firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + +@dataclass +class PrepGetDefaultTraverseHeight(PrepStatusRequest): + """GetDefaultTraverseHeight (cmd=10, dest=MLPrep). Returns F32.""" + + command_id = 10 + firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + value: F32 + + +@dataclass +class PrepGetTipAndNeedleDefinitions(PrepStatusRequest): + """GetTipAndNeedleDefinitions (cmd=11, dest=MLPrep). + + Returns the list of tip/needle definitions registered on the instrument. + Introspection: iface=1 id=11 GetTipAndNeedleDefinitions(value: type_64) -> void + (response carries STRUCTURE_ARRAY of tip definition structs). + """ + + command_id = 11 + firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + definitions: Annotated[list[TipDefinition], StructArray()] + + +@dataclass +class PrepGetDeckBounds(PrepStatusRequest): + """GetDeckBounds (cmd=1, dest=DeckConfiguration). Returns 6× F32 (min/max x,y,z).""" + + command_id = 1 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + min_x: F32 + max_x: F32 + min_y: F32 + max_y: F32 + min_z: F32 + max_z: F32 + + +@dataclass +class PrepGetCalibrationSiteDefinitions(PrepStatusRequest): + """GetCalibrationSiteDefinitions (cmd=3, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of CalibrationSiteDefinition structs: + DefaultValues: BOOL, Id: U32, LeftBottomFrontX/Y/Z: F32, Length, Width, Height: F32, Post: BOOL + """ + + command_id = 3 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_CalibrationSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetDeckSiteDefinitions(PrepStatusRequest): + """GetDeckSiteDefinitions (cmd=7, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of DeckSiteDefinition structs: + DefaultValues: BOOL, Id: U32, LeftBottomFrontX: F32, LeftBottomFrontY: F32, + LeftBottomFrontZ: F32, Length: F32, Width: F32, Height: F32 + """ + + command_id = 7 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_DeckSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetWasteSiteDefinitions(PrepStatusRequest): + """GetWasteSiteDefinitions (cmd=12, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of WasteSiteDefinition structs: + DefaultValues: BOOL, Index: ENUM, XPosition: I8, YPosition: U16, + ZPosition: F32, ZSeek: F32 + """ + + command_id = 12 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_WasteSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetChannelBounds(PrepStatusRequest): + """GetChannelBounds (cmd=10, dest=PipettorService). + + Returns per-channel movement bounds (x_min, x_max, y_min, y_max, z_min, z_max) + as a StructArray of ChannelBoundsParameters. + """ + + command_id = 10 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor.PipettorService" + + @dataclass(frozen=True) + class Response: + bounds: Annotated[list[ChannelBoundsParameters], StructArray()] + + +@dataclass +class PrepGetPresentChannels(PrepStatusRequest): + """GetPresentChannels (cmd=17, dest=MLPrepService). + + Returns a list of enum values (iface=1, id=5): which channels are present. + Map to ChannelIndex: 0=InvalidIndex, 1=FrontChannel, 2=RearChannel, 3=MPHChannel. + Use this to determine hardware configuration: 1 vs 2 channels, or 8MPH presence. + """ + + command_id = 17 + firmware_path = "MLPrepRoot.MLPrepService" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + channels: EnumArray # list of ints: map to ChannelIndex for present channels + + +# ----------------------------------------------------------------------------- +# MLPrepCalibration commands +# ----------------------------------------------------------------------------- + + +@dataclass +class PrepBeginCalibration(PrepCommand): + """BeginCalibration (cmd=1, dest=MLPrepCalibration). Enter calibration mode.""" + + command_id = 1 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + +@dataclass +class PrepCancelCalibration(PrepCommand): + """CancelCalibration (cmd=2, dest=MLPrepCalibration). Cancel active calibration session.""" + + command_id = 2 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + +@dataclass +class PrepEndCalibration(PrepCommand): + """EndCalibration (cmd=3, dest=MLPrepCalibration). End calibration and store results with timestamp.""" + + command_id = 3 + firmware_path = "MLPrepRoot.MLPrepCalibration" + date_time: Annotated[HoiDateTime, Struct()] + + +@dataclass +class PrepResetCalibration(PrepCommand): + """ResetCalibration (cmd=4, dest=MLPrepCalibration). Reset calibration data, optionally storing.""" + + command_id = 4 + firmware_path = "MLPrepRoot.MLPrepCalibration" + store: PaddedBool + + +@dataclass +class PrepCalibrationInitialize(PrepCommand): + """CalibrationInitialize (cmd=5, dest=MLPrepCalibration). Initialize calibration hardware.""" + + command_id = 5 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + +@dataclass +class NeedleDefinition: + """Wire shape for NeedleDefinition (MLPrepCalibration local struct, id=2). + + When default_values=True the firmware uses stored defaults for all fields. + TipDefinition is nested (global pool source_id=1, ref_id=8). + """ + + default_values: PaddedBool + x_position: F32 + y_position: F32 + z_start: F32 + z_stop: F32 + tip_definition: Annotated[TipDefinition, Struct()] + tip_mask: U32 + + @classmethod + def defaults(cls) -> "NeedleDefinition": + """Return an all-defaults instance (firmware fills in stored values).""" + return cls( + default_values=True, + x_position=0.0, + y_position=0.0, + z_start=0.0, + z_stop=0.0, + tip_definition=TipDefinition( + default_values=True, + id=0, + volume=0.0, + length=0.0, + tip_type=0, + has_filter=False, + is_needle=False, + is_tool=False, + label="", + ), + tip_mask=0, + ) + + +@dataclass +class PrepSelfCalibrate(PrepCommand): + """SelfCalibrate (cmd=6, dest=MLPrepCalibration). + + Runs a full self-calibration sequence. Set individual booleans to select + which calibration phases to run. Pass NeedleDefinition.defaults() to use + firmware-stored needle parameters. + """ + + command_id = 6 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channels: WEnum # ChannelIndex + axis: PaddedBool + pressure: PaddedBool + touchoff: PaddedBool + needle: Annotated[NeedleDefinition, Struct()] + + +@dataclass +class PrepCalibrateXAxis(PrepCommand): + """CalibrateXAxis (cmd=7, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 7 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + +@dataclass +class PrepCalibrateYAxis(PrepCommand): + """CalibrateYAxis (cmd=8, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 8 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + +@dataclass +class PrepCalibrateZAxis(PrepCommand): + """CalibrateZAxis (cmd=9, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 9 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + +@dataclass +class PrepCalibrateSqueeze(PrepCommand): + """CalibrateSqueeze (cmd=14, dest=MLPrepCalibration). Returns position: U32.""" + + command_id = 14 + firmware_path = "MLPrepRoot.MLPrepCalibration" + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + position: U32 + + +@dataclass +class PrepCalibrateSqueezeTips(PrepCommand): + """CalibrateSqueezeTips (cmd=15, dest=MLPrepCalibration). + + Takes per-channel TipPositionParameters (same struct as pick_up_tips) and + returns per-channel squeeze positions as a list of u32. + """ + + command_id = 15 + firmware_path = "MLPrepRoot.MLPrepCalibration" + channels: Annotated[list[TipPositionParameters], StructArray()] + + @dataclass(frozen=True) + class Response: + positions: U32Array + + +@dataclass +class PrepGetCalibrationValues(PrepStatusRequest): + """GetCalibrationValues (cmd=16, dest=MLPrepCalibration). + + Returns independentOffsetX (F32), mphOffsetX (F32), and per-channel + calibration values as a StructArray of ChannelCalibrationValues. + """ + + command_id = 16 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + @dataclass(frozen=True) + class Response: + independent_offset_x: F32 + mph_offset_x: F32 + channel_values: Annotated[list[_ChannelCalibrationValuesWire], StructArray()] + + +@dataclass +class PrepGetChannelHardwareConfiguration(PrepStatusRequest): + """GetChannelHardwareConfiguration (cmd=24, dest=MLPrepCalibration). + + Response is a StructArray of ChannelHardwareConfig: Channel (enum) + Hardware (enum). + """ + + command_id = 24 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + @dataclass(frozen=True) + class Response: + channels: Annotated[list[_ChannelHardwareConfigWire], StructArray()] diff --git a/pylabrobot/hamilton/prep/tests/__init__.py b/pylabrobot/hamilton/prep/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/prep/tests/channels_tests.py b/pylabrobot/hamilton/prep/tests/channels_tests.py new file mode 100644 index 00000000000..f50defe8b86 --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/channels_tests.py @@ -0,0 +1,141 @@ +"""PrepPIPChannel facade + enumeration against the chatterbox.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from pylabrobot.hamilton.prep import Prep +from pylabrobot.hamilton.prep.channels import PrepChannels, PrepPIPChannel +from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub +from pylabrobot.resources.hamilton import PrepDeck, STARLetDeck, hamilton_96_tiprack_50uL_NTR +from pylabrobot.resources.tip_tracker import set_tip_tracking +from pylabrobot.resources.volume_tracker import set_volume_tracking + + +def _run(coro): + asyncio.run(coro) + + +def test_channels_match_info_num_channels(): + """PrepChannels.channels length matches info.config.num_channels on a default chatterbox.""" + + async def _t(): + p = Prep(deck=STARLetDeck(), chatterbox=True) + await p.setup() + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + assert len(p.channels.channels) == p.info.config.num_channels + for i, ch in enumerate(p.channels.channels): + assert isinstance(ch, PrepPIPChannel) + assert ch.index == i + await p.stop() + + _run(_t()) + + +def test_channels_attach_bounds_even_when_empty_offline(): + """Chatterbox firmware tree is empty, so bounds are None — but the attribute must exist.""" + + async def _t(): + p = Prep(deck=STARLetDeck(), chatterbox=True) + await p.setup() + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + for ch in p.channels.channels: + assert hasattr(ch, "bounds") + assert ch.bounds is None + await p.stop() + + _run(_t()) + + +def test_channels_tip_trackers_pick_and_drop(): + """pick_up_tips / drop_tips update spot + channel TipTrackers when tip tracking is on.""" + + async def _t(): + set_tip_tracking(True) + try: + deck = PrepDeck() + tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.channels is not None + spots = [tip_rack.get_item("A1"), tip_rack.get_item("B1")] + n = min(2, p.channels.num_channels) + spots = spots[:n] + use = list(range(n)) + assert all(s.has_tip() for s in spots) + assert all(t is None for t in p.channels.get_mounted_tips()[:n]) + + await p.channels.pick_up_tips(spots, use_channels=use) + assert all(not s.has_tip() for s in spots) + mounted = p.channels.get_mounted_tips() + assert all(mounted[i] is not None for i in use) + assert all(p.channels.head[i].has_tip for i in use) + + await p.channels.drop_tips(spots, use_channels=use) + assert all(s.has_tip() for s in spots) + assert all(not p.channels.head[i].has_tip for i in use) + await p.stop() + finally: + set_tip_tracking(False) + + _run(_t()) + + +def test_channels_volume_trackers_aspirate_dispense(): + """aspirate/dispense update well and tip VolumeTrackers when volume tracking is on.""" + + async def _t(): + set_tip_tracking(True) + set_volume_tracking(True) + try: + deck = PrepDeck() + tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) + plate = deck[0] = cor_axy_96_wellplate_500uL_Ub("plate") + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.channels is not None + n = min(2, p.channels.num_channels) + spots = [tip_rack.get_item("A1"), tip_rack.get_item("B1")][:n] + use = list(range(n)) + src = plate["A1:B1"][:n] + dst = plate["A7:B7"][:n] + vols = [20.0] * n + for well in src: + well.tracker.set_volume(100.0) + + await p.channels.pick_up_tips(spots, use_channels=use) + await p.channels.aspirate( + src, + vols=vols, + use_channels=use, + disable_volume_correction=[True] * n, + ) + for well in src: + assert well.tracker.get_used_volume() == pytest.approx(80.0) + for ch in use: + tip = p.channels.head[ch].get_tip() + assert tip.tracker.get_used_volume() == pytest.approx(20.0) + + await p.channels.dispense( + dst, + vols=vols, + use_channels=use, + disable_volume_correction=[True] * n, + ) + for well in dst: + assert well.tracker.get_used_volume() == pytest.approx(20.0) + for ch in use: + tip = p.channels.head[ch].get_tip() + assert tip.tracker.get_used_volume() == pytest.approx(0.0) + + await p.channels.drop_tips(spots, use_channels=use) + await p.stop() + finally: + set_tip_tracking(False) + set_volume_tracking(False) + + _run(_t()) diff --git a/pylabrobot/hamilton/prep/tests/client_tests.py b/pylabrobot/hamilton/prep/tests/client_tests.py new file mode 100644 index 00000000000..5e51745bc32 --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/client_tests.py @@ -0,0 +1,177 @@ +import asyncio + +import pytest + +from pylabrobot.hamilton.prep import Prep, PrepChatterboxClient +from pylabrobot.hamilton.prep import prep_commands as PrepCmd +from pylabrobot.hamilton.prep.channels import PrepChannels +from pylabrobot.hamilton.prep.gripper import PrepGripper, PrepGripperArm +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.resources.hamilton import STARLetDeck + + +def test_chatterbox_sets_resolved_interfaces_and_channels(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + + assert isinstance(p.client.mlprep_address, Address) + addr = await p.client.resolve_path("MLPrepRoot.PipettorRoot.Pipettor") + assert isinstance(addr, Address) + assert p.info.config.num_channels == 2 + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + assert p.channels.num_channels == 2 + assert p.channels.setup_finished is True + # Default setup: use_v1_aspirate_dispense=False → v2 probe passes (chatterbox stubs). + assert p.channels._supports_v2_pipetting is True + + await p.stop() + assert p.info._config is None + + asyncio.run(_run()) + + +def test_chatterbox_use_v1_skips_v2_probe(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup(use_v1_aspirate_dispense=True) + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + assert p.channels.setup_finished is True + assert p.channels._supports_v2_pipetting is False + + await p.stop() + + asyncio.run(_run()) + + +def test_prep_device_motion_method_and_power_commands(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + await p.park() + await p.spread() + assert p.method is not None + await p.method.begin(automatic_pause=False) + await p.method.end() + await p.cancel_power_down() + await p.stop() + + asyncio.run(_run()) + + +def test_prep_method_run_context_manager_aborts_on_exception(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.method is not None + + calls: list[str] = [] + orig_begin = p.method.begin + orig_end = p.method.end + orig_abort = p.method.abort + + async def rec_begin(automatic_pause: bool = False) -> None: + calls.append("begin") + await orig_begin(automatic_pause=automatic_pause) + + async def rec_end() -> None: + calls.append("end") + await orig_end() + + async def rec_abort() -> None: + calls.append("abort") + await orig_abort() + + p.method.begin = rec_begin # type: ignore[method-assign] + p.method.end = rec_end # type: ignore[method-assign] + p.method.abort = rec_abort # type: ignore[method-assign] + + # Clean exit: begin + end, no abort. + async with p.method.run(): + pass + assert calls == ["begin", "end"] + + # Exception inside: begin + abort, re-raised. + calls.clear() + with pytest.raises(RuntimeError, match="boom"): + async with p.method.run(): + raise RuntimeError("boom") + assert calls == ["begin", "abort"] + + await p.stop() + + asyncio.run(_run()) + + +def test_prep_device_wires_calibration_after_setup(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.info.num_channels == p.info.config.num_channels + assert p.info.has_mph == p.info.config.has_mph + assert p.calibration is not None + assert p.calibration.num_channels == p.info.config.num_channels + assert p.calibration.has_mph == p.info.config.has_mph + assert isinstance(p.gripper, PrepGripper) + async with p.core_grippers() as arm: + assert isinstance(arm, PrepGripperArm) + assert isinstance(arm.backend, PrepGripper) + await p.stop() + + asyncio.run(_run()) + + +def test_send_command_surfaces_clear_error_for_unresolvable_path(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + + missing = "MLPrepRoot.MLPrep" + orig_resolve = p.client.resolve_path + + async def _fake_resolve(path: str): + if path == missing: + raise KeyError(path) + return await orig_resolve(path) + + p.client.resolve_path = _fake_resolve # type: ignore[assignment] + + with pytest.raises(RuntimeError, match="firmware path"): + await p.client.send_command(PrepCmd.PrepPark()) + await p.stop() + + asyncio.run(_run()) + + +def test_chatterbox_preregisters_diagnostic_paths(): + async def _run() -> None: + d = PrepChatterboxClient() + await d.setup() + assert isinstance(await d.resolve_path("MLPrepRoot.MLPrepCpu"), Address) + assert isinstance(await d.resolve_path("MLPrepRoot.PipettorRoot.ModuleInformation"), Address) + await d.stop() + + asyncio.run(_run()) + + +def test_force_initialize_skips_is_initialized_check(): + """When force_initialize=True, Prep.setup() never queries is_initialized.""" + from unittest.mock import AsyncMock + + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + p.info.is_initialized = AsyncMock(side_effect=AssertionError("should not be called")) # type: ignore[method-assign] + await p.setup(force_initialize=True) + p.info.is_initialized.assert_not_called() + await p.stop() + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/prep/tests/gripper_tests.py b/pylabrobot/hamilton/prep/tests/gripper_tests.py new file mode 100644 index 00000000000..9adc5047ab3 --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/gripper_tests.py @@ -0,0 +1,257 @@ +"""Tests for PrepGripperArm resource/coordinate pick and drop helpers.""" + +from __future__ import annotations + +import asyncio +from typing import Any, List, Optional +from unittest.mock import AsyncMock + +import pytest + +from pylabrobot.hamilton.prep import Prep +from pylabrobot.hamilton.prep import prep_commands as PrepCmd +from pylabrobot.hamilton.prep.gripper import PrepGripper, PrepGripperArm +from pylabrobot.resources import Coordinate +from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub +from pylabrobot.resources.hamilton import HamiltonCoreGrippers, PrepDeck + + +def _record_send(prep: Prep) -> list[Any]: + captured: list[Any] = [] + orig_send = prep.client.send_command + + async def recording(command, **kw): + captured.append(command) + return await orig_send(command, **kw) + + prep.client.send_command = recording # type: ignore[method-assign, assignment] + return captured + + +def _make_arm(deck: PrepDeck) -> PrepGripperArm: + backend = PrepGripper(client=AsyncMock(), channels=AsyncMock()) + backend.pick_up_at_location = AsyncMock() # type: ignore[method-assign] + backend.drop_at_location = AsyncMock() # type: ignore[method-assign] + return PrepGripperArm(backend=backend, reference_resource=deck, grip_axis="y") + + +def test_drop_location_matches_holder_geometry_and_offset(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + dest = deck[2] + arm = _make_arm(deck) + + pdfb = arm._resolve_pickup_distance(plate, None) + arm._held_resource = plate + arm._pickup_distance_from_bottom = pdfb + arm._holding_resource_width = arm._resource_width(plate) + + offset = Coordinate(1.0, 2.0, 3.0) + got = arm._drop_location(dest, offset) + + expected = ( + dest.get_absolute_location("l", "f", "b") + + dest.get_default_child_location(plate) + + plate.center() + + offset + + Coordinate(0, 0, pdfb) + ) + assert got.x == pytest.approx(expected.x) + assert got.y == pytest.approx(expected.y) + assert got.z == pytest.approx(expected.z) + + +def test_drop_resource_not_holding_raises(): + deck = PrepDeck(with_core_grippers=True) + arm = _make_arm(deck) + + async def _run() -> None: + with pytest.raises(RuntimeError, match="Not holding anything"): + await arm.drop_resource(deck[2]) + + asyncio.run(_run()) + + +def test_drop_resource_after_coordinate_pick_raises(): + deck = PrepDeck(with_core_grippers=True) + arm = _make_arm(deck) + + async def _run() -> None: + await arm.pick_up_at_location( + Coordinate(100, 200, 50), + resource_width=85.0, + resource_length=127.0, + resource_height=14.0, + plate_top_z_offset=5.0, + ) + with pytest.raises(RuntimeError, match="pick_up_resource"): + await arm.drop_resource(deck[2]) + + asyncio.run(_run()) + + +def test_drop_resource_reassigns_holder(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + dest = deck[2] + arm = _make_arm(deck) + dropped: List[Coordinate] = [] + + async def _capture_drop(location: Coordinate, resource_width: float, **kwargs: Any) -> None: + del resource_width, kwargs + dropped.append(location) + + arm.backend.drop_at_location = _capture_drop # type: ignore[method-assign] + + async def _run() -> None: + await arm.pick_up_resource(plate) + assert plate.parent is deck[4] + await arm.drop_resource(dest) + assert plate.parent is dest + assert dest.resource is plate + assert deck[4].resource is None + assert arm._held_resource is None + assert arm._holding_resource_width is None + assert len(dropped) == 1 + + asyncio.run(_run()) + + +def test_pick_up_resource_width_override(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + arm = _make_arm(deck) + captured: dict[str, Any] = {} + + async def _capture_pick( + location: Coordinate, + resource_width: float, + *, + resource_length: float, + resource_height: float, + plate_top_z_offset: float, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + del location, resource_length, resource_height, plate_top_z_offset + del clearance_y, grip_speed_y, squeeze_mm + captured["resource_width"] = resource_width + + arm.backend.pick_up_at_location = _capture_pick # type: ignore[method-assign] + + async def _run() -> None: + await arm.pick_up_resource(plate, resource_width=80.5) + assert captured["resource_width"] == 80.5 + assert arm._holding_resource_width == 80.5 + + asyncio.run(_run()) + + +def test_pick_up_at_location_enables_drop_at_location(): + deck = PrepDeck(with_core_grippers=True) + arm = _make_arm(deck) + place = Coordinate(10, 20, 30) + + async def _run() -> None: + await arm.pick_up_at_location( + Coordinate(1, 2, 3), + resource_width=85.0, + resource_length=127.0, + resource_height=14.0, + plate_top_z_offset=5.0, + ) + assert arm._holding_resource_width == 85.0 + assert arm._held_resource is None + await arm.drop_at_location(place) + arm.backend.drop_at_location.assert_awaited_once() # type: ignore[attr-defined] + args = arm.backend.drop_at_location.await_args # type: ignore[attr-defined] + assert args is not None + assert args.args[0] == place + assert args.args[1] == 85.0 + assert arm._holding_resource_width is None + + asyncio.run(_run()) + + +def test_drop_resource_applies_offset_to_firmware_location(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + dest = deck[2] + arm = _make_arm(deck) + dropped_loc: Optional[Coordinate] = None + + async def _capture_drop(location: Coordinate, resource_width: float, **kwargs: Any) -> None: + nonlocal dropped_loc + del resource_width, kwargs + dropped_loc = location + + arm.backend.drop_at_location = _capture_drop # type: ignore[method-assign] + offset = Coordinate(0.5, -0.25, 1.0) + + async def _run() -> None: + await arm.pick_up_resource(plate) + expected = arm._drop_location(dest, offset) + await arm.drop_resource(dest, offset=offset) + assert dropped_loc is not None + assert dropped_loc.x == pytest.approx(expected.x) + assert dropped_loc.y == pytest.approx(expected.y) + assert dropped_loc.z == pytest.approx(expected.z) + + asyncio.run(_run()) + + +def test_pick_up_tool_default_pre_position_moves_then_picks(): + """Default pre_position=True issues PrepMoveToPosition before PrepPickUpTool.""" + + async def _run() -> None: + deck = PrepDeck(with_core_grippers=True) + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.gripper is not None + captured = _record_send(p) + + await p.pick_up_core_grippers() + + seq = [ + c for c in captured if isinstance(c, (PrepCmd.PrepMoveToPosition, PrepCmd.PrepPickUpTool)) + ] + assert len(seq) >= 2 + assert isinstance(seq[0], PrepCmd.PrepMoveToPosition) + assert isinstance(seq[1], PrepCmd.PrepPickUpTool) + + await p.return_core_grippers() + await p.stop() + + asyncio.run(_run()) + + +def test_pick_up_tool_pre_position_false_skips_move(): + """Explicit pre_position=False sends PrepPickUpTool without a prior PrepMoveToPosition.""" + + async def _run() -> None: + deck = PrepDeck(with_core_grippers=True) + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.gripper is not None + captured = _record_send(p) + + mount = deck.get_resource("core_grippers") + assert isinstance(mount, HamiltonCoreGrippers) + loc = mount.get_location_wrt(deck) + await p.gripper.pick_up_tool( + tool_position_x=loc.x, + tool_position_z=loc.z, + front_channel_position_y=loc.y + mount.front_channel_y_center, + rear_channel_position_y=loc.y + mount.back_channel_y_center, + pre_position=False, + ) + + moves = [c for c in captured if isinstance(c, PrepCmd.PrepMoveToPosition)] + pickups = [c for c in captured if isinstance(c, PrepCmd.PrepPickUpTool)] + assert moves == [] + assert len(pickups) >= 1 + + await p.stop() + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/prep/tests/head8_tests.py b/pylabrobot/hamilton/prep/tests/head8_tests.py new file mode 100644 index 00000000000..650c3bd269c --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/head8_tests.py @@ -0,0 +1,589 @@ +"""Tests for PrepHead8. + +Covers core logic that must survive refactors: + - _resolve_probe_positions: pitch validation for 96-well columns and interleaved 384-well + - _validate_container_span: minimum Y-span check for trough path + - all-8-channel enforcement (ganged head constraint) + - V1/V2 aspirate/dispense dispatch and LLD/TADM kwargs +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from pylabrobot.hamilton.prep import Prep +from pylabrobot.hamilton.prep import prep_commands as PrepCmd +from pylabrobot.hamilton.prep.channels import ( + LLDMode, + _build_pipettor_gantry_move_parameters, +) +from pylabrobot.hamilton.prep.head8 import PROBE_PITCH_MM, PrepHead8 +from pylabrobot.resources import Coordinate +from pylabrobot.resources.corning.axygen.plates import Cor_Axy_96_wellplate_500uL_Ub +from pylabrobot.resources.hamilton import PrepDeck, hamilton_96_tiprack_50uL_NTR + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_deck(): + deck = PrepDeck() + tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) + src_plate = deck[0] = Cor_Axy_96_wellplate_500uL_Ub("src") + dst_plate = deck[4] = Cor_Axy_96_wellplate_500uL_Ub("dst") + return deck, tip_rack, src_plate, dst_plate + + +def _make_head8() -> PrepHead8: + return PrepHead8(client=None, info=None) # type: ignore[arg-type] + + +def _record_send(prep: Prep) -> tuple[list[Any], Any]: + captured: list[Any] = [] + orig_send = prep.client.send_command + + async def recording(command, **kw): + captured.append(command) + return await orig_send(command, **kw) + + prep.client.send_command = recording # type: ignore[method-assign, assignment] + return captured, orig_send + + +# --------------------------------------------------------------------------- +# Group 1: _resolve_probe_positions / _validate_container_span +# --------------------------------------------------------------------------- + + +def test_resolve_probe_positions_valid_96well_column(): + """96-well column A→H has exactly 9mm pitch — should pass and return expected Ys.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + plate.location = Coordinate(100, 200, 0) + wells = plate.column(0) + + be = _make_head8() + ys = be._resolve_probe_positions(wells) + + assert len(ys) == 8 + ref_y = wells[0].get_absolute_location("c", "c", "cavity_bottom").y + for i, y in enumerate(ys): + assert y == pytest.approx(ref_y - i * PROBE_PITCH_MM), ( + f"probe {i}: expected {ref_y - i * PROBE_PITCH_MM}, got {y}" + ) + + +def test_resolve_probe_positions_misaligned_raises(): + """Wells not at 9mm pitch must raise ValueError with a descriptive message.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + plate.location = Coordinate(100, 200, 0) + col = plate.column(0) + # Swap rows 0 and 1 — now the pitch from well[0] to well[1] is wrong. + bad_wells = [col[1], col[0]] + list(col[2:]) + + be = _make_head8() + with pytest.raises(ValueError, match="9.0 mm probe pitch"): + be._resolve_probe_positions(bad_wells) + + +def test_resolve_probe_positions_interleaved_384well(): + """Every-other-row selection on a 96-well plate (simulating 4.5mm × 2 = 9mm pitch) passes.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + plate.location = Coordinate(100, 200, 0) + col = plate.column(0) + be = _make_head8() + ys = be._resolve_probe_positions(col) + ref_y = col[0].get_absolute_location("c", "c", "cavity_bottom").y + assert ys[0] == pytest.approx(ref_y) + assert ys[7] == pytest.approx(ref_y - 7 * PROBE_PITCH_MM) + + +def test_validate_container_span_sufficient(): + """Container wider than 63mm passes without error.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + # Cor_Axy_96 is 85.48mm in Y — well above 63mm minimum. + be = _make_head8() + be._validate_container_span(plate) # should not raise + + +def test_validate_container_span_too_narrow(): + """Container narrower than 63mm raises ValueError.""" + narrow = MagicMock() + narrow.name = "narrow_container" + narrow.get_size_y.return_value = 40.0 # less than 63mm + + be = _make_head8() + with pytest.raises(ValueError, match="too narrow"): + be._validate_container_span(narrow) + + +# --------------------------------------------------------------------------- +# Group 2: all-8-channel enforcement + PrepHead8 wiring +# --------------------------------------------------------------------------- + + +def test_partial_channel_pickup_raises_value_error(): + """PrepHead8 rejects pick_up_tips8 with fewer than all 8 channels.""" + + async def _run() -> None: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(1)[4:] # E2, F2, G2, H2 + with pytest.raises(ValueError, match="fully-ganged head"): + await p.head8.pick_up_tips8(spots, use_channels=(4, 5, 6, 7)) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_present_after_chatterbox_setup(): + async def _run() -> None: + deck, _, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + assert isinstance(p.head8, PrepHead8) + await p.stop() + + asyncio.run(_run()) + + +def test_head8_full_flow(): + """pick_up_tips8 → aspirate8 → dispense8 → drop_tips8 on chatterbox.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=20) + await p.head8.dispense8(wells=dst_plate.column(0), volume=20) + await p.head8.drop_tips8(spots) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_tip_trackers_pick_and_drop(): + """8 TipTrackers stay in sync across pick_up_tips8 / drop_tips8 with tip tracking on.""" + from pylabrobot.resources.tip_tracker import set_tip_tracking + + async def _run() -> None: + set_tip_tracking(True) + try: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + spots = tip_rack.column(0) + assert all(s.has_tip() for s in spots) + await p.head8.pick_up_tips8(spots) + assert all(not s.has_tip() for s in spots) + assert all(p.head8.head[i].has_tip for i in range(8)) + assert all(t is not None for t in p.head8.get_mounted_tips()) + await p.head8.drop_tips8(spots) + assert all(s.has_tip() for s in spots) + assert all(not p.head8.head[i].has_tip for i in range(8)) + await p.stop() + finally: + set_tip_tracking(False) + + asyncio.run(_run()) + + +def test_mph_move_to_position_command_metadata(): + move = PrepCmd.MphMoveToPosition(x_position=1.5, y_position=2.5, z_position=120.0) + assert move.firmware_path == "MLPrepRoot.MphRoot.MPH" + assert move.command_id == 17 + assert move.x_position == 1.5 and move.y_position == 2.5 and move.z_position == 120.0 + + via = PrepCmd.MphMoveToPositionViaLane(x_position=0.0, y_position=0.0, z_position=0.0) + assert via.command_id == 18 + assert via.firmware_path == move.firmware_path + params = move.build_parameters() + assert params is not None + + +def test_build_pipettor_gantry_move_parameters_maps_rear_front(): + m = _build_pipettor_gantry_move_parameters(10.0, [0, 1], [20.0, 30.0], [40.0, 50.0]) + assert m.gantry_x_position == 10.0 + assert len(m.axis_parameters) == 2 + assert m.axis_parameters[0].channel == PrepCmd.ChannelIndex.RearChannel + assert m.axis_parameters[0].y_position == 20.0 + assert m.axis_parameters[0].z_position == 40.0 + assert m.axis_parameters[1].channel == PrepCmd.ChannelIndex.FrontChannel + assert m.axis_parameters[1].y_position == 30.0 + assert m.axis_parameters[1].z_position == 50.0 + + +def test_head8_move_to_position_sends_mph_wire_commands(): + """PrepHead8.move_to_position sends MphMoveToPosition / ViaLane.""" + + async def _run() -> None: + deck, _, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + await p.head8.move_to_position(11.0, 22.5, 99.0) + direct = [c for c in captured if isinstance(c, PrepCmd.MphMoveToPosition)] + assert len(direct) == 1 + assert direct[0].x_position == 11.0 + assert direct[0].y_position == 22.5 + assert direct[0].z_position == 99.0 + + await p.head8.move_to_position(1.0, 2.0, 3.0, via_lane=True) + lanes = [c for c in captured if isinstance(c, PrepCmd.MphMoveToPositionViaLane)] + assert len(lanes) == 1 + assert lanes[0].x_position == 1.0 and lanes[0].y_position == 2.0 and lanes[0].z_position == 3.0 + + await p.stop() + + asyncio.run(_run()) + + +def test_pick_up_tips_default_pre_position_sends_mph_move_then_pickup(): + """Default pre_position=True issues MphMoveToPosition before MphPickupTips.""" + + async def _run() -> None: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + await p.head8.pick_up_tips8(tip_rack.column(0)) + + mph_seq = [ + c for c in captured if isinstance(c, (PrepCmd.MphMoveToPosition, PrepCmd.MphPickupTips)) + ] + assert len(mph_seq) >= 2 + assert isinstance(mph_seq[0], PrepCmd.MphMoveToPosition) + assert isinstance(mph_seq[1], PrepCmd.MphPickupTips) + + await p.stop() + + asyncio.run(_run()) + + +def test_pick_up_tips_pre_position_false_skips_mph_move(): + """Explicit pre_position=False sends only MphPickupTips among MPH move/pickup pair.""" + + async def _run() -> None: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + await p.head8.pick_up_tips8(tip_rack.column(1), pre_position=False) + + mph_moves = [c for c in captured if isinstance(c, PrepCmd.MphMoveToPosition)] + pickups = [c for c in captured if isinstance(c, PrepCmd.MphPickupTips)] + assert mph_moves == [] + assert len(pickups) >= 1 + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_partial_channel_aspirate_raises_value_error(): + """PrepHead8 rejects aspirate8 with fewer than all 8 channels.""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + + with pytest.raises(ValueError, match="fully-ganged head"): + await p.head8.aspirate8( + wells=src_plate.column(0)[:4], + volume=10, + use_channels=(0, 1, 2, 3), + ) + + await p.stop() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# Group 3: V2 aspirate/dispense dispatch +# --------------------------------------------------------------------------- + + +def test_head8_v2_aspirate_sends_mphaspiratenolldmonitoring2(): + """Chatterbox default (use_v1=False) → V2 command class is sent.""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) + + asp_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + v1_cmds = [ + c + for c in captured + if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring) + and not isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2) + ] + assert len(asp_cmds) == 1, f"Expected 1 MphAspirateNoLldMonitoring2, got {len(asp_cmds)}" + assert len(v1_cmds) == 0, "V1 aspirate command should not be sent when V2 is supported" + assert len(asp_cmds[0].aspirate_parameters) == 1, ( + "MPH sends a single struct element (probe-0 reference); firmware drives all 8 probes" + ) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_v2_dispense_sends_mphdispensetnolld2(): + """Chatterbox default (use_v1=False) → V2 dispense command class is sent.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) + await p.head8.dispense8(wells=dst_plate.column(0), volume=10) + + disp_cmds = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] + v1_cmds = [ + c + for c in captured + if isinstance(c, PrepCmd.MphDispenseNoLld) and not isinstance(c, PrepCmd.MphDispenseNoLld2) + ] + assert len(disp_cmds) == 1, f"Expected 1 MphDispenseNoLld2, got {len(disp_cmds)}" + assert len(v1_cmds) == 0, "V1 dispense command should not be sent when V2 is supported" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_v1_fallback_when_use_v1_flag_set(): + """use_v1_aspirate_dispense=True → V1 command classes are sent for MPH too.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup(use_v1_aspirate_dispense=True) + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) + await p.head8.dispense8(wells=dst_plate.column(0), volume=10) + + v2_asp = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + v2_disp = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] + v1_asp = [ + c + for c in captured + if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring) + and not isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2) + ] + v1_disp = [ + c + for c in captured + if isinstance(c, PrepCmd.MphDispenseNoLld) and not isinstance(c, PrepCmd.MphDispenseNoLld2) + ] + + assert len(v2_asp) == 0, "V2 aspirate should not be sent with use_v1=True" + assert len(v2_disp) == 0, "V2 dispense should not be sent with use_v1=True" + assert len(v1_asp) == 1, f"Expected 1 V1 aspirate, got {len(v1_asp)}" + assert len(v1_disp) == 1, f"Expected 1 V1 dispense, got {len(v1_disp)}" + + await p.stop() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# Group 4: LLD and TADM dispatch +# --------------------------------------------------------------------------- + + +def test_head8_aspirate_tadm_sends_mphaspirate_tadm2(): + """tadm= kwargs → MphAspirateTadm2 (v2, no LLD).""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8( + wells=src_plate.column(0), + volume=10, + tadm=PrepCmd.TadmParameters.default(), + ) + + tadm_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateTadm2)] + assert len(tadm_cmds) == 1, f"Expected 1 MphAspirateTadm2, got {len(tadm_cmds)}" + no_lld_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + assert len(no_lld_cmds) == 0, "NoLldMonitoring2 should not be sent when tadm= is set" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_aspirate_clld_sends_mphaspirate_with_lld2(): + """lld_mode=CAPACITIVE → MphAspirateWithLld2 (v2, LLD, no TADM).""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8( + wells=src_plate.column(0), + volume=10, + lld_mode=LLDMode.CAPACITIVE, + ) + + lld_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateWithLld2)] + assert len(lld_cmds) == 1, f"Expected 1 MphAspirateWithLld2, got {len(lld_cmds)}" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_aspirate_lld_and_tadm_sends_mphaspirate_with_lld_tadm2(): + """lld_mode=CAPACITIVE + tadm= → MphAspirateWithLldTadm2.""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8( + wells=src_plate.column(0), + volume=10, + lld_mode=LLDMode.CAPACITIVE, + tadm=PrepCmd.TadmParameters.default(), + ) + + lld_tadm_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateWithLldTadm2)] + assert len(lld_tadm_cmds) == 1, f"Expected 1 MphAspirateWithLldTadm2, got {len(lld_tadm_cmds)}" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_dispense_lld_pressure_raises(): + """lld_mode=PRESSURE on dispense raises ValueError — pressure LLD needs aspiration.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) + + with pytest.raises(ValueError, match="PRESSURE"): + await p.head8.dispense8( + wells=dst_plate.column(0), + volume=10, + lld_mode=LLDMode.PRESSURE, + ) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_command_version_override_v1(): + """command_version='v1' per-call override forces v1 even when v2 is available.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8( + wells=src_plate.column(0), + volume=10, + command_version="v1", + ) + await p.head8.dispense8( + wells=dst_plate.column(0), + volume=10, + command_version="v1", + ) + + v1_asp = [c for c in captured if type(c) is PrepCmd.MphAspirateNoLldMonitoring] + v2_asp = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + v1_disp = [c for c in captured if type(c) is PrepCmd.MphDispenseNoLld] + v2_disp = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] + + assert len(v1_asp) == 1, f"Expected 1 V1 aspirate with override, got {len(v1_asp)}" + assert len(v2_asp) == 0, "V2 aspirate must not be sent with command_version='v1'" + assert len(v1_disp) == 1, f"Expected 1 V1 dispense with override, got {len(v1_disp)}" + assert len(v2_disp) == 0, "V2 dispense must not be sent with command_version='v1'" + + await p.stop() + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/tests/__init__.py b/pylabrobot/hamilton/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py b/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py new file mode 100644 index 00000000000..6c6adb72e57 --- /dev/null +++ b/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py @@ -0,0 +1,110 @@ +"""Tests for :mod:`liquid_class_resolver`.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, Dict + +import pytest + +from pylabrobot.hamilton.liquid_class_resolver import ( + corrected_volumes_for_ops, + resolve_hamilton_liquid_classes, +) +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star import get_star_liquid_class +from pylabrobot.resources.hamilton import HamiltonTip, TipPickupMethod, TipSize +from pylabrobot.resources.liquid import Liquid + + +def _hlc(**overrides: Any) -> HamiltonLiquidClass: + base: Dict[str, Any] = dict( + curve={0.0: 0.0, 1000.0: 1000.0}, + aspiration_flow_rate=1.0, + aspiration_mix_flow_rate=2.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=4.0, + aspiration_swap_speed=5.0, + aspiration_settling_time=6.0, + aspiration_over_aspirate_volume=7.0, + aspiration_clot_retract_height=8.0, + dispense_flow_rate=9.0, + dispense_mode=0.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=11.0, + dispense_blow_out_volume=12.0, + dispense_swap_speed=13.0, + dispense_settling_time=14.0, + dispense_stop_flow_rate=15.0, + dispense_stop_back_volume=16.0, + ) + base.update(overrides) + return HamiltonLiquidClass(**base) + + +def test_resolve_explicit_returns_copy(): + h = _hlc() + out = resolve_hamilton_liquid_classes([h], [], jet=False, blow_out=False) + assert out == [h] + out[0] = None # type: ignore[assignment] + assert h is not None + + +def test_resolve_auto_non_hamilton_tip_is_none(): + op = SimpleNamespace(tip=object()) + assert resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False) == [None] + + +def test_resolve_auto_hamilton_tip_matches_get_star(): + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + op = SimpleNamespace(tip=tip) + a = resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False)[0] + b = get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + assert a is not None and b is not None + assert a.aspiration_flow_rate == b.aspiration_flow_rate + + +def test_resolve_custom_lookup(): + custom = _hlc(aspiration_flow_rate=99.0) + + def lookup(**kwargs): # noqa: ARG001 + return custom + + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + op = SimpleNamespace(tip=tip) + got = resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False, lookup=lookup)[0] + assert got is not None + assert got.aspiration_flow_rate == 99.0 + + +def test_corrected_volumes_respects_disable_and_none_hlc(): + ops = [SimpleNamespace(volume=100.0)] + hlc = _hlc(curve={0.0: 0.0, 100.0: 200.0, 200.0: 400.0}) + assert corrected_volumes_for_ops(ops, [hlc], None) == [200.0] + assert corrected_volumes_for_ops(ops, [hlc], [True]) == [100.0] + assert corrected_volumes_for_ops(ops, [None], None) == [100.0] + + +def test_corrected_volumes_length_mismatch_raises(): + with pytest.raises(ValueError, match="hlcs length"): + corrected_volumes_for_ops([SimpleNamespace(volume=1.0)], []) diff --git a/pylabrobot/hamilton/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..0439cd74e5e --- /dev/null +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -0,0 +1,967 @@ +"""Curated tests for Hamilton TCP protocol implementation. + +Focused on high-value invariants: +- packet/frame wire shape and round-trip parsing +- DataFragment encode/decode and parser behavior +- warning/exception payload semantics +- command response auto-decode contract +""" + +from __future__ import annotations + +import struct +import unittest +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 = await client.resolve_target( + "pipettor_service", aliases={"pipettor_service": "Root.Child"} + ) + self.assertEqual(got, Address(1, 1, 999)) + + async 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 = 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()) + + async 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 = 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) + 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"]) + + 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) + 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 = await 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) diff --git a/pylabrobot/resources/__init__.py b/pylabrobot/resources/__init__.py index ca92f0edc5b..449326eceb0 100644 --- a/pylabrobot/resources/__init__.py +++ b/pylabrobot/resources/__init__.py @@ -40,6 +40,19 @@ from .powder import Powder from .resource import Resource from .resource_stack import ResourceStack +from .resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + all_channels_succeeded, + finalize_tip_ops, + finalize_volume_ops, + place_resource, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) from .revvity import * from .rotation import Rotation from .sergi import * diff --git a/pylabrobot/resources/hamilton/__init__.py b/pylabrobot/resources/hamilton/__init__.py index 8bcc28e3f86..1fc362db6ef 100644 --- a/pylabrobot/resources/hamilton/__init__.py +++ b/pylabrobot/resources/hamilton/__init__.py @@ -1,12 +1,15 @@ from .hamilton_decks import ( + HamiltonCoreGrippers, HamiltonDeck, HamiltonSTARDeck, + PrepDeck, STARDeck, STARLetDeck, + prep_core_gripper_mount, ) from .mfx_carriers import * from .mfx_modules import * -from .nimbus_decks import NimbusDeck +from .nimbus_decks import NimbusDeck, nimbus_core_gripper_1000ul_at_waste from .plate_adapters import * from .plate_carriers import * from .tip_carriers import * diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 8c0b9d3fff9..0fef5d50efa 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -2,13 +2,16 @@ import logging from abc import ABCMeta, abstractmethod -from typing import Literal, Optional, cast +from typing import List, Literal, Optional, cast from pylabrobot.resources.carrier import ResourceHolder from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.deck import Deck from pylabrobot.resources.errors import NoLocationError -from pylabrobot.resources.hamilton.tip_creators import hamilton_teaching_needle_300uL +from pylabrobot.resources.hamilton.tip_creators import ( + hamilton_teaching_needle_300uL, + hamilton_tip_300uL_filter, +) from pylabrobot.resources.resource import Resource from pylabrobot.resources.tip_rack import TipRack, TipSpot from pylabrobot.resources.trash import Trash @@ -389,6 +392,24 @@ def serialize(self): } +def prep_core_gripper_mount() -> HamiltonCoreGrippers: + """CORE gripper mount for PREP decks. Assign at Coordinate(290, 266.5, 62). + + Physical rear paddle at (290, 257.5, 62), front at (290, 275.5, 62). + front_channel_y_center / back_channel_y_center are named for the PREP command + (front_channel_position_y, rear_channel_position_y) so the correct paddle is used. + """ + return HamiltonCoreGrippers( + name="core_grippers", + back_channel_y_center=9.0, + front_channel_y_center=-9.0, + size_x=20.0, + size_y=20.0, + size_z=24.0, + model="prep_core_gripper_mount", + ) + + def hamilton_core_gripper_1000ul_at_waste() -> HamiltonCoreGrippers: # inner hole diameter is 8.6mm # distance from base of rack to outer base of containers: -7mm @@ -618,3 +639,85 @@ def STARDeck( with_teaching_rack=with_teaching_rack, core_grippers=core_grippers, ) + + +class PrepDeck(Deck): + """Hamilton PREP deck: labware spots, trash, teaching tip site, and waste positions. + + Geometry aligns with the prep_tcp / MLPrep DeckConfiguration teaching site and waste + sites used by :class:`~pylabrobot.hamilton.prep.channels.PrepChannels` + (``waste_rear``, ``waste_front``, ``waste_mph``). Validate coordinates on hardware + (plastic mounts, calibration) before production use. + + This is **not** a :class:`HamiltonSTARDeck` (rails/teaching rack layout differ). + """ + + def __init__( + self, + name: str = "deck", + size_x: float = 300.0, + size_y: float = 394.0, + size_z: float = 0, + origin: Coordinate = Coordinate.zero(), + category: str = "deck", + with_core_grippers: bool = False, + ): + super().__init__( + name=name, size_x=size_x, size_y=size_y, size_z=size_z, origin=origin, category=category + ) + if with_core_grippers: + self.assign_child_resource(prep_core_gripper_mount(), location=Coordinate(290, 266.5, 62.5)) + spots_list: List[ResourceHolder] = [] + for column in range(2): + for row in range(4): + x = column * 140 + y = row * 95.125 + spot = ResourceHolder( + name=f"spot_{column}_{row}", + size_x=127.76, + size_y=92, + size_z=12.5, + child_location=Coordinate( + 0, 1.5, 3.75 + ), # Adjusted for plastic corner mounts; validate on hardware + ) + self.assign_child_resource(spot, location=Coordinate(x, y, 0)) + spots_list.append(spot) + self.spots: List[ResourceHolder] = spots_list + + trash = Trash(name="trash", size_x=13, size_y=132.7, size_z=73) + self.assign_child_resource(trash, location=Coordinate(280.3, -3, 0)) + + teaching_tip_spot = TipSpot( + name="teaching_tip", + size_x=6.0, + size_y=6.0, + make_tip=hamilton_tip_300uL_filter, + size_z=0.0, + category="teaching_tip", + ) + self.assign_child_resource( + teaching_tip_spot, + location=Coordinate(x=284.76, y=214.29, z=23.85), + ) + + for waste_name, y_pos in [("waste_rear", 30.0), ("waste_front", 10.0), ("waste_mph", 112.0)]: + waste = Trash( + name=waste_name, + size_x=6.0, + size_y=6.0, + size_z=0.0, + category="waste_position", + ) + self.assign_child_resource( + waste, + location=Coordinate(x=286.8, y=y_pos, z=68.4), + ) + + def __getitem__(self, key: int) -> ResourceHolder: + """Labware spot by index 0–7 (column-major: ``spot_0_0`` … ``spot_1_3``).""" + return self.spots[key] + + def __setitem__(self, key: int, value: Resource): + """Assign a resource to labware spot ``key`` (0–7).""" + self.spots[key].assign_child_resource(value) diff --git a/pylabrobot/resources/hamilton/nimbus_decks.py b/pylabrobot/resources/hamilton/nimbus_decks.py index 00b90c2cbac..7221a2357ee 100644 --- a/pylabrobot/resources/hamilton/nimbus_decks.py +++ b/pylabrobot/resources/hamilton/nimbus_decks.py @@ -12,7 +12,10 @@ from typing import Any, Dict, List, Literal, Optional from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources.hamilton.hamilton_decks import HamiltonDeck +from pylabrobot.resources.hamilton.hamilton_decks import ( + HamiltonCoreGrippers, + HamiltonDeck, +) from pylabrobot.resources.resource import Resource from pylabrobot.resources.trash import Trash from pylabrobot.serializer import serialize @@ -20,6 +23,25 @@ logger = logging.getLogger(__name__) +def nimbus_core_gripper_1000ul_at_waste() -> HamiltonCoreGrippers: + """CORE gripper rack for Nimbus decks, co-located with the waste block. + + Derived from measured Hamilton coordinates on the default Nimbus8 deck: + Front paddle (ch_last): Ham(557.352, -293.030, 147.559) → PLR y = 70.800 + Back paddle (ch1): Ham(557.352, -263.820, 147.559) → PLR y = 100.010 + Resource center placed at PLR(708.862, 85.405, 147.559). + """ + return HamiltonCoreGrippers( + name="core_grippers", + back_channel_y_center=14.605, + front_channel_y_center=-14.605, + size_x=20.0, + size_y=30.0, + size_z=25.0, + model="nimbus_core_gripper_1000ul_at_waste", + ) + + class NimbusDeck(HamiltonDeck): """Hamilton Nimbus deck. @@ -45,6 +67,7 @@ def __init__( category: str = "deck", origin: Coordinate = Coordinate.zero(), waste_type: Optional[Literal["default_long"]] = "default_long", + core_grippers: Optional[Literal["1000uL-at-waste"]] = "1000uL-at-waste", ) -> None: """Create a new Nimbus deck. @@ -68,6 +91,10 @@ def __init__( origin: PyLabRobot origin coordinate (default: Coordinate.zero()) waste_type: Waste configuration type (default: "default_long"). If "default_long", creates a waste block with 8 channel positions. If None, no waste is created. + core_grippers: CORE gripper rack type (default: "1000uL-at-waste"). If + "1000uL-at-waste", assigns the gripper rack resource at the waste block + using the standard Nimbus8 paddle positions. Requires waste_type="default_long". + If None, no gripper resource is created. """ super().__init__( num_rails=num_rails, @@ -94,10 +121,13 @@ def __init__( # Store waste type for waste position lookup self.waste_type = waste_type + self.core_grippers_type = core_grippers # Create waste resources if specified if waste_type == "default_long": self._create_default_long_waste() + if core_grippers == "1000uL-at-waste": + self._create_core_grippers() def _create_default_long_waste(self) -> None: """Create default_long waste block with 8 channel positions. @@ -164,6 +194,22 @@ def _create_default_long_waste(self) -> None: # Assign waste position to waste block waste_block.assign_child_resource(waste_position, location=pos_plr_rel) + def _create_core_grippers(self) -> None: + """Assign CORE gripper rack to the waste block at the standard Nimbus8 paddle position.""" + waste_block = self.get_resource("default_long_block") + waste_loc = waste_block.get_location_wrt(self) + + # Center of the two paddles in Hamilton coordinates, converted to PLR + center_ham = Coordinate(x=557.352, y=(-293.030 + -263.820) / 2, z=147.559) + center_plr = self.from_hamilton_coordinate(center_ham) + + rel = Coordinate( + x=center_plr.x - waste_loc.x, + y=center_plr.y - waste_loc.y, + z=center_plr.z - waste_loc.z, + ) + waste_block.assign_child_resource(nimbus_core_gripper_1000ul_at_waste(), location=rel) + def rails_to_location(self, rails: int) -> Coordinate: """Convert a rail identifier to an absolute (x, y, z) coordinate. @@ -289,6 +335,7 @@ def serialize(self) -> dict: "rail_width": self._rail_width, "rail_y": self._rail_y, "waste_type": self.waste_type, + "core_grippers": None, # encoded as child resource; prevent double-creation on deserialize } @classmethod @@ -308,15 +355,16 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> "NimbusDeck": """ data_copy = data.copy() original_waste_type = data_copy.get("waste_type") - # Set waste_type=None to prevent __init__() from creating waste block - # The waste block will come from children data (already serialized) + original_core_grippers = data_copy.get("core_grippers") + # Suppress creation of waste/gripper resources in __init__; children carry the serialized data data_copy["waste_type"] = None + data_copy["core_grippers"] = None - # Call parent deserialize (waste block won't be created in __init__) deck = super().deserialize(data_copy, allow_marshal=allow_marshal) - # Restore waste_type attribute from serialized data to keep instance consistent + # Restore type attributes so the instance stays consistent with what was serialized deck.waste_type = original_waste_type + deck.core_grippers_type = original_core_grippers return deck @@ -338,6 +386,7 @@ def from_files( rail_width: Optional[float] = None, rail_y: Optional[float] = None, waste_type: Optional[Literal["default_long"]] = None, + core_grippers: Optional[Literal["1000uL-at-waste"]] = None, ) -> NimbusDeck: """Create a Nimbus deck by parsing config files. @@ -630,4 +679,5 @@ def extract_dck_exsite_ids(layout_num: int) -> List[str]: rail_y=rail_y_val, origin=origin, waste_type=waste_type, + core_grippers=core_grippers, ) diff --git a/pylabrobot/resources/hamilton/tip_carriers.py b/pylabrobot/resources/hamilton/tip_carriers.py index fc06281825f..a19ea670c7b 100644 --- a/pylabrobot/resources/hamilton/tip_carriers.py +++ b/pylabrobot/resources/hamilton/tip_carriers.py @@ -352,3 +352,20 @@ def TIP_CAR_NTR_A00(name: str) -> TipCarrier: ), model="TIP_CAR_NTR_A00", ) + + +def hamilton_prep_ftr_pedestal(name: str) -> TipCarrier: + """Hamilton cat. no.: 6600553-01 + Pedestal for elevating fixed tip racks (FTR) on the MicroLab Prep. + Body: 133.11 x 89.96 x 53.37 mm. FTR rack seats on top of the pedestal. + """ + site = ResourceHolder(name=f"{name}-0", size_x=122.4, size_y=82.6, size_z=0) + site.location = Coordinate(1.5, 1, 53.37) + return TipCarrier( + name=name, + size_x=133.11, + size_y=89.96, + size_z=53.37, + sites={0: site}, + model="hamilton_prep_ftr_pedestal", + ) diff --git a/pylabrobot/resources/resource_state.py b/pylabrobot/resources/resource_state.py new file mode 100644 index 00000000000..ee1c480cdc8 --- /dev/null +++ b/pylabrobot/resources/resource_state.py @@ -0,0 +1,150 @@ +"""Shared tip / volume / deck state helpers for device peers. + +Devices adapt instrument outcomes into :data:`ChannelSuccesses` / bools, then call +these helpers. No vendor or transport imports — safe for Prep, Nimbus, and a future +LiquidHandler to share. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Collection, Literal, Mapping, Optional, Sequence, Union + +from pylabrobot.resources.container import Container +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker, does_tip_tracking +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.volume_tracker import does_volume_tracking + +ChannelSuccesses = Mapping[int, bool] + + +def all_channels_succeeded(use_channels: Sequence[int]) -> dict[int, bool]: + return {ch: True for ch in use_channels} + + +def successes_from_failed_channels( + use_channels: Sequence[int], + failed: Collection[int], +) -> dict[int, bool]: + failed_set = set(failed) + return {ch: ch not in failed_set for ch in use_channels} + + +@dataclass(frozen=True) +class TipPickupIntent: + channel: int + tip_spot: TipSpot + tip: Tip + channel_tracker: TipTracker + + +@dataclass(frozen=True) +class TipDropIntent: + channel: int + destination: Union[TipSpot, Trash] + tip: Tip + channel_tracker: TipTracker + + +@dataclass(frozen=True) +class VolumeTransferIntent: + channel: int + container: Container + tip: Tip + volume_ul: float + direction: Literal["aspirate", "dispense"] + + +def queue_tip_pickups(intents: Sequence[TipPickupIntent]) -> None: + """Queue spot remove + channel add (commit=False). Spot ops gated by tip tracking.""" + for intent in intents: + if intent.channel_tracker.has_tip: + raise RuntimeError(f"Channel {intent.channel} already has a tip") + if does_tip_tracking() and not intent.tip_spot.tracker.is_disabled: + intent.tip_spot.tracker.remove_tip(commit=False) + intent.channel_tracker.add_tip(intent.tip, origin=intent.tip_spot, commit=False) + + +def queue_tip_drops(intents: Sequence[TipDropIntent]) -> None: + """Queue channel remove; TipSpot destinations get the tip back. Trash: channel only.""" + for intent in intents: + if not intent.tip.tracker.is_disabled and intent.tip.tracker.get_used_volume() > 1e-6: + raise RuntimeError( + f"Cannot drop tip on channel {intent.channel} with volume " + f"{intent.tip.tracker.get_used_volume()} uL" + ) + if not intent.channel_tracker.has_tip: + raise RuntimeError(f"Channel {intent.channel} has no tip to drop") + intent.channel_tracker.remove_tip(commit=False) + if isinstance(intent.destination, TipSpot): + if does_tip_tracking() and not intent.destination.tracker.is_disabled: + intent.destination.tracker.add_tip(intent.tip, origin=None, commit=False) + + +def finalize_tip_ops( + intents: Sequence[Union[TipPickupIntent, TipDropIntent]], + successes: ChannelSuccesses, +) -> None: + for intent in intents: + ok = successes.get(intent.channel, False) + if isinstance(intent, TipPickupIntent): + if does_tip_tracking() and not intent.tip_spot.tracker.is_disabled: + (intent.tip_spot.tracker.commit if ok else intent.tip_spot.tracker.rollback)() + (intent.channel_tracker.commit if ok else intent.channel_tracker.rollback)() + else: + (intent.channel_tracker.commit if ok else intent.channel_tracker.rollback)() + if isinstance(intent.destination, TipSpot): + if does_tip_tracking() and not intent.destination.tracker.is_disabled: + (intent.destination.tracker.commit if ok else intent.destination.tracker.rollback)() + + +def queue_volume_transfers(intents: Sequence[VolumeTransferIntent]) -> None: + if not does_volume_tracking(): + return + for intent in intents: + if intent.direction == "aspirate": + if not intent.container.tracker.is_disabled: + intent.container.tracker.remove_liquid(intent.volume_ul) + if not intent.tip.tracker.is_disabled: + intent.tip.tracker.add_liquid(intent.volume_ul) + else: + if not intent.tip.tracker.is_disabled: + intent.tip.tracker.remove_liquid(intent.volume_ul) + if not intent.container.tracker.is_disabled: + intent.container.tracker.add_liquid(intent.volume_ul) + + +def finalize_volume_ops( + intents: Sequence[VolumeTransferIntent], + successes: ChannelSuccesses, +) -> None: + if not does_volume_tracking(): + return + for intent in intents: + ok = successes.get(intent.channel, False) + if not intent.container.tracker.is_disabled: + (intent.container.tracker.commit if ok else intent.container.tracker.rollback)() + if not intent.tip.tracker.is_disabled: + (intent.tip.tracker.commit if ok else intent.tip.tracker.rollback)() + + +def place_resource( + resource: Resource, + destination: Resource, + *, + location: Optional[Coordinate] = None, +) -> None: + """Reassign ``resource`` under ``destination`` after a successful place.""" + destination.check_can_drop_resource_here(resource) + resource.unassign() + if isinstance(destination, ResourceHolder): + destination.assign_child_resource(resource, location=location) + else: + destination.assign_child_resource( + resource, location=location if location is not None else Coordinate.zero() + ) diff --git a/pylabrobot/resources/resource_state_tests.py b/pylabrobot/resources/resource_state_tests.py new file mode 100644 index 00000000000..e53e7672087 --- /dev/null +++ b/pylabrobot/resources/resource_state_tests.py @@ -0,0 +1,157 @@ +"""Tests for shared tip / volume / deck resource state helpers.""" + +from __future__ import annotations + +import unittest + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + finalize_tip_ops, + finalize_volume_ops, + place_resource, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker, set_tip_tracking +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.volume_tracker import set_volume_tracking +from pylabrobot.resources.well import Well, WellBottomType + + +def _tip(name: str = "t") -> Tip: + return Tip( + has_filter=False, + total_tip_length=50, + maximal_volume=200, + fitting_depth=10, + name=name, + ) + + +def _spot(name: str = "spot") -> TipSpot: + spot = TipSpot(name=name, size_x=9, size_y=9, size_z=0, make_tip=_tip) + spot.tracker.add_tip(spot.make_tip(), origin=spot, commit=True) + return spot + + +class TestResourceStateTips(unittest.TestCase): + def setUp(self) -> None: + set_tip_tracking(True) + set_volume_tracking(False) + + def tearDown(self) -> None: + set_tip_tracking(False) + set_volume_tracking(False) + + def test_pickup_commit_clears_spot_and_mounts_channel(self) -> None: + spot = _spot() + channel = TipTracker(thing="ch0") + tip = spot.get_tip() + intents = [TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel)] + queue_tip_pickups(intents) + finalize_tip_ops(intents, {0: True}) + self.assertFalse(spot.has_tip()) + self.assertTrue(channel.has_tip) + self.assertIs(channel.get_tip(), tip) + + def test_pickup_rollback_restores_spot(self) -> None: + spot = _spot() + channel = TipTracker(thing="ch0") + tip = spot.get_tip() + intents = [TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel)] + queue_tip_pickups(intents) + finalize_tip_ops(intents, {0: False}) + self.assertTrue(spot.has_tip()) + self.assertFalse(channel.has_tip) + + def test_drop_to_spot_and_trash(self) -> None: + spot = _spot("src") + dest = TipSpot(name="dest", size_x=9, size_y=9, size_z=0, make_tip=_tip) + trash = Trash(name="trash", size_x=10, size_y=10, size_z=10) + channel = TipTracker(thing="ch0") + tip = spot.get_tip() + pick = [TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel)] + queue_tip_pickups(pick) + finalize_tip_ops(pick, {0: True}) + + drop_spot = [TipDropIntent(channel=0, destination=dest, tip=tip, channel_tracker=channel)] + queue_tip_drops(drop_spot) + finalize_tip_ops(drop_spot, {0: True}) + self.assertTrue(dest.has_tip()) + self.assertFalse(channel.has_tip) + + tip2 = dest.get_tip() + pick2 = [TipPickupIntent(channel=0, tip_spot=dest, tip=tip2, channel_tracker=channel)] + queue_tip_pickups(pick2) + finalize_tip_ops(pick2, {0: True}) + drop_trash = [TipDropIntent(channel=0, destination=trash, tip=tip2, channel_tracker=channel)] + queue_tip_drops(drop_trash) + finalize_tip_ops(drop_trash, {0: True}) + self.assertFalse(channel.has_tip) + self.assertFalse(dest.has_tip()) + + def test_successes_from_failed_channels(self) -> None: + self.assertEqual( + successes_from_failed_channels([0, 1], {1: Exception("x")}), + {0: True, 1: False}, + ) + + +class TestResourceStateVolume(unittest.TestCase): + def setUp(self) -> None: + set_volume_tracking(True) + set_tip_tracking(False) + + def tearDown(self) -> None: + set_volume_tracking(False) + set_tip_tracking(False) + + def test_aspirate_commit(self) -> None: + well = Well( + name="w", + size_x=9, + size_y=9, + size_z=10, + bottom_type=WellBottomType.FLAT, + max_volume=200, + ) + well.tracker.set_volume(100) + tip = _tip() + intents = [ + VolumeTransferIntent( + channel=0, + container=well, + tip=tip, + volume_ul=25, + direction="aspirate", + ) + ] + queue_volume_transfers(intents) + finalize_volume_ops(intents, {0: True}) + self.assertAlmostEqual(well.tracker.get_used_volume(), 75) + self.assertAlmostEqual(tip.tracker.get_used_volume(), 25) + + +class TestPlaceResource(unittest.TestCase): + def test_place_onto_holder(self) -> None: + holder_a = ResourceHolder(name="a", size_x=100, size_y=100, size_z=10) + holder_b = ResourceHolder(name="b", size_x=100, size_y=100, size_z=10) + plate = Resource(name="p", size_x=127, size_y=85, size_z=14) + holder_a.assign_child_resource(plate) + place_resource(plate, holder_b) + self.assertIs(holder_b.resource, plate) + self.assertIsNone(holder_a.resource) + self.assertEqual(plate.location, Coordinate.zero()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/resources/tip_tracker.py b/pylabrobot/resources/tip_tracker.py index 1a876ce86f5..4b1ee76b92c 100644 --- a/pylabrobot/resources/tip_tracker.py +++ b/pylabrobot/resources/tip_tracker.py @@ -57,15 +57,15 @@ def has_tip(self) -> bool: return self._pending_tip is not None def get_tip(self) -> "Tip": - """Get the tip. Note that does includes pending operations. + """Get the tip. Note that this includes pending operations. Raises: NoTipError: If the tip spot does not have a tip. """ - if self._tip is None: + if self._pending_tip is None: raise NoTipError(f"{self.thing} does not have a tip.") - return self._tip + return self._pending_tip def disable(self) -> None: """Disable the tip tracker."""