Skip to content

feat(hamilton): add Prep device over TCP transport - #1196

Open
cmoscy wants to merge 32 commits into
PyLabRobot:mainfrom
cmoscy:hamilton-prep
Open

feat(hamilton): add Prep device over TCP transport#1196
cmoscy wants to merge 32 commits into
PyLabRobot:mainfrom
cmoscy:hamilton-prep

Conversation

@cmoscy

@cmoscy cmoscy commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Stacked on #1195.

Adds a plain Prep device on the Hamilton TCP transport (channels / head8 / gripper), plus PrepDeck, liquid-class resolver, shared tip/volume finalize helpers, and a basic demo wired into the Hamilton docs.

Resource-facing APIs (no LiquidHandler/Backend) are intentionally not included.

cmoscy added 12 commits August 8, 2026 15:43
Replace the thin post-PyLabRobot#1000 stub with the HOI/HARP session client, command
layer, wire types, and introspection stack so Prep/Nimbus can build on it.
Python 3.9 binds asyncio.Lock to the current loop at Socket construction, so
HamiltonTCPClient cannot be created in sync TestCase methods. Use
IsolatedAsyncioTestCase for those cases.
Plain Prep package with channels/head8/gripper over HamiltonTCPClient,
kwargs for LH params, local op types, PrepDeck, and liquid_class_resolver.
…pick

Expose resource-aware plate drop with deck reassignment, arm-level
pick_up_at_location, and optional resource_width on pick_up_resource so
notebooks no longer compute grip geometry by hand.
Delete prep/standard op dataclasses; peers take TipSpots/wells + vols with
kwargs instead of constructing op objects.
Queue intents before device commands and commit or roll back from
ChannelSuccesses so labware trackers stay consistent for deck and
visualizer updates without vendor-specific bookkeeping.
Replace private mounted-tip maps with TipTrackers and call shared
finalize after pick/drop and aspirate/dispense so spot, well, and
mount state update from command outcomes.
Move to mount XY at traverse height before PrepPickUpTool, matching
tip pickup, so tool engage does not swoop down from an arbitrary pose.
Show deck-rooted visualizer updates for tip spots and well fills, with
status prints for channel mount state during dual-channel transfer.
Capture tip/volume tracking through dual-channel and 8MPH sections, link
the notebook under Hamilton Prep docs, add channels volume-tracker coverage,
and apply ruff formatting on the related Prep modules.
Drop field(kw_only=...) (3.10+), default dest in __post_init__, and declare
dest on constructors that pass it. Also fix liquid-class test typing,
gripper mount isinstance, and head8 test formatting for CI.
cmoscy and others added 17 commits August 8, 2026 22:59
Those investigation notebooks are not in the PR; listing them in conf.py
leaked local-only paths into shared docs config. Demo wiring stays via
hamilton/prep/index.md.
…connect

_send_raw retried by re-writing the command on connection errors. TimeoutError
and OSError were both treated as retryable, and the read uses a 300s default
timeout, so a slow motion command that timed out on the read was physically
executed twice.

Reconnection now belongs to the caller: stop() then setup(). No other transport
in the repo self-heals a connection, and re-establishing the session cannot
report whether the in-flight command completed.

Session state is scoped to the connected session and fully reset by setup():
client id, sequence numbers, instrument addresses and the object registry all
survived a reconnect before. setup() on a live client now raises instead of
leaking the socket.
…eclared data

wire_type_of() resolves an Annotated alias or bare WireType in one place,
replacing four ad-hoc __metadata__ probes across messages.py and wire_types.py.

TCPCommand now declares Response and uses_physical_channels as ClassVars.
Response replaces a hasattr() dispatch; uses_physical_channels replaces
duck-typing that inferred channel semantics by looking for a "channel"
attribute on the first element of any list field. Device peers declare the
flag; the transport no longer guesses.

get_log_params reads dataclass fields instead of walking the __init__
signature and probing self.

assert isinstance on wire-derived responses became real raises: asserts are
stripped under python -O, which is exactly when a malformed frame most needs
to fail loudly.
TCP-side concurrency semantics are not established for this protocol, so only
one command is in flight at a time.

_transact holds the lock across sequence-number allocation, build, write and
the terminal read, then releases it before the response is decoded. That
boundary is load-bearing: error enrichment resolves interface and method names
through introspection, which sends further commands through _transact, so a
lock spanning the whole of _send_raw would deadlock on the first firmware
error. A test covers that path and fails with a timeout under the naive design.
Reading used to happen inline inside a command, which meant events were only
observed while a command was in flight, and any non-ACK non-EVENT frame was
accepted as the current command's response regardless of origin. A response
arriving late, after a timeout, silently became the next command's answer.

The reader owns the socket from the end of setup() until stop(). It dispatches
events continuously, skips ACKs, and hands the terminal frame to the waiting
command. Frames arriving with nothing waiting are dropped and logged rather
than queued.

The handoff is a single slot, not a keyed map: the command lock already allows
one command in flight, and whether the device echoes the request sequence
number is unverified. Mismatches are logged, so the pairing can be confirmed
from real traffic before a keyed demux is built on it.

Reads stay inline during the init and registration handshake, which exchanges
Registration frames rather than commands. A reader that dies on a live
connection fails the waiting command instead of hanging it.
…outing

The transport had no coverage of connection lifecycle, retry, event dispatch
or concurrency. Adds the regressions for the behaviour the preceding commits
established: a command is written exactly once when the read fails, I/O on a
disconnected client names setup(), setup() refuses to run twice, session state
and sequence numbers reset between sessions, commands do not interleave, error
enrichment does not deadlock against the command lock, events arrive between
commands, unmatched frames are dropped rather than misdelivered, and a dead
reader fails the waiting command.
Found against MLPrep firmware. Shortly after registration the device sends a
HARP protocol-2 frame carrying options and no HOI body. _read_one_message
routed every protocol-2 frame to CommandResponse, which unpacks a HOI header
that is not there, so the reader died and every later command failed with
"reader is not running". Inline reading never hit this because nothing read
the socket between commands.

_read_one_message now returns None for a frame with no routable message, and
the reader skips unparseable frames instead of terminating. Frames are
length-prefixed and consumed whole, so skipping one cannot desynchronize the
stream.

Also records what the same session established: the device echoes the request
address and sequence number on every response (26/26), so the mismatch warning
now documents a real invariant rather than an open question.
Turning a firmware error into a readable message asks the device for interface
and method names. When those queries fail too, the failure was enriched the
same way, which enriched again: a device answering STATUS_EXCEPTION to every
request produced a RecursionError after 55 reads rather than an HoiError.

Pre-dates this branch; reproduced identically at 4e71a71 and confirmed to
need a device that fails Interface-0 queries, which is why healthy hardware
never showed it. It surfaces exactly when the error message matters most.

Enrichment is now non-re-entrant, guarded by a ContextVar so concurrent
callers keep their own state. A nested entry falls back to HC_RESULT_PROTOCOL
and terse addressing, so the caller still gets a real HoiError naming the
failing address, interface and action.
Reconciles the Prep peers with the transport changes:

- PrepClient drops auto_reconnect / max_reconnect_attempts; recovery is
  stop() then setup(). Its _send_raw override and PrepChatterboxClient
  lose the ensure_connection parameter.
- PREP_ERROR_CODES now imports from pylabrobot.hamilton.prep.error_tables,
  following the table move on the transport branch.
- PrepCommand derives uses_physical_channels from its per-channel
  StructArray fields. The transport no longer infers channel semantics, but
  Prep declares over a hundred command types whose per-channel-ness follows
  from their wire shape, so the device layer derives it rather than
  repeating a flag on each. Verified to classify all 57 constructible Prep
  commands identically to the previous transport-level logic.
- TCPCommand keeps protocol / interface_id / command_id / action_code as
  plain class attributes rather than ClassVar: PrepStatusRequest redeclares
  command_id and interface_id as per-instance dataclass fields by design,
  which ClassVar forbids.
…able

Declaring protocol, interface_id, command_id and the action configuration as
ClassVar forbids a subclass from redeclaring them as per-instance dataclass
fields. Prep's PrepStatusRequest does exactly that by design, carrying
command_id and interface_id on the instance so one class can serve many
firmware methods.

The ClassVar conversion was incidental to removing attribute probing and was
not needed for it; Response and uses_physical_channels remain ClassVar because
nothing overrides them per instance.
ruff check --select I, which CI runs as part of make format-check.
…waits

Three reader tests queued response frames before sending the command. The
reader could drain them first and correctly drop them as unmatched, leaving
the command waiting until its timeout. Whether that happened depended on task
scheduling: they passed on 3.14 and failed on 3.11, so CI would have caught
them intermittently at best.

Frames are now fed after the command registers its pending response, with an
explicit wait for that registration. Also asserts the stale-frame test really
did consume the frame it is about to prove was dropped, rather than passing
because the reader had not run yet.

Sorts imports after the error-table move, which CI checks via
ruff check --select I in make format-check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants