diff --git a/CHANGELOG.md b/CHANGELOG.md index 220209251b6..b0c4231645a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Unreleased +### Added + +- HighRes Biosolutions MicroSpin centrifuge backend (`pylabrobot.centrifuge.highres.MicroSpinBackend`) speaking the device's ASCII command/response protocol over TCP/1000, plus a `MicroSpin(...)` factory. +- In-process `MicroSpinMockServer` (`pylabrobot.centrifuge.highres.mock_server`) that faithfully emulates the MicroSpin's wire protocol -- including the firmware's "`status` blocks until the spindle has stopped" semantics and the low-G spin-down-detection hang -- usable as a Python async context manager or runnable as a script (`python -m pylabrobot.centrifuge.highres.mock_server`) for `nc`/`telnet` debugging. +- `MicroSpinBackend.reset()` recovery helper that issues `abort` -> `clearbuttonabort` -> `status`, using the last as the gate that genuinely confirms the rotor has stopped. +- User guide notebook for the MicroSpin (`docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb`). + +### Fixed + +- Imported `unittest.mock` in `pylabrobot/centrifuge/centrifuge_tests.py` (pre-existing bug that prevented the test class from running). + ## 0.2.1 ### Added diff --git a/docs/api/pylabrobot.centrifuge.rst b/docs/api/pylabrobot.centrifuge.rst index df0932e8e22..3449bdfd62d 100644 --- a/docs/api/pylabrobot.centrifuge.rst +++ b/docs/api/pylabrobot.centrifuge.rst @@ -11,6 +11,7 @@ This package contains APIs for working with centrifuges. :recursive: centrifuge.Centrifuge + centrifuge.Loader Backends @@ -22,3 +23,21 @@ Backends :recursive: vspin_backend.VSpinBackend + highres.microspin_backend.MicroSpinBackend + + +Errors +------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + standard.BucketHasPlateError + standard.BucketNoPlateError + standard.CentrifugeDoorError + standard.LoaderNoPlateError + standard.NotAtBucketError + highres.microspin_backend.MicroSpinError + highres.microspin_backend.MicroSpinProtocolError diff --git a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md index d6e13a1191b..0aa460e01c0 100644 --- a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md +++ b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md @@ -21,4 +21,5 @@ PLR supports the following centrifuges: :maxdepth: 1 agilent_vspin +highres_microspin ``` diff --git a/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb new file mode 100644 index 00000000000..299ce5f8e4b --- /dev/null +++ b/docs/user_guide/01_material-handling/centrifuge/highres_microspin.ipynb @@ -0,0 +1,388 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f5b94f96", + "metadata": {}, + "source": [ + "# HighRes Biosolutions MicroSpin\n", + "\n", + "The MicroSpin is a sealed automated microplate centrifuge from HighRes Biosolutions. It has two swing-out buckets (1 SBS plate per bucket), spins up to 3000 ×g (4729 RPM), and is driven over TCP/IP -- there is no separate USB driver or FTDI library needed.\n", + "\n", + "It is controlled by the {class}`~pylabrobot.centrifuge.highres.microspin_backend.MicroSpinBackend` class and the {func}`~pylabrobot.centrifuge.highres.microspin.MicroSpin` factory.\n", + "\n", + "Reference: HighRes Biosolutions, MicroSpin User Manual (document 1058675).\n" + ] + }, + { + "cell_type": "markdown", + "id": "b3f4d99d", + "metadata": {}, + "source": [ + "## 1. Network configuration\n", + "\n", + "The MicroSpin ships with a static IP address printed on the product label (factory default `192.168.127.60`, mask `255.255.255.0`). To talk to it you need to put your computer on the same subnet, or change the device's IP to one that fits your network.\n", + "\n", + "There are two ways to find/change the IP:\n", + "\n", + "1. **Direct-attached (Ethernet point-to-point).** Connect the MicroSpin to a wired NIC on your computer (e.g. via a USB-to-Ethernet adapter). Configure that NIC with a static address such as `192.168.127.10 / 255.255.255.0`. Then open `http:///network.html` in a browser, and either:\n", + " - switch the device to **DHCP** (recommended for lab networks with a DHCP server), or\n", + " - enter a **static IP** in your lab's subnet plus the appropriate gateway.\n", + "\n", + " The same page also exposes a **Server Port** setting (factory default 1000); change it here if 1000 conflicts with something else on your network, and remember to pass the matching `port=...` when constructing `MicroSpin(...)` in Python.\n", + "\n", + " After saving, wait ~30 seconds, then reboot the MicroSpin (back-panel power switch).\n", + "\n", + "2. **Plug into the lab network directly.** If the device is already DHCP-enabled, find its lease on your DHCP server using the MAC address printed on the product label (the OUI is `00:d0:69`, assigned to Technologic Systems, who make the embedded controller inside the MicroSpin).\n", + "\n", + "Once you know the IP, you can verify connectivity with a simple `ping` before going any further.\n" + ] + }, + { + "cell_type": "markdown", + "id": "102ad3dc", + "metadata": {}, + "source": [ + "## 2. Connecting from PLR\n", + "\n", + "The {func}`~pylabrobot.centrifuge.highres.microspin.MicroSpin` factory returns a fully configured {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge`. Unlike the VSpin + Access2 combo, no separate `Loader` is needed: an integration arm (or a human) places plates directly into the presented bucket.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d6079238", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.centrifuge import MicroSpin\n", + "\n", + "# Default: port 1000 (the MicroSpin's factory default).\n", + "cf = MicroSpin(name=\"microspin\", host=\"192.168.127.60\")\n", + "\n", + "# If your device has been reconfigured (Server Port setting on /network.html),\n", + "# pass the matching port explicitly:\n", + "# cf = MicroSpin(name=\"microspin\", host=\"192.168.127.60\", port=2300)\n", + "\n", + "await cf.setup() # opens a TCP connection to the configured port\n" + ] + }, + { + "cell_type": "markdown", + "id": "924995f5", + "metadata": {}, + "source": [ + "## 3. Homing\n", + "\n", + "The MicroSpin needs to be homed once after every power-cycle before bucket or spin commands are accepted. The backend exposes `home()` and `is_homed()` for this:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91c23cb3", + "metadata": {}, + "outputs": [], + "source": [ + "if not await cf.backend.is_homed():\n", + " await cf.backend.home()\n" + ] + }, + { + "cell_type": "markdown", + "id": "58d6e7ac", + "metadata": {}, + "source": [ + "## 4. Reading status & version\n", + "\n", + "Two read-only helpers are convenient for diagnostics:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d30ab739", + "metadata": {}, + "outputs": [], + "source": "await cf.backend.request_version()\n# {'Product Name': 'RandomServe', 'Serial Number': 'HRB-...',\n# 'Version': 'MS-1.3.3', 'Firmware Build': '...'}\n" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6984b371", + "metadata": {}, + "outputs": [], + "source": "await cf.backend.request_status()\n# {'Spindle Position': '1958', 'Door Position': '-457'}\n" + }, + { + "cell_type": "markdown", + "id": "7fb071d2", + "metadata": {}, + "source": [ + "## 5. Positioning buckets\n", + "\n", + "The MicroSpin's `open ` firmware command both opens the door and rotates the chosen bucket into the load position; PLR exposes this as `go_to_bucket1()` / `go_to_bucket2()`. There is no separate \"open the door without a bucket\" workflow.\n", + "\n", + "Door closing is handled automatically by the firmware at the start of `spin` and `home`, so application code never has to issue a close. The pneumatic door lock and the nest-pin that holds plates during loading are likewise managed by the firmware as part of `open ` and `spin` -- none of these primitives (`od`, `cd`, `lockdoor`, `unlockdoor`, `locknest`, `unlocknest`) are exposed as standalone calls on the MicroSpin backend; the manual classifies them all as maintenance commands (§6.7).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "935d0e90", + "metadata": {}, + "outputs": [], + "source": [ + "# Open the door and present bucket 1 in one step\n", + "await cf.go_to_bucket1()\n", + "\n", + "# Or bucket 2\n", + "# await cf.go_to_bucket2()\n", + "\n", + "# The next spin or home will close the door automatically; no explicit\n", + "# close call is needed (and is in fact disabled on this backend).\n" + ] + }, + { + "cell_type": "markdown", + "id": "32fe9d83", + "metadata": {}, + "source": [ + "## 6. Spinning\n", + "\n", + "```{warning}\n", + "**Pre-spin checklist (manual §§5.3, 7, 8):**\n", + "\n", + "- The device must be homed (see step 3).\n", + "- The shipping tie-wraps that hold the buckets to the rotor MUST be removed -- starting a spin with them in place will damage the buckets.\n", + "- Both buckets must be properly seated on the rotor pins, swing freely, and the bucket pivot pins must not protrude.\n", + "- The payload must be balanced (max 15 g imbalance at full speed, up to 75 g at lower speeds).\n", + "- The chamber must be free of debris.\n", + "- Compressed air must be supplied at 70-135 psi.\n", + "\n", + "(The door is closed automatically by the firmware at the start of `spin` / `home`, so it does not need to be closed by the caller.)\n", + "\n", + "The MicroSpin does not have biosafety seals -- do not centrifuge hazardous, flammable, or corrosive materials.\n", + "```\n", + "\n", + "PLR's `spin()` takes:\n", + "\n", + "- `g`: G-force in ×g (valid range 1-3000)\n", + "- `duration`: time at speed in seconds (≥ 1)\n", + "- `acceleration`: ramp-up rate as a fraction (0, 1]\n", + "- `deceleration`: ramp-down rate as a fraction (0, 1]\n", + "\n", + "The backend converts the 0-1 fractions to the integer 0-100 percentages the firmware expects.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "997c455e", + "metadata": {}, + "outputs": [], + "source": [ + "await cf.spin(\n", + " g=500,\n", + " duration=60, # seconds\n", + " acceleration=1.0, # 0-1 fraction of max\n", + " deceleration=1.0, # 0-1 fraction of max\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "e3a6810c", + "metadata": {}, + "source": [ + "```{warning}\n", + "**Slow / stuck deceleration:** Low `deceleration` values cause noticeably long spin-downs, and very low ones can hang the firmware entirely. The backend emits a `UserWarning` at two tiers:\n", + "\n", + "* `deceleration < 0.40` (40 %) -- \"long spin-down expected\". A tested `spin 1000 100 20 10` (decel = 0.20) took ~7 minutes from full speed to stopped. Make sure your `wait_for_spindle_stopped` budget covers this.\n", + "* `deceleration < 0.20` (20 %) -- \"possible firmware hang\". A tested `spin 1000 100 10 10` (decel = 0.10) ran for >30 minutes without ever reporting spin-down. Recovery is the same as for the low-G hang below.\n", + "```\n", + "\n", + "```{warning}\n", + "**Low-G hang:** Spinning at less than ~30 ×g is known to occasionally hang the firmware. The spindle \"stopped\" sensor sometimes fails to latch when the rotor decelerates from a very low speed, so no `OK!` completion line is emitted and every subsequent command times out. The backend will emit a `UserWarning` if you call `spin()` with `g < 30`. If you hit the hang, the recovery path is:\n", + "\n", + "```python\n", + "await cf.backend.abort()\n", + "await cf.backend.clear_button_abort()\n", + "```\n", + "\n", + "If the device still won't respond, power-cycle it from the back-panel switch.\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "id": "988b0cdd", + "metadata": {}, + "source": [ + "## 7. Recovering from a stuck or aborted state\n", + "\n", + "`abort()` requests a decel + stop. After an abort the firmware enters a latched abort state that must be cleared before any further motion commands will run.\n", + "\n", + "A subtlety: `abort` and `clearbuttonabort` both return `OK!` **as soon as the firmware accepts the request** -- they do *not* wait for the rotor to actually spin down. The real \"we are stopped\" gate is the `status` command: while motion is in progress, the firmware queues `status` behind it and only answers once motion has completed. The MicroSpin backend uses this property in `reset()`, which is therefore the canonical \"really, fully back to idle\" call:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad40dddd", + "metadata": {}, + "outputs": [], + "source": [ + "# Sends abort, then clearbuttonabort, then status (which blocks until\n", + "# the spindle has actually stopped). Returns the final status report.\n", + "final_status = await cf.backend.reset()\n", + "print(final_status)\n", + "# {'Spindle Position': '...', 'Door Position': '...'}\n" + ] + }, + { + "cell_type": "markdown", + "id": "8441c6c3", + "metadata": {}, + "source": [ + "`reset()` swallows errors from the `abort` step by default (it's common for the firmware to reject abort if there's nothing to abort), so it's safe as a generic \"get me back to a known state\" routine. The arguments let you tune each phase:\n", + "\n", + "- `swallow_abort_errors=False` -- propagate any error from the abort step instead of ignoring it\n", + "- `wait_for_settle=False` -- skip the final `status` gate (just clear the abort latch, don't wait for the rotor)\n", + "- `abort_timeout=...` / `settle_timeout=...` -- override the per-step timeouts\n", + "\n", + "If you only want the \"wait for actual stop\" gate without changing any state, use `wait_for_spindle_stopped()` -- it sends a single `status` with a generous timeout and returns the parsed result:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f5029b27", + "metadata": {}, + "outputs": [], + "source": [ + "# Block until the firmware confirms the rotor is fully stopped.\n", + "status = await cf.backend.wait_for_spindle_stopped()\n" + ] + }, + { + "cell_type": "markdown", + "id": "68e263c4", + "metadata": {}, + "source": [ + "You can of course still call the two primitives directly if you need finer control. Remember that without the `status` step, you do NOT have confirmation that the rotor has actually spun down -- just that the abort latch was set/cleared:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93c2434b", + "metadata": {}, + "outputs": [], + "source": "await cf.backend.abort() # request OK; rotor may still be spinning\nawait cf.backend.clear_button_abort() # latch cleared; rotor may still be spinning\nstatus = await cf.backend.request_status() # NOW we know we're really stopped\n" + }, + { + "cell_type": "markdown", + "id": "b76bed47", + "metadata": {}, + "source": "## 8. Error stack\n\nThe MicroSpin maintains an internal error stack. `request_errors(n)` returns the top `n` lines (default 10):\n" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "94eac76d", + "metadata": {}, + "outputs": [], + "source": "await cf.backend.request_errors(10)\n" + }, + { + "cell_type": "markdown", + "id": "2ff390d5", + "metadata": {}, + "source": [ + "## 9. Sending raw commands\n", + "\n", + "For advanced use, `send_command()` exposes the underlying ASCII protocol. It returns the data lines emitted between `ACK!` and `OK!`, and raises {class}`~pylabrobot.centrifuge.highres.microspin_backend.MicroSpinError` on `ERROR!`. See manual §6.6 for the protocol; use `list` and `info` to enumerate commands the device supports:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0bc961a5", + "metadata": {}, + "outputs": [], + "source": [ + "await cf.backend.send_command(\"list\") # public commands\n", + "# await cf.backend.send_command(\"list all\") # includes maintenance commands\n" + ] + }, + { + "cell_type": "markdown", + "id": "bfcb2439", + "metadata": {}, + "source": [ + "## 10. Developing without a real device\n", + "\n", + "Bringing a 43 kg centrifuge into your apartment for testing is suboptimal. PLR ships an in-process mock TCP server that speaks the MicroSpin command protocol faithfully enough for the `MicroSpinBackend` to drive it end-to-end. Use it from Python:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43c67d77", + "metadata": {}, + "outputs": [], + "source": "import asyncio\nfrom pylabrobot.centrifuge import MicroSpin\nfrom pylabrobot.centrifuge.highres.mock_server import MicroSpinMockServer\n\nasync def demo():\n async with MicroSpinMockServer() as srv:\n cf = MicroSpin(name=\"mock-microspin\", host=srv.host, port=srv.port)\n await cf.setup()\n await cf.backend.home()\n print(await cf.backend.request_version())\n # The mock implements `status`-blocks-during-motion, so reset(), abort\n # recovery, and the low-G hang are all reproducible in tests.\n await cf.stop()\n\n# await demo() # uncomment to run from a notebook\n" + }, + { + "cell_type": "markdown", + "id": "314c2e82", + "metadata": {}, + "source": [ + "Or hand-drive it with `nc` / `telnet` after starting it as a script -- handy when you want to poke at the wire protocol directly:\n", + "\n", + "```bash\n", + "python -m pylabrobot.centrifuge.highres.mock_server --port 1000\n", + "# then, in another terminal:\n", + "nc 127.0.0.1 1000\n", + "```\n", + "\n", + "A few capabilities worth knowing about for tests:\n", + "\n", + "- `srv.motion_dwell[\"home\"] = 0.5` -- slow down motions to race against them.\n", + "- `srv.state.simulate_low_g_hang = True` -- reproduce the firmware bug where `status` never returns after a spin (see [§6 Spinning](#6-spinning)).\n", + "- Inspect `srv.state.homed`, `srv.state.at_bucket`, etc. directly from your test code.\n" + ] + }, + { + "cell_type": "markdown", + "id": "1ed4571a", + "metadata": {}, + "source": [ + "## 11. Cleaning up\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d3aba74b", + "metadata": {}, + "outputs": [], + "source": [ + "await cf.stop() # closes the TCP connection\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/centrifuge/__init__.py index df6a67cc292..f910c01a64e 100644 --- a/pylabrobot/centrifuge/__init__.py +++ b/pylabrobot/centrifuge/__init__.py @@ -1,5 +1,12 @@ from .access2 import Access2 from .centrifuge import Centrifuge, Loader +from .highres import ( + MicroSpin, + MicroSpinAbortedError, + MicroSpinBackend, + MicroSpinError, + MicroSpinProtocolError, +) from .standard import ( BucketHasPlateError, BucketNoPlateError, diff --git a/pylabrobot/centrifuge/centrifuge_tests.py b/pylabrobot/centrifuge/centrifuge_tests.py index 9dbf6c56d8b..9e84c90440b 100644 --- a/pylabrobot/centrifuge/centrifuge_tests.py +++ b/pylabrobot/centrifuge/centrifuge_tests.py @@ -1,4 +1,5 @@ import unittest +import unittest.mock from pylabrobot.centrifuge import ( BucketHasPlateError, diff --git a/pylabrobot/centrifuge/highres/__init__.py b/pylabrobot/centrifuge/highres/__init__.py new file mode 100644 index 00000000000..fa4117dbff1 --- /dev/null +++ b/pylabrobot/centrifuge/highres/__init__.py @@ -0,0 +1,15 @@ +from .microspin import MicroSpin +from .microspin_backend import ( + MicroSpinAbortedError, + MicroSpinBackend, + MicroSpinError, + MicroSpinProtocolError, +) + +__all__ = [ + "MicroSpin", + "MicroSpinBackend", + "MicroSpinError", + "MicroSpinAbortedError", + "MicroSpinProtocolError", +] diff --git a/pylabrobot/centrifuge/highres/microspin.py b/pylabrobot/centrifuge/highres/microspin.py new file mode 100644 index 00000000000..ffca7c7d2e0 --- /dev/null +++ b/pylabrobot/centrifuge/highres/microspin.py @@ -0,0 +1,68 @@ +"""Factory for the HighRes Biosolutions MicroSpin centrifuge. + +The MicroSpin is a sealed, automation-friendly microplate centrifuge with two +swing-out buckets (1 SBS plate per bucket). It does not have a separate +plate loader -- plates are placed into the presented bucket directly by a +robot arm, then the door is closed and a spin is started. Because of this, +the :class:`~pylabrobot.centrifuge.Centrifuge` returned here is *not* +paired with a separate :class:`~pylabrobot.centrifuge.Loader` (unlike the +Agilent VSpin + Access2 combo). +""" + +from __future__ import annotations + +from typing import Optional + +from pylabrobot.centrifuge.centrifuge import Centrifuge +from pylabrobot.centrifuge.highres.microspin_backend import MicroSpinBackend + +# Spec sheet (manual §11): +# Dimensions (H x W x L): 15" x 15" x 16" +# Weight (empty) : 43 kg +# pylabrobot conventionally uses size_x = width, size_y = depth, size_z = height. +_INCH_MM = 25.4 +_MICROSPIN_WIDTH_MM = 15.0 * _INCH_MM # 381.0 mm +_MICROSPIN_DEPTH_MM = 16.0 * _INCH_MM # 406.4 mm +_MICROSPIN_HEIGHT_MM = 15.0 * _INCH_MM # 381.0 mm + + +def MicroSpin( + name: str, + host: str, + port: int = MicroSpinBackend.DEFAULT_PORT, + timeout: float = 30.0, + backend: Optional[MicroSpinBackend] = None, +) -> Centrifuge: + """Construct a HighRes Biosolutions MicroSpin centrifuge. + + Args: + name: A descriptive name for this centrifuge instance. + host: IP address or DNS name of the MicroSpin's Ethernet port. The + factory default is ``192.168.127.60`` but the IP is configurable on + the device's ``/network.html`` web page. + port: TCP port for the remote-control server. Defaults to + :attr:`MicroSpinBackend.DEFAULT_PORT` (1000, the factory default). + Pass a different value if the device's ``SERVER_PORT`` setting has + been changed via ``/network.html``. + timeout: Default per-command timeout in seconds. The backend extends + this automatically for long-running operations (spin, home). + backend: Optionally supply a pre-constructed + :class:`MicroSpinBackend`. Useful for tests or for sharing a backend + between front-end objects. If omitted, a new backend is built from + ``host``/``port``/``timeout``. + + Returns: + A :class:`~pylabrobot.centrifuge.Centrifuge` wired to a + :class:`MicroSpinBackend`. + """ + if backend is None: + backend = MicroSpinBackend(host=host, port=port, timeout=timeout) + + return Centrifuge( + backend=backend, + name=name, + size_x=_MICROSPIN_WIDTH_MM, + size_y=_MICROSPIN_DEPTH_MM, + size_z=_MICROSPIN_HEIGHT_MM, + model="HighRes Biosolutions MicroSpin", + ) diff --git a/pylabrobot/centrifuge/highres/microspin_backend.py b/pylabrobot/centrifuge/highres/microspin_backend.py new file mode 100644 index 00000000000..8f9d3917761 --- /dev/null +++ b/pylabrobot/centrifuge/highres/microspin_backend.py @@ -0,0 +1,762 @@ +"""Backend for the HighRes Biosolutions MicroSpin automated microplate centrifuge. + +The MicroSpin exposes an ASCII command/response protocol over TCP/IP on port +1000 by default. The wire protocol is documented in HighRes Biosolutions +"MicroSpin User Manual" (document 1058675, §6.6). In summary, every command +exchange has the following shape:: + + >>> [args...]\\r\\n + <<< ACK! + <<< (optional data lines, depending on the command) + <<< OK! -- success terminator + ERROR! -- failure terminator + Error : -- (one or more lines after ERROR!) + +This module also exposes a small surface of MicroSpin-specific helpers +(``home``, ``abort``, ``request_status`` ...) in addition to the abstract +:class:`~pylabrobot.centrifuge.backend.CentrifugeBackend` interface. + +Safety +------ +The MicroSpin is a real piece of mechanical equipment that can develop +3000 ``×g`` (4729 RPM) and weighs 43 kg. Before issuing a ``spin`` command: + +* the device MUST be homed (use :meth:`home` if :meth:`is_homed` returns False) +* the shipping tie-wraps holding the payload buckets MUST be removed +* both buckets must be properly seated on the rotor pins and balanced +* the door must be closed (the firmware will refuse to spin otherwise) +* nothing flammable, corrosive, or biohazardous may be in the chamber + +Refer to the MicroSpin user manual (HighRes document 1058675) §7 for the +full pre-spin checklist, and to §11 for environmental specifications. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +import warnings +from typing import Dict, List, Optional + +from pylabrobot.io.socket import Socket + +from ..backend import CentrifugeBackend + +logger = logging.getLogger(__name__) + + +_ACK_RE = re.compile(r"^ACK!\s+(?P.*?)\s+(?P\d+)\s*$") +#: The three terminator statuses the device can send. ``ABORTED!`` is +#: emitted for commands cancelled by an ``abort`` and for motion commands +#: issued while the abort latch is set. +_END_STATUSES = "OK!|ERROR!|ABORTED!" +#: Matches any terminator line, regardless of command id. +#: Used by :meth:`MicroSpinBackend._drain_stale_responses` to identify +#: terminators from previously-cancelled commands without needing to know +#: their cmd-ids. +_ANY_TERMINATOR_RE = re.compile(rf"^(?:{_END_STATUSES})\s+.*\s+\d+\s*$") + + +class MicroSpinError(RuntimeError): + """Raised when the MicroSpin responds with ``ERROR!`` to a command. + + Attributes: + command: The command text that was sent. + command_id: The numeric command id assigned by the device, or -1 if the + ACK could not be parsed. + error_lines: The diagnostic lines (``Error : ...``) the device emitted + before the ``ERROR!`` (or ``ABORTED!``) terminator. + """ + + #: The terminator keyword that triggered this exception. Overridden in + #: subclasses (e.g. :class:`MicroSpinAbortedError`). + TERMINATOR: str = "ERROR!" + + def __init__(self, command: str, command_id: int, error_lines: List[str]): + self.command = command + self.command_id = command_id + self.error_lines = list(error_lines) + super().__init__( + f"MicroSpin returned {self.TERMINATOR} for {command!r} (id={command_id}):\n " + + ("\n ".join(error_lines) if error_lines else "(no error detail)") + ) + + +class MicroSpinAbortedError(MicroSpinError): + """Raised when the MicroSpin terminates a command with ``ABORTED!``. + + The firmware emits ``ABORTED!`` (rather than ``ERROR!``) when: + + * a motion command (``home``, ``open``, ``spin``) was cancelled mid-flight + by an :meth:`MicroSpinBackend.abort`, or + * a motion command was issued while the device's abort latch was set + (i.e. before :meth:`MicroSpinBackend.clear_button_abort` had been + called). In this case the firmware sends ``ACK!`` followed immediately + by ``ABORTED!`` with no diagnostic data lines. + + Recovery is the same as for an :meth:`abort` event: call + :meth:`MicroSpinBackend.reset` to clear the latch and confirm the rotor + has stopped. + """ + + TERMINATOR = "ABORTED!" + + +class MicroSpinProtocolError(RuntimeError): + """Raised when the MicroSpin emits a response we cannot parse.""" + + +class MicroSpinBackend(CentrifugeBackend): + """Asynchronous backend for the HighRes Biosolutions MicroSpin centrifuge. + + Communicates over a single persistent TCP connection. Commands are serialised + with an :class:`asyncio.Lock` so the front-end can safely interleave callers. + """ + + #: Factory-default TCP port for the MicroSpin's remote-control server + #: (manual §6.3). The port is configurable per-device on the + #: ``/network.html`` web page (the ``SERVER_PORT`` setting); if your unit + #: has been reconfigured, pass the right port to the constructor or to + #: :func:`~pylabrobot.centrifuge.highres.microspin.MicroSpin`. + DEFAULT_PORT: int = 1000 + #: Spec sheet maximum (manual §11). The firmware will reject larger values. + MAX_G_FORCE = 3000 + #: Empirically observed minimum below which the firmware sometimes fails to + #: detect spin-down. Spinning below this triggers a :class:`UserWarning`; + #: see :meth:`spin` for the failure mode. + LOW_G_WARNING_THRESHOLD = 30 + #: Deceleration fraction (0-1) below which spin-down is *slow but + #: legitimate*: a tested ``spin 1000 100 20 10`` (decel = 0.20) took ~7 + #: minutes to fully stop. Spinning below this threshold triggers a + #: :class:`UserWarning` so callers can plan their timeouts accordingly. + SLOW_DECEL_WARNING_THRESHOLD = 0.40 + #: Deceleration fraction (0-1) below which the firmware appears to *hang* + #: rather than just be slow: a tested ``spin 1000 100 10 10`` (decel = + #: 0.10) ran for >30 minutes without ever reporting spin-down. Spinning + #: below this threshold triggers a stronger :class:`UserWarning`. + STUCK_DECEL_WARNING_THRESHOLD = 0.20 + + def __init__( + self, + host: str, + port: int = DEFAULT_PORT, + timeout: float = 30.0, + ): + """ + Args: + host: IP address or DNS name of the MicroSpin's Ethernet interface. + port: TCP port for the remote-control server. Defaults to + :attr:`DEFAULT_PORT` (1000, the factory default). Override this if + your device has been reconfigured via its ``/network.html`` web UI + to listen on a different port. + timeout: Default per-command timeout in seconds. Long-running commands + (``spin``, ``home``) automatically extend this internally. + """ + self.host = host + self.port = port + self.timeout = timeout + + self.io = Socket( + human_readable_device_name="HighRes MicroSpin", + host=host, + port=port, + read_timeout=timeout, + write_timeout=timeout, + ) + self._lock = asyncio.Lock() + # Number of terminator lines we still need to drain from the socket + # because previously-issued commands were cancelled (e.g. by + # `asyncio.wait_for`) before their response fully arrived. Each new + # `send_command` first drains this many terminators before reading its + # own response, preventing protocol desync after timeouts. + self._pending_terminator_count: int = 0 + + # ------------------------------------------------------------------ lifecycle + + async def setup(self) -> None: + """Open the TCP connection to the MicroSpin's remote-control server.""" + logger.debug("[microspin] connecting to %s:%d", self.host, self.port) + await self.io.setup() + + async def stop(self) -> None: + """Close the TCP connection. Safe to call even if never set up.""" + await self.io.stop() + + def serialize(self) -> dict: + """Return a JSON-serialisable view of this backend's construction args.""" + return { + **super().serialize(), + "host": self.host, + "port": self.port, + "timeout": self.timeout, + } + + # ------------------------------------------------------------------ wire IO + + async def _readline(self, *, timeout: Optional[float] = None) -> str: + raw = await self.io.readline(timeout=timeout) + if not raw: + raise ConnectionError("MicroSpin closed the connection") + return raw.rstrip(b"\r\n").decode("ascii", errors="replace") + + async def send_command( + self, + command: str, + *, + timeout: Optional[float] = None, + ) -> List[str]: + """Send a single command and return any data lines emitted by the device. + + Args: + command: The full command line *without* CR/LF. + timeout: Override the default per-command timeout (seconds). + + Returns: + A list of the data lines emitted between ``ACK!`` and ``OK!``. The list + is empty for commands that report only status (e.g. ``home``). + + Raises: + MicroSpinError: If the device terminates with ``ERROR!``. + MicroSpinProtocolError: If the ACK or terminator cannot be parsed. + asyncio.TimeoutError: If the timeout elapses. + """ + effective_timeout = self.timeout if timeout is None else timeout + + async with self._lock: + return await asyncio.wait_for( + self._send_command_no_lock(command, effective_timeout=effective_timeout), + timeout=effective_timeout, + ) + + async def _drain_stale_responses(self, *, timeout: Optional[float] = None) -> None: + """Consume any leftover lines from previously-cancelled commands. + + We track the number of terminators we still owe in + ``self._pending_terminator_count``. Each cancelled in-flight command + leaves at most one full response (``ACK!`` -> data -> ``OK!``/``ERROR!``) + in the socket buffer; reading lines until we have seen that many + terminators is sufficient to resynchronise the stream. + """ + while self._pending_terminator_count > 0: + line = await self._readline(timeout=timeout) + if _ANY_TERMINATOR_RE.match(line): + self._pending_terminator_count -= 1 + logger.debug( + "[microspin] drained stale terminator (%d still pending)", + self._pending_terminator_count, + ) + + async def _send_command_no_lock( + self, command: str, *, effective_timeout: Optional[float] = None + ) -> List[str]: + # Resynchronise the stream before writing anything new. + await self._drain_stale_responses(timeout=effective_timeout) + + logger.debug("[microspin] >>> %s", command) + await self.io.write((command + "\r\n").encode("ascii"), timeout=effective_timeout) + + # Speculatively assume our response will become orphaned if we are + # cancelled mid-read; the count is decremented again iff we successfully + # consume our own terminator. + self._pending_terminator_count += 1 + + # Stage 2: ACK! + ack = await self._readline(timeout=effective_timeout) + m = _ACK_RE.match(ack) + if not m: + raise MicroSpinProtocolError(f"Expected ACK!, got {ack!r}") + command_id = int(m.group("id")) + logger.debug("[microspin] <<< ACK id=%d", command_id) + + # Stages 3+4: data lines until OK!/ERROR!/ABORTED! + end_re = re.compile(rf"^(?P{_END_STATUSES})\s+.*\s+{command_id}\s*$") + data: List[str] = [] + while True: + line = await self._readline(timeout=effective_timeout) + end = end_re.match(line) + if end: + self._pending_terminator_count -= 1 + status = end.group("status") + if status == "OK!": + logger.debug("[microspin] <<< OK (%d data lines)", len(data)) + return data + if status == "ABORTED!": + raise MicroSpinAbortedError(command, command_id, data) + raise MicroSpinError(command, command_id, data) + data.append(line) + + # ------------------------------ CentrifugeBackend abstract methods -------- + + async def go_to_bucket1(self) -> None: + """Present bucket 1 at the load position. + + Note that on the MicroSpin the ``open `` command also *opens the + door* as a side effect; this is the only way to position a bucket for + loading. + """ + await self.send_command("open 1", timeout=max(self.timeout, 60.0)) + + async def go_to_bucket2(self) -> None: + """Present bucket 2 at the load position (also opens the door).""" + await self.send_command("open 2", timeout=max(self.timeout, 60.0)) + + # The six door/lock primitives below are declared abstract by + # CentrifugeBackend, but on the MicroSpin they are firmware-internal + # maintenance commands (see manual §6.7) that the higher-level commands + # (``open ``, ``spin``, ``home``) already handle automatically: + # + # * ``open_door`` / ``close_door`` -- on the MicroSpin there is no + # "open the door without choosing a bucket" workflow; door opening + # happens as a side effect of ``open ``, and door closing + # happens automatically at the start of ``spin`` and ``home``. + # * ``lock_door`` / ``unlock_door`` -- pneumatic door lock, driven by + # the firmware during ``spin``. + # * ``lock_bucket`` / ``unlock_bucket`` -- nest-lock pin, driven by + # the firmware during ``open ``. + # + # We deliberately do NOT forward them to the wire so callers can't put + # the device into a half-managed state by issuing them out-of-band. If + # you really need to drive the underlying maintenance commands + # (``od`` / ``cd`` / ``lockdoor`` / ``unlockdoor`` / ``locknest`` / + # ``unlocknest``) directly -- e.g. for service -- use + # :meth:`send_command`. + + async def open_door(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: there is no door-only open workflow. + + Always raises :class:`NotImplementedError`. The MicroSpin's + ``open `` firmware command opens the door *and* presents the + requested bucket in one shot, which is what callers actually want + 99% of the time -- see :meth:`go_to_bucket1` / :meth:`go_to_bucket2`. + The standalone ``od`` wire command is documented as maintenance-only + in manual §6.7 and is deliberately not exposed here. If you really + need to drive it (e.g. for service), use + ``backend.send_command("od")`` directly. + """ + raise NotImplementedError( + "There is no standalone door-open workflow on the MicroSpin. Use " + "`go_to_bucket1()` / `go_to_bucket2()` to open the door and present " + "a bucket in one step. The underlying `od` command is a " + "maintenance primitive (manual §6.7); if you really need it, use " + "`backend.send_command('od')`." + ) + + async def close_door(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: door closing is firmware-managed. + + Always raises :class:`NotImplementedError`. The MicroSpin firmware + closes the door automatically at the start of :meth:`spin` and + :meth:`home`, so application code never needs to issue an explicit + close. The standalone ``cd`` wire command is documented as + maintenance-only in manual §6.7 and is deliberately not exposed here. + If you really need it, use ``backend.send_command("cd")`` directly. + """ + raise NotImplementedError( + "Door closing on the MicroSpin happens automatically as part of " + "`spin` and `home`. The underlying `cd` command is a maintenance " + "primitive (manual §6.7); if you really need it, use " + "`backend.send_command('cd')`." + ) + + async def lock_door(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: door locking is firmware-managed. + + Always raises :class:`NotImplementedError`. The MicroSpin firmware locks + the door automatically as part of ``spin``; the underlying ``lockdoor`` + wire command is documented as maintenance-only in manual §6.7 and is + deliberately not exposed here. If you really need to drive it, use + ``backend.send_command("lockdoor")`` directly. + """ + raise NotImplementedError( + "Door locking is handled automatically by the MicroSpin firmware as " + "part of `spin`; the standalone `lockdoor` command is a maintenance " + "primitive (manual §6.7) and is not exposed through pylabrobot. If " + "you really need it, use `backend.send_command('lockdoor')`." + ) + + async def unlock_door(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: door unlocking is firmware-managed. + + Always raises :class:`NotImplementedError`. See :meth:`lock_door`. + """ + raise NotImplementedError( + "Door unlocking is handled automatically by the MicroSpin firmware " + "as part of `open `; the standalone `unlockdoor` command is " + "a maintenance primitive (manual §6.7) and is not exposed through " + "pylabrobot. If you really need it, use " + "`backend.send_command('unlockdoor')`." + ) + + async def lock_bucket(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: nest locking is firmware-managed. + + Always raises :class:`NotImplementedError`. See :meth:`lock_door`. + """ + raise NotImplementedError( + "Nest locking is handled automatically by the MicroSpin firmware as " + "part of `open `; the standalone `locknest` command is a " + "maintenance primitive (manual §6.7) and is not exposed through " + "pylabrobot. If you really need it, use " + "`backend.send_command('locknest')`." + ) + + async def unlock_bucket(self) -> None: # pragma: no cover -- always raises + """Not supported on the MicroSpin: nest unlocking is firmware-managed. + + Always raises :class:`NotImplementedError`. See :meth:`lock_door`. + """ + raise NotImplementedError( + "Nest unlocking is handled automatically by the MicroSpin firmware " + "as part of `spin`; the standalone `unlocknest` command is a " + "maintenance primitive (manual §6.7) and is not exposed through " + "pylabrobot. If you really need it, use " + "`backend.send_command('unlocknest')`." + ) + + async def spin( + self, + g: float, + duration: float, + acceleration: float = 0.5, + deceleration: float = 0.5, + ) -> None: + """Start a spin cycle on the MicroSpin. + + Args: + g: Relative centrifugal force in ``×g``. Must be in ``[1, 3000]`` + (3000 ``×g`` is the spec maximum per manual §11). + duration: Time at speed in seconds. Must be ``>= 1``. + acceleration: Acceleration ramp as a fraction of the machine maximum, + in ``(0, 1]``. Internally converted to the integer percent the + firmware expects. + deceleration: Deceleration ramp as a fraction of the machine maximum, + in ``(0, 1]``. The MicroSpin firmware uses a fast decel curve above + ``CENTRIFUGE_DECEL_THRESHOLD_G`` (default 300 ``×g``) and a slow one + below, so the *effective* decel rate may vary across the run. + + Raises: + ValueError: If any argument is out of range. + MicroSpinError: If the device rejects the spin (e.g. door not closed, + device not homed, imbalance trip). + + Warns: + UserWarning: If ``g`` is below + :attr:`LOW_G_WARNING_THRESHOLD` (30 ×g by default). At very low + G-forces the spindle's "stopped" sensor sometimes fails to latch at + the end of the spin, so the firmware never emits the final ``OK!`` + completion line. From the client's point of view the command + appears to hang indefinitely; subsequent commands will time out + because the firmware still considers a spin in progress. If you + hit this, the recovery path is :meth:`abort` followed by + :meth:`clear_button_abort` (and possibly a power-cycle). + UserWarning: If ``deceleration`` is below + :attr:`STUCK_DECEL_WARNING_THRESHOLD` (0.20 = 20 % of max). Very + low decel rates have empirically failed to ever report spin-down + in real-world testing (``spin 1000 100 10 10`` ran for >30 min + with no completion). The recovery path is the same as for the + low-G hang above. + UserWarning: If ``deceleration`` is below + :attr:`SLOW_DECEL_WARNING_THRESHOLD` (0.40 = 40 % of max) but at + or above the stuck threshold. Spin-down completes correctly here + but is slow: tested ``spin 1000 100 20 10`` (decel = 0.20) took + ~7 minutes. Make sure your :meth:`wait_for_spindle_stopped` + budget (default 30 min) and any application-level timeouts allow + for this. Only one decel warning is emitted per call; if both + thresholds are crossed, the stuck-decel warning takes precedence. + + Safety: + See the module-level docstring for the pre-spin checklist. This method + does NOT verify physical conditions; it only validates argument ranges. + """ + if not 1 <= g <= self.MAX_G_FORCE: + raise ValueError(f"g must be in [1, {self.MAX_G_FORCE}] ×g, got {g}") + if duration < 1: + raise ValueError(f"duration must be at least 1 second, got {duration}") + if not 0 < acceleration <= 1: + raise ValueError(f"acceleration must be in (0, 1], got {acceleration}") + if not 0 < deceleration <= 1: + raise ValueError(f"deceleration must be in (0, 1], got {deceleration}") + + if g < self.LOW_G_WARNING_THRESHOLD: + warnings.warn( + f"Spinning the MicroSpin at g={g} (<{self.LOW_G_WARNING_THRESHOLD} ×g) " + "is known to occasionally hang the firmware: the spindle-stopped " + "sensor may fail to latch, so no `OK!` is emitted and subsequent " + "commands will time out. If this happens, call `abort()` followed " + "by `clear_button_abort()`, and power-cycle if the device stays stuck.", + UserWarning, + stacklevel=2, + ) + + if deceleration < self.STUCK_DECEL_WARNING_THRESHOLD: + warnings.warn( + f"Spinning the MicroSpin with deceleration={deceleration} " + f"(<{self.STUCK_DECEL_WARNING_THRESHOLD}, i.e. <{int(self.STUCK_DECEL_WARNING_THRESHOLD * 100)} %) " + "may trigger a firmware bug where the rotor never reports having " + "spun down: a tested `spin 1000 100 10 10` ran for >30 minutes " + "without ever completing. If this happens, call `abort()` followed " + "by `clear_button_abort()`, and power-cycle if the device stays stuck.", + UserWarning, + stacklevel=2, + ) + elif deceleration < self.SLOW_DECEL_WARNING_THRESHOLD: + warnings.warn( + f"Spinning the MicroSpin with deceleration={deceleration} " + f"(<{self.SLOW_DECEL_WARNING_THRESHOLD}, i.e. <{int(self.SLOW_DECEL_WARNING_THRESHOLD * 100)} %) " + "results in a long spin-down: a tested `spin 1000 100 20 10` " + "took ~7 minutes from full speed to stopped. Make sure your " + "`wait_for_spindle_stopped` budget and any application-level " + "timeouts allow for this.", + UserWarning, + stacklevel=2, + ) + + g_int = int(round(g)) + duration_int = int(round(duration)) + accel_pct = max(1, min(100, int(round(acceleration * 100)))) + decel_pct = max(1, min(100, int(round(deceleration * 100)))) + + # The spin command completes when the rotor has fully decelerated and the + # "spindle stopped" sensor latches. Generous padding on top of the user's + # `duration` is needed to cover both ramps plus the sensor settle window. + spin_timeout = max(self.timeout, duration + 180.0) + await self.send_command( + f"spin {g_int} {accel_pct} {decel_pct} {duration_int}", + timeout=spin_timeout, + ) + + # ------------------------------ MicroSpin-specific helpers ---------------- + + async def home(self) -> None: + """Home both axes (door and spindle). + + The MicroSpin User Manual (HighRes doc 1058675 Rev C) does not + explicitly require homing after every power-cycle, but observably the + firmware reports ``hss -> not homed`` after a power-cycle, and + subsequent motion commands (``open ``, ``spin``) fail with + "not homed" errors until ``home`` is issued. The manual's recommended + unpacking procedure (§5.3) also opens with ``home``, and §7.2 requires + a re-home after an imbalance abort. In practice, treat ``home`` as the + first motion command of every power-on session. + """ + await self.send_command("home", timeout=max(self.timeout, 120.0)) + + async def is_homed(self) -> bool: + """Return ``True`` if the device reports ``homed`` to ``hss``.""" + data = await self.send_command("hss") + return bool(data) and data[0].strip().lower() == "homed" + + async def abort(self, *, timeout: Optional[float] = None) -> None: + """Decelerate the rotor and stop the current operation. + + After ``abort``, the firmware enters an aborted state that blocks further + motion commands until you call :meth:`clear_button_abort`. + + Args: + timeout: Override the per-command timeout. A full decel from 3000 ×g on + the slow-decel curve can take well over a minute, so by default this + uses ``max(self.timeout, 180s)``. + """ + effective = max(self.timeout, 180.0) if timeout is None else timeout + await self.send_command("abort", timeout=effective) + + async def clear_button_abort(self) -> None: + """Clear the abort state (resets the latch set by ``abort`` or the front + panel button).""" + await self.send_command("clearbuttonabort") + + async def reset( + self, + *, + abort_timeout: Optional[float] = None, + settle_timeout: Optional[float] = None, + swallow_abort_errors: bool = True, + wait_for_settle: bool = True, + ) -> Optional[Dict[str, str]]: + """Bring the device back to a clean, ready-to-command state. + + Issues the canonical recovery sequence: + + 1. :meth:`abort` -- request a decel + stop. + 2. :meth:`clear_button_abort` -- release the latched abort state so that + subsequent motion commands (``home``, ``open``, ``spin``, ...) are + accepted again. + 3. :meth:`wait_for_spindle_stopped` -- a single ``status`` call that the + firmware will not answer until the rotor is genuinely stopped. + + Steps 1 and 2 both return ``OK!`` *immediately* on the wire -- they are + just acknowledgements of the request, not confirmation that motion has + ceased. Step 3 is the real "we are stopped" gate, because the firmware + queues a ``status`` request behind any active motion and only answers + once that motion completes. + + Args: + abort_timeout: Override the timeout for the ``abort`` step. Pass a + generous value if you've configured a very tight backend timeout. + Defaults to :meth:`abort`'s own default. + settle_timeout: Override the timeout for the final ``status`` poll + that waits for the spindle to actually spin down. Defaults to + :meth:`wait_for_spindle_stopped`'s own default (``max(self.timeout, + 300s)``). + swallow_abort_errors: If ``True`` (the default), errors raised by the + ``abort`` step are logged and ignored so that + :meth:`clear_button_abort` is still attempted. This is usually what + you want for a recovery routine -- e.g. ``abort`` can legitimately + fail with "nothing to abort" depending on firmware state. Set this + to ``False`` if you want any abort failure to propagate. + wait_for_settle: If ``True`` (the default), block on step 3 and + return the final status dict. If ``False``, skip step 3 and return + ``None`` immediately after :meth:`clear_button_abort` -- useful + when you only want to clear the firmware's abort latch and don't + care whether the rotor has stopped yet. + + Returns: + The parsed status report from step 3 (a ``{field: value}`` dict), or + ``None`` if ``wait_for_settle=False``. + + Raises: + MicroSpinError: If :meth:`clear_button_abort` fails, or if the final + ``status`` poll returns ``ERROR!``. Either case indicates the + device is in a state that the normal recovery sequence can't get + out of -- a power-cycle is usually required at that point. + asyncio.TimeoutError: If the rotor doesn't stop within + ``settle_timeout`` (i.e. the spindle is genuinely stuck). + + Note: + ``reset`` does NOT verify that the resulting state is *homed*. Follow + up with :meth:`is_homed` (and re-:meth:`home` if needed) before + issuing the next motion command. The MicroSpin firmware also keeps a + persistent error stack which is unaffected by reset; use + :meth:`request_errors` to inspect it. + """ + try: + await self.abort(timeout=abort_timeout) + except MicroSpinError as exc: + if not swallow_abort_errors: + raise + logger.info( + "[microspin] abort during reset() returned ERROR! (swallowed): %s", + exc, + ) + await self.clear_button_abort() + if not wait_for_settle: + return None + return await self.wait_for_spindle_stopped(timeout=settle_timeout) + + async def request_status(self, *, timeout: Optional[float] = None) -> Dict[str, str]: + """Return the device's status report as a ``{field: value}`` dict. + + Typical fields include ``Spindle Position`` and ``Door Position``. + + Note: + When a spin or decel is in progress, the MicroSpin firmware will not + respond to ``status`` until the rotor is fully stopped. ``status`` is + therefore a convenient synchronous "are we really stopped yet?" gate; + see :meth:`wait_for_spindle_stopped` and :meth:`reset` for callers + that exploit this. + + Args: + timeout: Override the per-command timeout. The default (None) uses + ``self.timeout`` which may be too short if a spin-down is in + progress -- pass a generous value (e.g. 300 s) in that case. + """ + data = await self.send_command("status", timeout=timeout) + return _parse_kv_lines(data) + + #: Default *overall* budget for :meth:`wait_for_spindle_stopped` -- 30 min, + #: chosen to comfortably cover the worst-case observed decel (~17 min for a + #: high-G spin on the slow-decel curve, e.g. ``spin 1000 100 10 5``). + DEFAULT_SPINDLE_STOP_TIMEOUT: Optional[float] = 1800.0 + #: Default *per-poll* timeout for :meth:`wait_for_spindle_stopped`. The + #: method issues a fresh ``status`` every ``poll_interval`` seconds until + #: one succeeds (i.e. the spindle reports stopped) or until the overall + #: ``timeout`` budget expires. + DEFAULT_SPINDLE_POLL_INTERVAL: float = 60.0 + + async def wait_for_spindle_stopped( + self, + *, + timeout: Optional[float] = DEFAULT_SPINDLE_STOP_TIMEOUT, + poll_interval: float = DEFAULT_SPINDLE_POLL_INTERVAL, + ) -> Dict[str, str]: + """Block until the firmware confirms the rotor is fully stopped. + + The MicroSpin firmware queues ``status`` behind any active motion and + only answers once the rotor has spun down, so a single ``status`` is + sufficient as a "we are stopped" gate. This method issues ``status`` + repeatedly with a short per-call timeout until one returns successfully + (the rotor stopped) -- or until the overall ``timeout`` budget expires. + + Retrying matters in practice because long decels can take well over a + poll interval (a worst-case observed spin was ``spin 1000 100 10 5`` + taking >17 min to spin down on the slow-decel curve). With a single + long timeout, you have to either pick a value that's too short and + raise spuriously, or one that's so long it would mask a genuine hang + forever. Polling gives you both bounded latency and tolerant patience. + + Args: + timeout: Total time budget in seconds. ``None`` means "wait + indefinitely" (only do this if you're sure the device isn't stuck + -- see the low-G hang warning in :meth:`spin`). Defaults to + :attr:`DEFAULT_SPINDLE_STOP_TIMEOUT` (30 min). + poll_interval: Per-``status`` call timeout in seconds. Each + individual ``status`` call may legitimately time out (because the + rotor is still moving); the loop catches those and tries again. + Defaults to :attr:`DEFAULT_SPINDLE_POLL_INTERVAL` (60 s). + + Returns: + The parsed status report ({key: value} dict) from the first + ``status`` call that succeeds. + + Raises: + asyncio.TimeoutError: If the overall ``timeout`` expires before any + ``status`` call returns successfully. + MicroSpinError: If a ``status`` call returns ``ERROR!`` (this is not + retried -- an ``ERROR!`` from ``status`` means the device itself + thinks something is wrong, not that motion is still in progress). + """ + if poll_interval <= 0: + raise ValueError(f"poll_interval must be positive, got {poll_interval}") + + loop = asyncio.get_event_loop() + deadline: Optional[float] = None if timeout is None else loop.time() + timeout + + attempt = 0 + while True: + attempt += 1 + remaining = None if deadline is None else max(0.0, deadline - loop.time()) + if remaining is not None and remaining <= 0: + raise asyncio.TimeoutError( + f"Spindle did not stop within wait_for_spindle_stopped budget " + f"({timeout}s, {attempt - 1} polls)" + ) + this_call_timeout = poll_interval if remaining is None else min(poll_interval, remaining) + try: + return await self.request_status(timeout=this_call_timeout) + except asyncio.TimeoutError: + logger.debug( + "[microspin] status poll %d timed out after %.1fs; retrying", + attempt, + this_call_timeout, + ) + # Loop body retries until the overall deadline is reached. + + async def request_version(self) -> Dict[str, str]: + """Return the firmware/library version report as a ``{field: value}`` dict.""" + data = await self.send_command("version") + return _parse_kv_lines(data) + + async def request_errors(self, n: int = 10) -> List[str]: + """Return the top ``n`` entries from the device's error stack.""" + return await self.send_command(f"errors {int(n)}") + + +def _parse_kv_lines(lines: List[str]) -> Dict[str, str]: + """Parse ``key: value`` style report lines into a dict.""" + out: Dict[str, str] = {} + for line in lines: + if ":" in line: + key, _, value = line.partition(":") + out[key.strip()] = value.strip() + return out diff --git a/pylabrobot/centrifuge/highres/microspin_tests.py b/pylabrobot/centrifuge/highres/microspin_tests.py new file mode 100644 index 00000000000..4456b3eb6c0 --- /dev/null +++ b/pylabrobot/centrifuge/highres/microspin_tests.py @@ -0,0 +1,587 @@ +"""Edge-case tests for :class:`MicroSpinBackend` using a stub asyncio stream +pair. + +End-to-end wire-level behaviour (command mappings, status-blocks-during-motion, +reset happy path, etc.) lives in :mod:`mock_server_tests` and runs against the +real :class:`MicroSpinMockServer` over a TCP socket. The tests in *this* file +only cover situations the mock server cannot reasonably reproduce: + +* Malformed protocol bytes from the device (bad ACK line, EOF mid-response). +* Operating without :meth:`MicroSpinBackend.setup`. +* Specific ``ERROR!`` sequences whose exact text we want to assert on. +* Argument validation that never reaches the wire. +* Monkey-patched ``send_command`` for verifying timeout extension logic. +* Serialization (no socket). +* Reset's error-handling branches, which need precisely-controllable + ``ERROR!`` responses from each step. +""" + +from __future__ import annotations + +import asyncio +import unittest +import warnings +from typing import List, Tuple + +from pylabrobot.centrifuge.highres.microspin_backend import ( + MicroSpinAbortedError, + MicroSpinBackend, + MicroSpinError, + MicroSpinProtocolError, +) + + +class _FakeWriter: + """Captures everything written by the backend so tests can assert on it.""" + + def __init__(self) -> None: + self.sent: bytearray = bytearray() + self.closed = False + + def write(self, data: bytes) -> None: + self.sent.extend(data) + + async def drain(self) -> None: + return None + + def close(self) -> None: + self.closed = True + + async def wait_closed(self) -> None: + return None + + +class _FakeReader: + """Yields a queue of lines one ``readline()`` at a time. + + When the queue is exhausted, returns ``b""`` (EOF) by default, or hangs + indefinitely if ``hang_on_empty=True`` -- the latter is what we want when + simulating a slow device whose response never arrives in time. + """ + + def __init__(self, lines: List[bytes], *, hang_on_empty: bool = False) -> None: + self._lines: List[bytes] = list(lines) + self._hang_on_empty = hang_on_empty + + async def readline(self) -> bytes: + if not self._lines: + if self._hang_on_empty: + await asyncio.Event().wait() # never fires + return b"" # simulate EOF + return self._lines.pop(0) + + +def _make_backend(server_lines: List[str]) -> Tuple[MicroSpinBackend, _FakeWriter]: + """Build a backend pre-wired to a fake reader/writer pair. No real socket.""" + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + writer = _FakeWriter() + reader = _FakeReader([s.encode("ascii") for s in server_lines]) + backend.io._writer = writer # type: ignore[assignment] + backend.io._reader = reader # type: ignore[assignment] + return backend, writer + + +def _sent_commands(writer: _FakeWriter) -> List[str]: + text = writer.sent.decode("ascii") + return [line for line in text.split("\r\n") if line] + + +class MicroSpinProtocolEdgeCaseTests(unittest.IsolatedAsyncioTestCase): + """Cases the real mock server cannot easily produce (malformed bytes / EOF).""" + + async def test_protocol_error_on_bad_ack(self): + backend, _ = _make_backend(["GARBAGE\r\n"]) + with self.assertRaises(MicroSpinProtocolError): + await backend.send_command("status") + + async def test_connection_closed_mid_response(self): + backend, _ = _make_backend(["ACK! status 1\r\n"]) # no terminator, then EOF + with self.assertRaises(ConnectionError): + await backend.send_command("status") + + async def test_setup_required_before_send_command(self): + backend = MicroSpinBackend(host="ignored", port=0) + with self.assertRaises(RuntimeError): + await backend.send_command("status") + + async def test_error_response_carries_diagnostic_lines(self): + backend, _ = _make_backend( + [ + "ACK! spin 0 0 0 1 19\r\n", + "Error 1: (00:00:01) -12: bad params\r\n", + "ERROR! spin 0 0 0 1 19\r\n", + ] + ) + with self.assertRaises(MicroSpinError) as cm: + await backend.send_command("spin 0 0 0 1") + self.assertEqual(cm.exception.command_id, 19) + self.assertEqual(cm.exception.command, "spin 0 0 0 1") + self.assertEqual(cm.exception.error_lines, ["Error 1: (00:00:01) -12: bad params"]) + + async def test_aborted_terminator_raises_aborted_error(self): + """The device emits ``ABORTED!`` (a third terminator) for commands + cancelled by an abort or issued while the abort latch is set; the + parser must distinguish these from regular ``ERROR!`` responses. + """ + backend, _ = _make_backend( + [ + "ACK! spin 500 100 100 5 21\r\n", + "ABORTED! spin 500 100 100 5 21\r\n", + ] + ) + with self.assertRaises(MicroSpinAbortedError) as cm: + await backend.send_command("spin 500 100 100 5") + self.assertEqual(cm.exception.command_id, 21) + self.assertEqual(cm.exception.command, "spin 500 100 100 5") + # ABORTED! typically arrives with no preceding diagnostic data lines. + self.assertEqual(cm.exception.error_lines, []) + # And it's a MicroSpinError subclass so plain `except MicroSpinError` + # in caller code still catches aborts. + self.assertIsInstance(cm.exception, MicroSpinError) + + +class MicroSpinStreamResyncTests(unittest.IsolatedAsyncioTestCase): + """After a cancelled/timed-out command, the next command must transparently + drain the stale response and return its own result. Without this the + protocol desyncs and every subsequent command reads someone else's data. + """ + + async def test_stale_response_is_drained_before_next_command(self): + # Simulate the buffer state after a `status` was cancelled mid-flight: + # the device eventually delivers its full response, then ours arrives. + backend, writer = _make_backend( + [ + # Stale response for the previously-cancelled command (cmd-id 5): + "ACK! status 5\r\n", + "Spindle Position: 9999\r\n", + "OK! status 5\r\n", + # Our fresh response (cmd-id 6): + "ACK! version 6\r\n", + "Version: MS-1.3.3\r\n", + "OK! version 6\r\n", + ] + ) + # Pretend the previous send_command was cancelled after writing "status": + backend._pending_terminator_count = 1 + + data = await backend.send_command("version") + self.assertEqual(data, ["Version: MS-1.3.3"]) + # And the stale-counter is back to zero. + self.assertEqual(backend._pending_terminator_count, 0) + + async def test_partial_stale_response_drained(self): + # Cancelled AFTER reading the ACK but before the terminator: the buffer + # holds the remaining data + OK, then our response. + backend, writer = _make_backend( + [ + # Leftover from a partially-read previous response (cmd-id 5): + "Spindle Position: 9999\r\n", # data line we hadn't read yet + "OK! status 5\r\n", # terminator we hadn't read yet + # Our fresh response (cmd-id 6): + "ACK! version 6\r\n", + "Version: MS-1.3.3\r\n", + "OK! version 6\r\n", + ] + ) + backend._pending_terminator_count = 1 + + data = await backend.send_command("version") + self.assertEqual(data, ["Version: MS-1.3.3"]) + self.assertEqual(backend._pending_terminator_count, 0) + + async def test_multiple_stale_responses_drained(self): + backend, writer = _make_backend( + [ + # Two stale responses (cmd-ids 5 and 6): + "ACK! status 5\r\n", + "Spindle Position: 9999\r\n", + "OK! status 5\r\n", + "ACK! status 6\r\n", + "Spindle Position: 1\r\n", + "OK! status 6\r\n", + # Our fresh response (cmd-id 7): + "ACK! version 7\r\n", + "Version: MS-1.3.3\r\n", + "OK! version 7\r\n", + ] + ) + backend._pending_terminator_count = 2 + + data = await backend.send_command("version") + self.assertEqual(data, ["Version: MS-1.3.3"]) + self.assertEqual(backend._pending_terminator_count, 0) + + async def test_send_command_keeps_pending_count_on_cancellation(self): + """Verify the bookkeeping that enables the drain. + + If `send_command` is cancelled (e.g. by ``asyncio.wait_for``) mid-read, + the in-flight terminator must remain in the pending count so the *next* + call can drain it. + """ + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + backend.io._writer = _FakeWriter() # type: ignore[assignment] + backend.io._reader = _FakeReader( # type: ignore[assignment] + [b"ACK! home 5\r\n"], # ACK arrives, but the terminator never does + hang_on_empty=True, + ) + + with self.assertRaises(asyncio.TimeoutError): + await asyncio.wait_for(backend.send_command("home"), timeout=0.05) + self.assertEqual(backend._pending_terminator_count, 1) + + +class MicroSpinValidationTests(unittest.IsolatedAsyncioTestCase): + """Argument validation that raises before any bytes hit the wire.""" + + async def test_spin_rounds_and_clamps_percents(self): + backend, writer = _make_backend( + [ + "ACK! spin 250 1 100 5 9\r\n", + "OK! spin 250 1 100 5 9\r\n", + ] + ) + # 0.004 rounds to 0; we clamp to a minimum of 1%. + await backend.spin(g=250.4, duration=5.2, acceleration=0.004, deceleration=1.0) + self.assertEqual(_sent_commands(writer), ["spin 250 1 100 5"]) + + async def test_spin_rejects_out_of_range_g(self): + backend, _ = _make_backend([]) + for bad in [-1, 0, 3001, 100000]: + with self.assertRaises(ValueError): + await backend.spin(g=bad, duration=10) + + async def test_spin_rejects_short_duration(self): + backend, _ = _make_backend([]) + with self.assertRaises(ValueError): + await backend.spin(g=100, duration=0.5) + + async def test_spin_rejects_bad_acceleration(self): + backend, _ = _make_backend([]) + for bad in [0, -0.1, 1.1, 2.0]: + with self.assertRaises(ValueError): + await backend.spin(g=100, duration=10, acceleration=bad) + + async def test_spin_rejects_bad_deceleration(self): + backend, _ = _make_backend([]) + for bad in [0, -0.1, 1.1, 2.0]: + with self.assertRaises(ValueError): + await backend.spin(g=100, duration=10, deceleration=bad) + + async def test_spin_warns_below_low_g_threshold(self): + backend, writer = _make_backend( + [ + "ACK! spin 20 50 50 5 1\r\n", + "OK! spin 20 50 50 5 1\r\n", + ] + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await backend.spin(g=20, duration=5) + low_g_warnings = [ + w for w in caught if issubclass(w.category, UserWarning) and "×g" in str(w.message) + ] + self.assertEqual(len(low_g_warnings), 1) + self.assertEqual(_sent_commands(writer), ["spin 20 50 50 5"]) + + async def test_spin_does_not_warn_at_or_above_threshold(self): + backend, _ = _make_backend( + [ + "ACK! spin 30 50 50 5 1\r\n", + "OK! spin 30 50 50 5 1\r\n", + ] + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await backend.spin(g=30, duration=5) + low_g_warnings = [ + w for w in caught if issubclass(w.category, UserWarning) and "×g" in str(w.message) + ] + self.assertEqual(low_g_warnings, []) + + async def test_spin_warns_at_slow_decel(self): + """decel in [0.20, 0.40) -> 'long spin-down' warning, command still runs.""" + backend, writer = _make_backend( + [ + "ACK! spin 1000 100 30 10 1\r\n", + "OK! spin 1000 100 30 10 1\r\n", + ] + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await backend.spin(g=1000, duration=10, acceleration=1.0, deceleration=0.30) + decel_warnings = [ + w for w in caught if issubclass(w.category, UserWarning) and "deceleration" in str(w.message) + ] + self.assertEqual(len(decel_warnings), 1, [str(w.message) for w in caught]) + self.assertIn("long spin-down", str(decel_warnings[0].message)) + self.assertEqual(_sent_commands(writer), ["spin 1000 100 30 10"]) + + async def test_spin_warns_at_stuck_decel_threshold(self): + """decel < 0.20 -> 'firmware hang' warning takes precedence.""" + backend, writer = _make_backend( + [ + "ACK! spin 1000 100 10 10 1\r\n", + "OK! spin 1000 100 10 10 1\r\n", + ] + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await backend.spin(g=1000, duration=10, acceleration=1.0, deceleration=0.10) + decel_warnings = [ + w for w in caught if issubclass(w.category, UserWarning) and "deceleration" in str(w.message) + ] + # Exactly one decel warning: the more severe (stuck) one wins. + self.assertEqual(len(decel_warnings), 1, [str(w.message) for w in caught]) + self.assertIn("firmware bug", str(decel_warnings[0].message)) + self.assertEqual(_sent_commands(writer), ["spin 1000 100 10 10"]) + + async def test_spin_does_not_warn_at_safe_decel(self): + """decel >= 0.40 is fully safe and emits no decel warning.""" + backend, _ = _make_backend( + [ + "ACK! spin 1000 100 40 10 1\r\n", + "OK! spin 1000 100 40 10 1\r\n", + ] + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await backend.spin(g=1000, duration=10, acceleration=1.0, deceleration=0.40) + decel_warnings = [ + w for w in caught if issubclass(w.category, UserWarning) and "deceleration" in str(w.message) + ] + self.assertEqual(decel_warnings, []) + + async def test_maintenance_door_and_lock_methods_raise_not_implemented(self): + """open_door / close_door / the four lock primitives are maintenance-only + on the MicroSpin (manual §6.7). They must raise rather than silently + sending bytes; the firmware handles door + lock state internally as + part of `open `, `spin`, and `home`. + """ + backend, writer = _make_backend([]) + for method_name in ( + "open_door", + "close_door", + "lock_door", + "unlock_door", + "lock_bucket", + "unlock_bucket", + ): + with self.assertRaises(NotImplementedError): + await getattr(backend, method_name)() + self.assertEqual(_sent_commands(writer), []) + + +class MicroSpinHelperEdgeCaseTests(unittest.IsolatedAsyncioTestCase): + """Helper behaviour with carefully-shaped responses we don't get from the mock.""" + + async def test_request_errors_returns_lines_verbatim(self): + backend, _ = _make_backend( + [ + "ACK! errors 3 4\r\n", + "Error 1: foo\r\n", + "Error 2: bar\r\n", + "Error 3: baz\r\n", + "OK! errors 3 4\r\n", + ] + ) + self.assertEqual( + await backend.request_errors(3), + ["Error 1: foo", "Error 2: bar", "Error 3: baz"], + ) + + +class MicroSpinResetErrorPathTests(unittest.IsolatedAsyncioTestCase): + """Reset's error-handling branches, which need controllable ERROR! at each step.""" + + async def test_reset_swallows_abort_error_by_default(self): + # The MicroSpin can legitimately reject an abort if there's nothing to abort. + # `reset` should still go on to clear the button-abort latch AND wait. + backend, writer = _make_backend( + [ + "ACK! abort 1\r\n", + "Error 1: nothing to abort\r\n", + "ERROR! abort 1\r\n", + "ACK! clearbuttonabort 2\r\n", + "OK! clearbuttonabort 2\r\n", + "ACK! status 3\r\n", + "Spindle Position: 0\r\n", + "OK! status 3\r\n", + ] + ) + result = await backend.reset() + self.assertEqual(_sent_commands(writer), ["abort", "clearbuttonabort", "status"]) + self.assertEqual(result, {"Spindle Position": "0"}) + + async def test_reset_propagates_abort_error_when_asked(self): + backend, writer = _make_backend( + [ + "ACK! abort 1\r\n", + "Error 1: nothing to abort\r\n", + "ERROR! abort 1\r\n", + ] + ) + with self.assertRaises(MicroSpinError): + await backend.reset(swallow_abort_errors=False) + self.assertEqual(_sent_commands(writer), ["abort"]) + + async def test_reset_propagates_clear_button_abort_error(self): + backend, writer = _make_backend( + [ + "ACK! abort 1\r\n", + "OK! abort 1\r\n", + "ACK! clearbuttonabort 2\r\n", + "Error 1: stuck\r\n", + "ERROR! clearbuttonabort 2\r\n", + ] + ) + with self.assertRaises(MicroSpinError): + await backend.reset() + self.assertEqual(_sent_commands(writer), ["abort", "clearbuttonabort"]) + + async def test_reset_propagates_status_error(self): + backend, writer = _make_backend( + [ + "ACK! abort 1\r\n", + "OK! abort 1\r\n", + "ACK! clearbuttonabort 2\r\n", + "OK! clearbuttonabort 2\r\n", + "ACK! status 3\r\n", + "Error 1: spindle wedged\r\n", + "ERROR! status 3\r\n", + ] + ) + with self.assertRaises(MicroSpinError): + await backend.reset() + self.assertEqual(_sent_commands(writer), ["abort", "clearbuttonabort", "status"]) + + +class MicroSpinTimeoutExtensionTests(unittest.IsolatedAsyncioTestCase): + """Monkey-patched send_command to verify long-motion timeout extension.""" + + async def test_abort_uses_extended_default_timeout(self): + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + seen: list = [] + + async def fake_send(cmd, *, timeout=None): + seen.append((cmd, timeout)) + return [] + + backend.send_command = fake_send # type: ignore[assignment] + await backend.abort() + self.assertEqual(seen, [("abort", 180.0)]) + + seen.clear() + await backend.abort(timeout=5.0) + self.assertEqual(seen, [("abort", 5.0)]) + + async def test_wait_for_spindle_stopped_uses_poll_interval_per_call(self): + """Each individual ``status`` is bounded by ``poll_interval``, not by + the overall ``timeout``.""" + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + seen: list = [] + + async def fake_send(cmd, *, timeout=None): + seen.append((cmd, timeout)) + return [] + + backend.send_command = fake_send # type: ignore[method-assign] + # Defaults: poll_interval=60, total timeout=1800. First call should + # get poll_interval (or min(poll_interval, remaining) which == 60). + await backend.wait_for_spindle_stopped() + self.assertEqual(seen, [("status", 60.0)]) + + seen.clear() + await backend.wait_for_spindle_stopped(poll_interval=5.0, timeout=100.0) + self.assertEqual(seen, [("status", 5.0)]) + + async def test_wait_for_spindle_stopped_retries_on_per_call_timeout(self): + """If the per-call status times out, we issue another one.""" + import asyncio as _asyncio + + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + call_count = 0 + + async def fake_send(cmd, *, timeout=None): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise _asyncio.TimeoutError("still spinning") + return ["Spindle Position: 1958", "Door Position: -457"] + + backend.send_command = fake_send # type: ignore[method-assign] + result = await backend.wait_for_spindle_stopped(poll_interval=0.01, timeout=10.0) + self.assertEqual(call_count, 3) + self.assertEqual(result, {"Spindle Position": "1958", "Door Position": "-457"}) + + async def test_wait_for_spindle_stopped_raises_when_total_budget_expires(self): + """With every poll timing out and a tight overall budget, we raise.""" + import asyncio as _asyncio + + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + + async def fake_send(cmd, *, timeout=None): + raise _asyncio.TimeoutError("still spinning") + + backend.send_command = fake_send # type: ignore[method-assign] + with self.assertRaises(_asyncio.TimeoutError): + await backend.wait_for_spindle_stopped(poll_interval=0.01, timeout=0.05) + + async def test_wait_for_spindle_stopped_propagates_microspin_error(self): + """An ERROR! from status is a real device-state error -- don't retry.""" + backend = MicroSpinBackend(host="ignored", port=0, timeout=2.0) + call_count = 0 + + async def fake_send(cmd, *, timeout=None): + nonlocal call_count + call_count += 1 + raise MicroSpinError("status", 1, ["Error: spindle wedged"]) + + backend.send_command = fake_send # type: ignore[method-assign] + with self.assertRaises(MicroSpinError): + await backend.wait_for_spindle_stopped(poll_interval=10.0, timeout=60.0) + self.assertEqual(call_count, 1) # NOT retried + + async def test_wait_for_spindle_stopped_rejects_non_positive_poll_interval(self): + backend = MicroSpinBackend(host="ignored", port=0) + for bad in (0, -1, -0.001): + with self.assertRaises(ValueError): + await backend.wait_for_spindle_stopped(poll_interval=bad) + + +class MicroSpinConstructorTests(unittest.IsolatedAsyncioTestCase): + def test_default_port_is_1000(self): + """Backend, factory, and the class constant must agree on 1000.""" + from pylabrobot.centrifuge import MicroSpin + + self.assertEqual(MicroSpinBackend.DEFAULT_PORT, 1000) + backend = MicroSpinBackend(host="example.invalid") + self.assertEqual(backend.port, 1000) + cf = MicroSpin(name="x", host="example.invalid") + assert isinstance(cf.backend, MicroSpinBackend) + self.assertEqual(cf.backend.port, 1000) + + def test_port_can_be_customised(self): + from pylabrobot.centrifuge import MicroSpin + + backend = MicroSpinBackend(host="example.invalid", port=9001) + self.assertEqual(backend.port, 9001) + cf = MicroSpin(name="x", host="example.invalid", port=9001) + assert isinstance(cf.backend, MicroSpinBackend) + self.assertEqual(cf.backend.port, 9001) + + +class MicroSpinSerializeTests(unittest.IsolatedAsyncioTestCase): + def test_serialize_includes_connection_info(self): + backend = MicroSpinBackend(host="10.0.0.5", port=1234, timeout=12.5) + s = backend.serialize() + self.assertEqual(s["host"], "10.0.0.5") + self.assertEqual(s["port"], 1234) + self.assertEqual(s["timeout"], 12.5) + + def test_serialize_records_default_port(self): + backend = MicroSpinBackend(host="10.0.0.5") + self.assertEqual(backend.serialize()["port"], 1000) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/centrifuge/highres/mock_server.py b/pylabrobot/centrifuge/highres/mock_server.py new file mode 100644 index 00000000000..63256deeff6 --- /dev/null +++ b/pylabrobot/centrifuge/highres/mock_server.py @@ -0,0 +1,743 @@ +"""In-process mock of the HighRes MicroSpin TCP/1000 command server. + +This is a faithful-enough re-implementation of the MicroSpin's remote-control +protocol (manual \u00a76.6) to drive :class:`MicroSpinBackend` end-to-end without +a real device. It is intended for: + +* CI / local integration tests that want to exercise the full asyncio socket + path of the backend (not just stubs that replay canned bytes). +* Hand-driving via ``nc`` / ``telnet`` while developing or debugging. +* Reproducing tricky firmware behaviours -- e.g. the "``status`` blocks until + the spindle has truly stopped" gate, the ``ABORTED!`` terminator the + device emits for commands cancelled by an ``abort``, and the low-G + spin-down-detection hang. + +The mock implements **only** the commands the real device's ``list`` +enumerates (manual \u00a76.7's public set): + + clearbuttonabort, cba + commandstat, cstat + disconnect, d + errors, e + help + info, ?? + list, ? + logcommands + version, v + whoami + abort, a + home + homedstatus, hss + open + spin, sp + status, s + +Maintenance commands (``od``, ``cd``, ``lockdoor``, ``unlockdoor``, +``locknest``, ``unlocknest``, ``r``, ``copleyget``, ``copleyset``, ``ddio``, +etc.) are deliberately not modelled. Sending one to the mock will produce +the same ``Command "" not recognized!`` response the real device gives +to unknown commands. + +Response text -- including the layout of ``list``/``info``/``help``, the +"Issue the 'clearbuttonabort' (cba) command to re-enable the machine" line +emitted by ``abort``, and the use of the ``ABORTED!`` terminator -- was +captured verbatim from real-device netcat sessions. +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import time +from dataclasses import dataclass, field +from typing import Awaitable, Callable, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Internal handler-control exceptions +# ============================================================================ + + +#: Maximum number of error-stack entries the device dumps as data lines +#: before an ``ERROR!`` terminator. The real firmware caps this at 10 (per +#: the manual's "top 10 errors" wording in the ``errors`` command help). +_ERROR_DUMP_LIMIT = 10 + +#: Standard error code the firmware emits for argument/parsing problems +#: like unknown commands and parameter-count mismatches. +_ERROR_CODE_PARAM = -12 + + +class _MockError(Exception): + """Raised inside a command handler to produce an ``ERROR!`` terminator. + + Each instance carries a ``(message, code)`` pair. The handler dispatcher + formats the message into the real-device wire format + (``Error N: (HH:MM:SS) : ``), pushes it onto the + persistent error stack, and then dumps the last + :data:`_ERROR_DUMP_LIMIT` entries of the stack as data lines before + writing the ``ERROR!`` terminator -- mirroring how the real firmware + responds to any failing command. + """ + + def __init__(self, message: str, code: int = _ERROR_CODE_PARAM): + self.message = message + self.code = code + super().__init__(message) + + +class _MockAborted(Exception): + """Raised inside a command handler to produce an ``ABORTED!`` terminator. + + The real device emits ``ABORTED!`` (not ``ERROR!``) when a motion command + is cancelled by an ``abort``, or when a motion command is issued while the + abort latch is set (i.e. before ``clearbuttonabort``). + """ + + +# ============================================================================ +# Mutable mock device state +# ============================================================================ + + +@dataclass +class MockState: + """In-memory model of the MicroSpin's relevant firmware state.""" + + homed: bool = False + spindle_position: int = 0 + door_position: int = -258 # CENTRIFUGE_DOOR_CLOSED from real settings dump + at_bucket: Optional[int] = None # 1, 2, or None + abort_latched: bool = False + spinning: bool = False # True while a spin task is active + current_motion: Optional[asyncio.Task] = None + errors: List[str] = field(default_factory=list) + next_command_id: int = 1 + # The mock's analogue of the real device's spindle-stopped sensor. False + # while motion is in progress; set back to True when motion settles -- + # *unless* `simulate_low_g_hang` is True, in which case the sensor stays + # False and any subsequent `status` waits forever, reproducing the + # firmware bug we warn about in :meth:`MicroSpinBackend.spin`. + spindle_settled: asyncio.Event = field(default_factory=asyncio.Event) + #: When True, motion handlers refuse to mark the spindle as settled. + simulate_low_g_hang: bool = False + + def __post_init__(self): + self.spindle_settled.set() # idle to start + + def push_error(self, code: int, message: str) -> None: + """Append an entry to the simulated error stack in the firmware's format.""" + ts = time.strftime("%H:%M:%S", time.gmtime()) + self.errors.append(f"Error {len(self.errors) + 1}: ({ts}) {code}: {message}") + + +# ============================================================================ +# Command specification table -- single source of truth for list/info/help +# ============================================================================ + + +@dataclass(frozen=True) +class _CommandSpec: + """Static metadata about one wire command. + + ``description`` is the single-line summary shown by ``list``. + ``params_signature`` is the parameter-line ``info``/``help`` print + immediately under the description (e.g. ``"(No parameters)"`` or + ``"Parameters(1): "``). + ``params_description`` are additional indented lines printed under the + signature in ``info``/``help`` (often a description of each parameter). + """ + + name: str + aliases: tuple # tuple of alias strings (may be empty) + description: str + params_signature: str + params_description: tuple # tuple of extra info lines + + @property + def display_name(self) -> str: + """e.g. ``"homedstatus, hss"`` or ``"home"`` (used by list/info).""" + if not self.aliases: + return self.name + return f"{self.name}, {', '.join(self.aliases)}" + + +# Width of the name+alias column in `list`/`info` output (real device uses 32). +_NAME_COLUMN_WIDTH = 32 + + +# This table drives the responses to `list`, `info`, and `help `. The +# command order matches the real device's output: client/server-side commands +# first, then machine-side commands. +# +# IMPORTANT: any change here is visible on the wire and affects every test +# that scrapes list/info output. +_COMMAND_TABLE: List[_CommandSpec] = [ + _CommandSpec( + name="clearbuttonabort", + aliases=("cba",), + description="Clear abort state.", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="commandstat", + aliases=("cstat",), + description="Gets status of a command.", + params_signature="Parameters(1): ", + params_description=("",), # real device emits a trailing blank info line + ), + _CommandSpec( + name="disconnect", + aliases=("d",), + description="Close the current client's connection.", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="errors", + aliases=("e",), + description="Display the top 10 errors on the error stack.", + params_signature="Parameters(0 - 1): []", + params_description=("Optional parameter specifies the max number of errors to display.",), + ), + _CommandSpec( + name="help", + aliases=(), + description="Displays the parameter information for a specific command.", + params_signature="Parameters(1): ", + params_description=("Where is the name of the command to view information about.",), + ), + _CommandSpec( + name="info", + aliases=("??",), + description="Displays the list of user commands with parameter information.", + params_signature="Parameters(0 - 1): [all]", + params_description=("If 'all' is specified, maintenance commands will be included.",), + ), + _CommandSpec( + name="list", + aliases=("?",), + description="Displays the list of user commands that the server recognizes.", + params_signature="Parameters(0 - 1): [all]", + params_description=("If 'all' is specified, maintenance commands will be included.",), + ), + _CommandSpec( + name="logcommands", + aliases=(), + description="Log all received commands to a file.", + params_signature="Parameters(1): yes|no", + params_description=("Yes will enable logging. No will disable logging.",), + ), + _CommandSpec( + name="version", + aliases=("v",), + description="Return the software version report.", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="whoami", + aliases=(), + description="Get the current client's client number.", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="abort", + aliases=("a",), + description="Stop current machine operation.", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="home", + aliases=(), + description="Homes the system", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="homedstatus", + aliases=("hss",), + description="returns whether the device is in a homed state", + params_signature="(No parameters)", + params_description=(), + ), + _CommandSpec( + name="open", + aliases=(), + description="Open the door and present the specified bucket.", + params_signature="Parameters(1): ", + params_description=(" the bucket number to present",), + ), + _CommandSpec( + name="spin", + aliases=("sp",), + description="Spin the centrifuge.", + params_signature="Parameters(4):