From d01b19b9456ef9cf4c45dc97d8e7fc93ed42f1c7 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 3 Apr 2026 16:54:24 -0700 Subject: [PATCH 01/54] Add v1b1 Micronic rack reading support --- docs/api/pylabrobot.capabilities.rst | 375 ++++++++++++++++++ docs/api/pylabrobot.micronic.rst | 16 + docs/api/pylabrobot.rst | 1 + docs/user_guide/capabilities/index.md | 68 ++++ docs/user_guide/capabilities/rack-reading.md | 45 +++ docs/user_guide/index.md | 1 + docs/user_guide/micronic/index.md | 41 ++ pylabrobot/capabilities/__init__.py | 11 + .../capabilities/rack_reading/__init__.py | 11 + .../capabilities/rack_reading/backend.py | 43 ++ .../capabilities/rack_reading/chatterbox.py | 43 ++ .../capabilities/rack_reading/rack_reader.py | 82 ++++ .../rack_reading/rack_reader_tests.py | 99 +++++ .../capabilities/rack_reading/standard.py | 48 +++ pylabrobot/micronic/__init__.py | 3 + pylabrobot/micronic/code_reader.py | 39 ++ pylabrobot/micronic/http_driver.py | 147 +++++++ pylabrobot/micronic/micronic_tests.py | 124 ++++++ pylabrobot/micronic/rack_reading_backend.py | 143 +++++++ 19 files changed, 1340 insertions(+) create mode 100644 docs/api/pylabrobot.capabilities.rst create mode 100644 docs/api/pylabrobot.micronic.rst create mode 100644 docs/user_guide/capabilities/index.md create mode 100644 docs/user_guide/capabilities/rack-reading.md create mode 100644 docs/user_guide/micronic/index.md create mode 100644 pylabrobot/capabilities/__init__.py create mode 100644 pylabrobot/capabilities/rack_reading/__init__.py create mode 100644 pylabrobot/capabilities/rack_reading/backend.py create mode 100644 pylabrobot/capabilities/rack_reading/chatterbox.py create mode 100644 pylabrobot/capabilities/rack_reading/rack_reader.py create mode 100644 pylabrobot/capabilities/rack_reading/rack_reader_tests.py create mode 100644 pylabrobot/capabilities/rack_reading/standard.py create mode 100644 pylabrobot/micronic/__init__.py create mode 100644 pylabrobot/micronic/code_reader.py create mode 100644 pylabrobot/micronic/http_driver.py create mode 100644 pylabrobot/micronic/micronic_tests.py create mode 100644 pylabrobot/micronic/rack_reading_backend.py diff --git a/docs/api/pylabrobot.capabilities.rst b/docs/api/pylabrobot.capabilities.rst new file mode 100644 index 00000000000..448546c918d --- /dev/null +++ b/docs/api/pylabrobot.capabilities.rst @@ -0,0 +1,375 @@ +.. currentmodule:: pylabrobot.capabilities + +pylabrobot.capabilities package +=============================== + +Base classes for the capability system. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + capability.Capability + capability.CapabilityBackend + capability.BackendParams + + +Temperature Control +------------------- + +.. currentmodule:: pylabrobot.capabilities.temperature_controlling.temperature_controller + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + TemperatureController + TemperatureControllerBackend + + +Shaking +------- + +.. currentmodule:: pylabrobot.capabilities.shaking.shaking + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Shaker + ShakerBackend + + +Fan Control +----------- + +.. currentmodule:: pylabrobot.capabilities.fan_control.fan_control + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Fan + FanBackend + + +Humidity Control +---------------- + +.. currentmodule:: pylabrobot.capabilities.humidity_controlling.humidity_controller + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HumidityController + HumidityControllerBackend + + +Centrifuging +------------ + +.. currentmodule:: pylabrobot.capabilities.centrifuging.centrifuging + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Centrifuge + CentrifugeBackend + + +Sealing +------- + +.. currentmodule:: pylabrobot.capabilities.sealing.sealing + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Sealer + SealerBackend + + +Peeling +------- + +.. currentmodule:: pylabrobot.capabilities.peeling.peeling + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Peeler + PeelerBackend + + +Loading Tray +------------ + +.. currentmodule:: pylabrobot.capabilities.loading_tray.loading_tray + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + LoadingTray + +.. currentmodule:: pylabrobot.capabilities.loading_tray.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + LoadingTrayBackend + + +Tilting +------- + +.. currentmodule:: pylabrobot.capabilities.tilting.tilting + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Tilter + TilterBackend + + +Pumping +------- + +.. currentmodule:: pylabrobot.capabilities.pumping.pumping + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Pump + PumpBackend + PumpCalibration + + +Weighing +-------- + +.. currentmodule:: pylabrobot.capabilities.weighing.weighing + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Scale + ScaleBackend + + +Barcode Scanning +---------------- + +.. currentmodule:: pylabrobot.capabilities.barcode_scanning.barcode_scanning + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BarcodeScanner + BarcodeScannerBackend + + +Rack Reading +------------ + +.. currentmodule:: pylabrobot.capabilities.rack_reading + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + RackReader + RackReaderBackend + RackReaderState + RackReaderTimeoutError + RackScanEntry + RackScanResult + LayoutInfo + + +Microscopy +---------- + +.. currentmodule:: pylabrobot.capabilities.microscopy.microscopy + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Microscopy + MicroscopyBackend + + +Automated Retrieval +------------------- + +.. currentmodule:: pylabrobot.capabilities.automated_retrieval.automated_retrieval + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + AutomatedRetrieval + AutomatedRetrievalBackend + + +Plate Reading - Absorbance +-------------------------- + +.. currentmodule:: pylabrobot.capabilities.plate_reading.absorbance.absorbance + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Absorbance + AbsorbanceBackend + + +Plate Reading - Fluorescence +----------------------------- + +.. currentmodule:: pylabrobot.capabilities.plate_reading.fluorescence.fluorescence + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Fluorescence + FluorescenceBackend + + +Plate Reading - Luminescence +----------------------------- + +.. currentmodule:: pylabrobot.capabilities.plate_reading.luminescence.luminescence + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Luminescence + LuminescenceBackend + + +Devices +------- + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.multidrop_combi + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombi + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.driver + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombiDriver + + +Bulk Dispensing - Peristaltic +----------------------------- + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PeristalticDispensing8 + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PeristalticDispensingBackend8 + + +Bulk Dispensing - Syringe +------------------------- + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.syringe + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SyringeDispensing8 + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SyringeDispensingBackend8 + + +Liquid Handling - PIP (Independent Channels) +-------------------------------------------- + +.. currentmodule:: pylabrobot.capabilities.liquid_handling.pip + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PIP + PIPBackend + + +Liquid Handling - Head96 (96-Channel Head) +------------------------------------------ + +.. currentmodule:: pylabrobot.capabilities.liquid_handling.head96 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Head96 + Head96Backend diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst new file mode 100644 index 00000000000..c0b43495401 --- /dev/null +++ b/docs/api/pylabrobot.micronic.rst @@ -0,0 +1,16 @@ +.. currentmodule:: pylabrobot.micronic + +pylabrobot.micronic package +=========================== + +Micronic Code Reader integration built on the rack-reading capability. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MicronicCodeReader + MicronicHTTPDriver + MicronicRackReaderError + MicronicRackReadingBackend diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 206ff03dd1e..700ef14afcb 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -32,6 +32,7 @@ Manufacturers pylabrobot.kbioscience pylabrobot.kbiosystems pylabrobot.mettler_toledo + pylabrobot.micronic pylabrobot.molecular_devices pylabrobot.qinstruments pylabrobot.sartorius diff --git a/docs/user_guide/capabilities/index.md b/docs/user_guide/capabilities/index.md new file mode 100644 index 00000000000..fb4e0f7ddad --- /dev/null +++ b/docs/user_guide/capabilities/index.md @@ -0,0 +1,68 @@ +# Capabilities + +Capabilities are the building blocks of device functionality in PyLabRobot. Each capability defines a standard interface for a specific type of lab operation (e.g. temperature control, shaking, plate reading), decoupled from any particular hardware. + +A single device can expose multiple capabilities -- and capabilities can be optional (`None` if the hardware doesn't support it) or even duplicated (e.g. a device with two independent arms). For example, a heater-shaker exposes both a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` and a {class}`~pylabrobot.capabilities.shaking.shaking.Shaker`. + +## Architecture + +``` +Device + ├── driver (hardware communication) + └── capabilities + ├── TemperatureController (backend) + ├── Shaker (backend) + └── ... +``` + +Each capability has two layers: a **frontend** and a **backend**. + +### Frontend (the capability class) + +The frontend is what you interact with as a user. It provides: + +- A **stable, hardware-agnostic API** -- the same `set_temperature(37.0)` call works regardless of whether you're using an Inheco ThermoShake or a Hamilton Heater Cooler. +- **Validation** -- checking that arguments are in range, that the device is ready, that preconditions are met (e.g. you can't `wait_for_temperature` without first setting a target). +- **State tracking** -- keeping track of which tips are mounted, what the current tilt angle is, whether the door is open, etc. +- **Convenience methods** -- higher-level operations like `stamp` (aspirate + dispense) or `transfer` (one-to-many) built on top of the primitive backend calls. + +The frontend is the same across all hardware that supports the capability. + +### Backend (the hardware-specific implementation) + +The backend is what talks to the actual hardware. Each capability defines an abstract backend class (e.g. {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureControllerBackend`, {class}`~pylabrobot.capabilities.shaking.shaking.ShakerBackend`) that specifies the methods a hardware driver must implement. + +Backend methods are lower-level and closer to the wire protocol. For example, where the frontend {class}`~pylabrobot.capabilities.shaking.shaking.Shaker`.`shake(speed, duration)` handles timing and auto-stop, the backend only needs to implement `start_shaking(speed)` and `stop_shaking()`. + +To add support for a new piece of hardware, you implement the backend interface for the capabilities it supports. The frontend takes care of the rest. + +All capability methods are `async` and require the parent device to be set up before use (enforced by the `@need_capability_ready` decorator). + +## Available capabilities + +```{toctree} +:maxdepth: 1 + +temperature-control +shaking +fan-control +humidity-control +centrifuging +sealing +peeling +tilting +loading-tray +pumping +weighing +barcode-scanning +rack-reading +microscopy +automated-retrieval +absorbance +fluorescence +luminescence +dispensing/index +pip +head96 +arms +``` diff --git a/docs/user_guide/capabilities/rack-reading.md b/docs/user_guide/capabilities/rack-reading.md new file mode 100644 index 00000000000..050fd6f1df3 --- /dev/null +++ b/docs/user_guide/capabilities/rack-reading.md @@ -0,0 +1,45 @@ +# Rack Reading + +The `rack_reading` capability standardizes rack-scale code readers that trigger a rack scan, +report normalized state while scanning, and return structured per-position scan results. + +Unlike the single-barcode `barcode_scanning` capability, rack reading is job-oriented and returns +the full decoded rack map. + +## Public API + +```python +from pylabrobot.capabilities.rack_reading import RackReader + +result = await reader.scan_rack(timeout=60.0, poll_interval=1.0) +``` + +`scan_rack()` is the main public operation. It triggers the scan, waits internally until the +reader reaches `dataready`, and then returns a `RackScanResult`. + +Lower-level methods are also available: + +- `get_state()` +- `trigger_rack_scan()` +- `trigger_tube_scan()` +- `get_scan_result()` +- `get_rack_id()` +- `get_layouts()` +- `get_current_layout()` +- `set_current_layout(layout)` + +## Example With Micronic + +```python +from pylabrobot.micronic import MicronicCodeReader + +reader = MicronicCodeReader(host="localhost", port=2500) +await reader.setup() + +try: + result = await reader.scan_rack(timeout=90.0, poll_interval=1.0) + print(result.rack_id) + print(result.entries[0].position, result.entries[0].tube_id) +finally: + await reader.stop() +``` diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 0d07f7280bb..f3f798667e2 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -41,6 +41,7 @@ kbioscience/index kbiosystems/index mettler_toledo/index molecular_devices/index +micronic/index qinstruments/index sartorius/index thermo_fisher/index diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md new file mode 100644 index 00000000000..15c8bf8d59c --- /dev/null +++ b/docs/user_guide/micronic/index.md @@ -0,0 +1,41 @@ +# Micronic + +PyLabRobot includes a `v1b1` Micronic integration built on the generic `rack_reading` +capability. + +This integration targets the `IO Monitor` HTTP server exposed by the Micronic Code Reader +Windows application. + +## Supported operations + +- `GET /state` +- `POST /scanbox` +- `POST /scantube` +- `GET /scanresult` +- `GET /rackid` +- `GET /layoutlist` +- `GET /currentlayout` +- `PUT /currentlayout` + +## Example + +```python +from pylabrobot.micronic import MicronicCodeReader + +reader = MicronicCodeReader(host="localhost", port=2500) +await reader.setup() + +try: + result = await reader.scan_rack(timeout=60.0, poll_interval=1.0) + print(result.rack_id) + print(result.entries[0].position, result.entries[0].tube_id) +finally: + await reader.stop() +``` + +## Notes + +- The Micronic server is path-based. Use `POST /scanbox`, not `POST /` with raw text. +- The Micronic application must have the HTTP server enabled in `IO Monitor`. +- The reader only supports one external client at a time. +- `localhost` is typically safer than `127.0.0.1` on the Windows host. diff --git a/pylabrobot/capabilities/__init__.py b/pylabrobot/capabilities/__init__.py new file mode 100644 index 00000000000..d39489932c5 --- /dev/null +++ b/pylabrobot/capabilities/__init__.py @@ -0,0 +1,11 @@ +from .capability import Capability, CapabilityBackend, need_capability_ready +from .rack_reading import ( + LayoutInfo, + RackReader, + RackReaderBackend, + RackReaderError, + RackReaderState, + RackReaderTimeoutError, + RackScanEntry, + RackScanResult, +) diff --git a/pylabrobot/capabilities/rack_reading/__init__.py b/pylabrobot/capabilities/rack_reading/__init__.py new file mode 100644 index 00000000000..f0d7a8d1eb3 --- /dev/null +++ b/pylabrobot/capabilities/rack_reading/__init__.py @@ -0,0 +1,11 @@ +from .backend import RackReaderBackend +from .chatterbox import RackReaderChatterboxBackend +from .rack_reader import RackReader +from .standard import ( + LayoutInfo, + RackReaderError, + RackReaderState, + RackReaderTimeoutError, + RackScanEntry, + RackScanResult, +) diff --git a/pylabrobot/capabilities/rack_reading/backend.py b/pylabrobot/capabilities/rack_reading/backend.py new file mode 100644 index 00000000000..cbbc0fdec57 --- /dev/null +++ b/pylabrobot/capabilities/rack_reading/backend.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend + +from .standard import LayoutInfo, RackReaderState, RackScanResult + + +class RackReaderBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for rack readers that decode position-indexed rack contents.""" + + @abstractmethod + async def get_state(self) -> RackReaderState: + """Return the current rack reader state.""" + + @abstractmethod + async def trigger_rack_scan(self) -> None: + """Initiate a rack-wide scan.""" + + @abstractmethod + async def trigger_tube_scan(self) -> None: + """Initiate a single-tube scan.""" + + @abstractmethod + async def get_scan_result(self) -> RackScanResult: + """Return the most recent rack scan result.""" + + @abstractmethod + async def get_rack_id(self) -> str: + """Return the rack identifier reported by the scanner.""" + + @abstractmethod + async def get_layouts(self) -> list[LayoutInfo]: + """Return supported layouts.""" + + @abstractmethod + async def get_current_layout(self) -> str: + """Return the active layout.""" + + @abstractmethod + async def set_current_layout(self, layout: str) -> None: + """Set the active layout.""" diff --git a/pylabrobot/capabilities/rack_reading/chatterbox.py b/pylabrobot/capabilities/rack_reading/chatterbox.py new file mode 100644 index 00000000000..98e2fc9aeaa --- /dev/null +++ b/pylabrobot/capabilities/rack_reading/chatterbox.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from .backend import RackReaderBackend +from .standard import LayoutInfo, RackReaderState, RackScanEntry, RackScanResult + + +class RackReaderChatterboxBackend(RackReaderBackend): + """Device-free rack-reading backend for tests and examples.""" + + def __init__(self): + self._state = RackReaderState.IDLE + self._layout = "96" + + async def get_state(self) -> RackReaderState: + return self._state + + async def trigger_rack_scan(self) -> None: + self._state = RackReaderState.DATAREADY + + async def trigger_tube_scan(self) -> None: + self._state = RackReaderState.DATAREADY + + async def get_scan_result(self) -> RackScanResult: + return RackScanResult( + rack_id="CHATTERBOX", + date="19700101", + time="000000", + entries=[ + RackScanEntry(position="A01", tube_id="SIMULATED", status="Code OK"), + ], + ) + + async def get_rack_id(self) -> str: + return "CHATTERBOX" + + async def get_layouts(self) -> list[LayoutInfo]: + return [LayoutInfo(name="96"), LayoutInfo(name="48")] + + async def get_current_layout(self) -> str: + return self._layout + + async def set_current_layout(self, layout: str) -> None: + self._layout = layout diff --git a/pylabrobot/capabilities/rack_reading/rack_reader.py b/pylabrobot/capabilities/rack_reading/rack_reader.py new file mode 100644 index 00000000000..1d7a3c57599 --- /dev/null +++ b/pylabrobot/capabilities/rack_reading/rack_reader.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import asyncio +import time + +from pylabrobot.capabilities.capability import Capability, need_capability_ready + +from .backend import RackReaderBackend +from .standard import LayoutInfo, RackReaderState, RackReaderTimeoutError, RackScanResult + + +class RackReader(Capability): + """Rack-reading capability.""" + + def __init__(self, backend: RackReaderBackend): + super().__init__(backend=backend) + self.backend: RackReaderBackend = backend + + @need_capability_ready + async def get_state(self) -> RackReaderState: + return await self.backend.get_state() + + @need_capability_ready + async def trigger_rack_scan(self) -> None: + await self.backend.trigger_rack_scan() + + @need_capability_ready + async def trigger_tube_scan(self) -> None: + await self.backend.trigger_tube_scan() + + @need_capability_ready + async def get_scan_result(self) -> RackScanResult: + return await self.backend.get_scan_result() + + @need_capability_ready + async def get_rack_id(self) -> str: + return await self.backend.get_rack_id() + + @need_capability_ready + async def get_layouts(self) -> list[LayoutInfo]: + return await self.backend.get_layouts() + + @need_capability_ready + async def get_current_layout(self) -> str: + return await self.backend.get_current_layout() + + @need_capability_ready + async def set_current_layout(self, layout: str) -> None: + await self.backend.set_current_layout(layout) + + async def _wait_for_state( + self, + target: RackReaderState, + timeout: float, + poll_interval: float, + ) -> RackReaderState: + deadline = time.monotonic() + timeout + while True: + state = await self.backend.get_state() + if state == target: + return state + if time.monotonic() >= deadline: + raise RackReaderTimeoutError( + f"Timed out waiting for rack reader to reach {target.value}." + ) + await asyncio.sleep(poll_interval) + + @need_capability_ready + async def scan_rack( + self, + timeout: float = 60.0, + poll_interval: float = 1.0, + ) -> RackScanResult: + """Trigger a rack scan and return the completed result.""" + + await self.backend.trigger_rack_scan() + await self._wait_for_state( + target=RackReaderState.DATAREADY, + timeout=timeout, + poll_interval=poll_interval, + ) + return await self.backend.get_scan_result() diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py new file mode 100644 index 00000000000..29316e836e4 --- /dev/null +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -0,0 +1,99 @@ +import unittest + +from pylabrobot.capabilities.rack_reading.backend import RackReaderBackend +from pylabrobot.capabilities.rack_reading.rack_reader import RackReader +from pylabrobot.capabilities.rack_reading.standard import ( + LayoutInfo, + RackReaderState, + RackReaderTimeoutError, + RackScanEntry, + RackScanResult, +) + + +class RecordingRackReaderBackend(RackReaderBackend): + def __init__(self): + self.state = RackReaderState.IDLE + self.calls: list[str] = [] + self.result = RackScanResult( + rack_id="5500135415", + date="20260316", + time="160626", + entries=[ + RackScanEntry(position="A01", tube_id="7518613629", status="Code OK", free_text=""), + ], + ) + + async def get_state(self) -> RackReaderState: + self.calls.append("get_state") + if self.state == RackReaderState.SCANNING: + self.state = RackReaderState.DATAREADY + return self.state + + async def trigger_rack_scan(self) -> None: + self.calls.append("trigger_rack_scan") + self.state = RackReaderState.SCANNING + + async def trigger_tube_scan(self) -> None: + self.calls.append("trigger_tube_scan") + self.state = RackReaderState.SCANNING + + async def get_scan_result(self) -> RackScanResult: + self.calls.append("get_scan_result") + return self.result + + async def get_rack_id(self) -> str: + self.calls.append("get_rack_id") + return self.result.rack_id + + async def get_layouts(self) -> list[LayoutInfo]: + self.calls.append("get_layouts") + return [LayoutInfo(name="96")] + + async def get_current_layout(self) -> str: + self.calls.append("get_current_layout") + return "96" + + async def set_current_layout(self, layout: str) -> None: + self.calls.append(f"set_current_layout:{layout}") + + +class StuckRackReaderBackend(RecordingRackReaderBackend): + async def get_state(self) -> RackReaderState: + self.calls.append("get_state") + return RackReaderState.SCANNING + + +class TestRackReader(unittest.IsolatedAsyncioTestCase): + async def test_scan_rack_triggers_and_returns_result(self): + backend = RecordingRackReaderBackend() + reader = RackReader(backend=backend) + await reader._on_setup() + + result = await reader.scan_rack(timeout=1.0, poll_interval=0.01) + + self.assertEqual(result.rack_id, "5500135415") + self.assertEqual(result.entries[0].position, "A01") + self.assertEqual( + backend.calls[:3], + ["trigger_rack_scan", "get_state", "get_scan_result"], + ) + + async def test_scan_rack_times_out(self): + backend = StuckRackReaderBackend() + reader = RackReader(backend=backend) + await reader._on_setup() + + with self.assertRaises(RackReaderTimeoutError): + await reader.scan_rack(timeout=0.01, poll_interval=0.0) + + async def test_requires_setup(self): + backend = RecordingRackReaderBackend() + reader = RackReader(backend=backend) + + with self.assertRaises(RuntimeError): + await reader.get_state() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/rack_reading/standard.py b/pylabrobot/capabilities/rack_reading/standard.py new file mode 100644 index 00000000000..8be8ae8bdd6 --- /dev/null +++ b/pylabrobot/capabilities/rack_reading/standard.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Optional + + +class RackReaderError(Exception): + """Base exception for rack reader operations.""" + + +class RackReaderTimeoutError(RackReaderError): + """Raised when a rack reader operation times out.""" + + +class RackReaderState(enum.Enum): + """Normalized rack reader states.""" + + IDLE = "idle" + SCANNING = "scanning" + DATAREADY = "dataready" + + +@dataclass +class RackScanEntry: + """One decoded rack position.""" + + position: str + tube_id: Optional[str] + status: str + free_text: str = "" + + +@dataclass +class RackScanResult: + """A decoded rack scan.""" + + rack_id: str + date: str + time: str + entries: list[RackScanEntry] + + +@dataclass +class LayoutInfo: + """One rack layout supported by the reader.""" + + name: str diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py new file mode 100644 index 00000000000..7a9e52daaef --- /dev/null +++ b/pylabrobot/micronic/__init__.py @@ -0,0 +1,3 @@ +from .code_reader import MicronicCodeReader +from .http_driver import MicronicHTTPDriver, MicronicRackReaderError +from .rack_reading_backend import MicronicRackReadingBackend diff --git a/pylabrobot/micronic/code_reader.py b/pylabrobot/micronic/code_reader.py new file mode 100644 index 00000000000..ddd30fb4ad4 --- /dev/null +++ b/pylabrobot/micronic/code_reader.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Optional + +from pylabrobot.capabilities.rack_reading import RackReader +from pylabrobot.device import Device + +from .http_driver import MicronicHTTPDriver +from .rack_reading_backend import MicronicRackReadingBackend + + +class MicronicCodeReader(Device): + """Micronic Code Reader device using the IO Monitor HTTP server.""" + + def __init__( + self, + host: str = "localhost", + port: int = 2500, + timeout: float = 60.0, + poll_interval: float = 1.0, + driver: Optional[MicronicHTTPDriver] = None, + ): + if driver is None: + driver = MicronicHTTPDriver(host=host, port=port, timeout=timeout) + super().__init__(driver=driver) + self.driver: MicronicHTTPDriver = driver + self.default_timeout = timeout + self.default_poll_interval = poll_interval + self.rack_reading = RackReader(backend=MicronicRackReadingBackend(driver)) + # Temporary alias while the consumer code moves to the capability-centric v1b1 surface. + self.rack_reader = self.rack_reading + self._capabilities = [self.rack_reading] + + def serialize(self) -> dict: + return { + **super().serialize(), + "timeout": self.default_timeout, + "poll_interval": self.default_poll_interval, + } diff --git a/pylabrobot/micronic/http_driver.py b/pylabrobot/micronic/http_driver.py new file mode 100644 index 00000000000..1da7ce551fd --- /dev/null +++ b/pylabrobot/micronic/http_driver.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import asyncio +import http.client +import json +import time +from typing import Any, Optional +from urllib import error, parse, request + +from pylabrobot.capabilities.rack_reading.standard import RackReaderError +from pylabrobot.device import Driver + + +class MicronicRackReaderError(RackReaderError): + """Raised when the Micronic HTTP server returns an error.""" + + +class MicronicHTTPDriver(Driver): + """HTTP transport for the Micronic Code Reader IO Monitor server.""" + + def __init__( + self, + host: str = "localhost", + port: int = 2500, + timeout: float = 60.0, + user_agent: str = "curl/8.0", + ): + super().__init__() + self.host = host + self.port = port + self.timeout = timeout + self.user_agent = user_agent + + @property + def base_url(self) -> str: + return f"http://{self.host}:{self.port}" + + async def setup(self) -> None: + return None + + async def stop(self) -> None: + return None + + def serialize(self) -> dict: + return { + **super().serialize(), + "host": self.host, + "port": self.port, + "timeout": self.timeout, + "user_agent": self.user_agent, + } + + async def request( + self, + method: str, + path: str, + data: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + expect_json: bool = True, + ) -> bytes: + return await asyncio.to_thread( + self._request_sync, + method, + path, + data, + headers, + expect_json, + ) + + async def request_json( + self, + method: str, + path: str, + data: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + ) -> Any: + response = await self.request( + method=method, + path=path, + data=data, + headers=headers, + expect_json=True, + ) + try: + return json.loads(response.decode("utf-8")) + except json.JSONDecodeError as exc: + raise MicronicRackReaderError( + f"Micronic server returned non-JSON payload for {method} {path}." + ) from exc + + def _request_sync( + self, + method: str, + path: str, + data: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + expect_json: bool = True, + ) -> bytes: + req_headers = { + "Accept": "application/json" if expect_json else "*/*", + "Connection": "close", + "User-Agent": self.user_agent, + } + if headers is not None: + req_headers.update(headers) + if data is not None: + req_headers["Content-Length"] = str(len(data)) + + req = request.Request( + url=parse.urljoin(self.base_url, path), + data=data, + headers=req_headers, + method=method, + ) + + for attempt in range(3): + try: + with request.urlopen(req, timeout=self.timeout) as response: + return response.read() + except error.HTTPError as exc: + body = exc.read() + raise self._as_micronic_error(body, fallback=f"HTTP {exc.code} for {method} {path}") from exc + except error.URLError as exc: + raise MicronicRackReaderError( + f"Failed to reach Micronic server at {self.base_url}: {exc.reason}" + ) from exc + except (ConnectionResetError, http.client.RemoteDisconnected, OSError) as exc: + if attempt == 2: + raise MicronicRackReaderError( + f"Micronic connection failed for {method} {path}: {exc}" + ) from exc + time.sleep(0.25) + + raise MicronicRackReaderError(f"Micronic request failed for {method} {path}.") + + def _as_micronic_error(self, body: bytes, fallback: str) -> MicronicRackReaderError: + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return MicronicRackReaderError(fallback) + + if isinstance(payload, dict) and "ErrorMsg" in payload: + error_code = payload.get("ErrorCode") + error_msg = payload.get("ErrorMsg") + return MicronicRackReaderError(f"Micronic error {error_code}: {error_msg}") + + return MicronicRackReaderError(fallback) diff --git a/pylabrobot/micronic/micronic_tests.py b/pylabrobot/micronic/micronic_tests.py new file mode 100644 index 00000000000..663292e95d6 --- /dev/null +++ b/pylabrobot/micronic/micronic_tests.py @@ -0,0 +1,124 @@ +import json +import unittest +from unittest.mock import MagicMock, patch + +from pylabrobot.capabilities.rack_reading import RackReaderState +from pylabrobot.micronic import MicronicCodeReader +from pylabrobot.micronic.http_driver import MicronicHTTPDriver, MicronicRackReaderError +from pylabrobot.micronic.rack_reading_backend import MicronicRackReadingBackend + + +class TestMicronicHTTPDriver(unittest.IsolatedAsyncioTestCase): + async def test_request_sync_retries_connection_reset(self): + driver = MicronicHTTPDriver() + response = MagicMock() + response.read.return_value = b'{"state":"idle"}' + response.__enter__.return_value = response + response.__exit__.return_value = False + + with patch( + "pylabrobot.micronic.http_driver.request.urlopen", + side_effect=[ConnectionResetError(104, "reset"), response], + ): + body = driver._request_sync("GET", "/state") + + self.assertEqual(body, b'{"state":"idle"}') + + async def test_http_error_maps_to_backend_error(self): + driver = MicronicHTTPDriver() + err = driver._as_micronic_error( + json.dumps({"ErrorCode": 4, "ErrorMsg": "invalid state"}).encode("utf-8"), + fallback="fallback", + ) + self.assertIsInstance(err, MicronicRackReaderError) + self.assertIn("invalid state", str(err)) + + +class TestMicronicRackReadingBackend(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + super().setUp() + self.driver = MicronicHTTPDriver() + self.backend = MicronicRackReadingBackend(driver=self.driver) + + async def test_on_setup_checks_state(self): + with patch.object(self.backend, "get_state", return_value=RackReaderState.IDLE) as get_state: + await self.backend._on_setup() + get_state.assert_called_once_with() + + async def test_get_state(self): + with patch.object( + self.driver, + "request_json", + return_value={"state": "dataready"}, + ): + state = await self.backend.get_state() + self.assertEqual(state, RackReaderState.DATAREADY) + + async def test_trigger_rack_scan(self): + with patch.object(self.driver, "request", return_value=b"") as request_bytes: + await self.backend.trigger_rack_scan() + request_bytes.assert_called_once_with("POST", "/scanbox", data=b"", expect_json=False) + + async def test_get_scan_result(self): + payload = { + "RackID": "3000756455", + "Date": "20260315", + "Time": "114804", + "Position": ["A01", "A02"], + "TubeID": ["5007377910", "5007377911"], + "Status": ["Code OK", "Code OK"], + "FreeText": ["", ""], + } + with patch.object(self.driver, "request_json", return_value=payload): + result = await self.backend.get_scan_result() + + self.assertEqual(result.rack_id, "3000756455") + self.assertEqual(result.entries[0].position, "A01") + self.assertEqual(result.entries[1].tube_id, "5007377911") + + async def test_get_layouts_dict_payload(self): + with patch.object(self.driver, "request_json", return_value={"Layout": ["8x12", "6x8"]}): + layouts = await self.backend.get_layouts() + self.assertEqual([layout.name for layout in layouts], ["8x12", "6x8"]) + + async def test_set_current_layout(self): + with patch.object(self.driver, "request", return_value=b"") as request_bytes: + await self.backend.set_current_layout("96") + request_bytes.assert_called_once_with( + "PUT", + "/currentlayout", + data=b'{"Layout": "96"}', + headers={"Content-Type": "application/json; charset=utf-8"}, + expect_json=False, + ) + + +class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): + async def test_device_exposes_rack_reading_capability(self): + reader = MicronicCodeReader(timeout=12.0, poll_interval=0.25) + with patch.object( + reader.rack_reading.backend, + "get_state", + return_value=RackReaderState.IDLE, + ): + await reader.setup() + try: + self.assertIs(reader.rack_reader, reader.rack_reading) + with patch.object( + reader.rack_reading, + "scan_rack", + return_value=MagicMock(rack_id="5500135415"), + ) as scan_rack: + result = await reader.rack_reading.scan_rack( + timeout=reader.default_timeout, + poll_interval=reader.default_poll_interval, + ) + finally: + await reader.stop() + + self.assertEqual(result.rack_id, "5500135415") + scan_rack.assert_called_once_with(timeout=12.0, poll_interval=0.25) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/micronic/rack_reading_backend.py b/pylabrobot/micronic/rack_reading_backend.py new file mode 100644 index 00000000000..10b4e54ba17 --- /dev/null +++ b/pylabrobot/micronic/rack_reading_backend.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import json +from typing import Any + +from pylabrobot.capabilities.rack_reading import ( + LayoutInfo, + RackReaderBackend, + RackReaderState, + RackScanEntry, + RackScanResult, +) + +from .http_driver import MicronicHTTPDriver, MicronicRackReaderError + + +class MicronicRackReadingBackend(RackReaderBackend): + """Rack-reading backend for the Micronic Code Reader HTTP server.""" + + def __init__(self, driver: MicronicHTTPDriver): + super().__init__() + self.driver = driver + + async def _on_setup(self): + await self.get_state() + + async def get_state(self) -> RackReaderState: + payload = await self.driver.request_json("GET", "/state") + state = payload.get("state") + if not isinstance(state, str): + raise MicronicRackReaderError("Micronic server response did not contain a valid state.") + try: + return RackReaderState(state) + except ValueError as exc: + raise MicronicRackReaderError(f"Unknown Micronic state: {state}") from exc + + async def trigger_rack_scan(self) -> None: + await self.driver.request("POST", "/scanbox", data=b"", expect_json=False) + + async def trigger_tube_scan(self) -> None: + await self.driver.request("POST", "/scantube", data=b"", expect_json=False) + + async def get_scan_result(self) -> RackScanResult: + payload = await self.driver.request_json("GET", "/scanresult") + return self._parse_scan_result(payload) + + async def get_rack_id(self) -> str: + payload = await self.driver.request_json("GET", "/rackid") + + if isinstance(payload, dict): + for key in ("RackID", "rackid", "rack_id"): + value = payload.get(key) + if isinstance(value, str): + return value + + raise MicronicRackReaderError("Micronic rack ID response had an unexpected shape.") + + async def get_layouts(self) -> list[LayoutInfo]: + payload = await self.driver.request_json("GET", "/layoutlist") + + if isinstance(payload, list): + return [LayoutInfo(name=str(item)) for item in payload] + + if isinstance(payload, dict): + for key in ("Layout", "layouts", "layoutlist", "data"): + value = payload.get(key) + if isinstance(value, list): + return [LayoutInfo(name=str(item)) for item in value] + + raise MicronicRackReaderError("Micronic layout list response had an unexpected shape.") + + async def get_current_layout(self) -> str: + payload = await self.driver.request_json("GET", "/currentlayout") + + if isinstance(payload, str): + return payload + + if isinstance(payload, dict): + for key in ("Layout", "layout", "currentlayout", "name"): + value = payload.get(key) + if isinstance(value, str): + return value + + raise MicronicRackReaderError("Micronic current layout response had an unexpected shape.") + + async def set_current_layout(self, layout: str) -> None: + await self.driver.request( + "PUT", + "/currentlayout", + data=json.dumps({"Layout": layout}).encode("utf-8"), + headers={"Content-Type": "application/json; charset=utf-8"}, + expect_json=False, + ) + + def _parse_scan_result(self, payload: dict[str, Any]) -> RackScanResult: + positions = self._get_list(payload, "Position") + tube_ids = self._get_list(payload, "TubeID") + statuses = self._get_list(payload, "Status") + free_texts = self._get_list(payload, "FreeText") + + if not positions: + raise MicronicRackReaderError("Micronic scan result did not include any positions.") + + entries: list[RackScanEntry] = [] + for idx, position in enumerate(positions): + tube_id = self._get_optional_item(tube_ids, idx) + entries.append( + RackScanEntry( + position=str(position), + tube_id=None if tube_id in (None, "") else str(tube_id), + status=str(self._get_required_item(statuses, idx, "Status")), + free_text=str(self._get_optional_item(free_texts, idx) or ""), + ) + ) + + rack_id = payload.get("RackID") + date = payload.get("Date") + time = payload.get("Time") + if not isinstance(rack_id, str) or not isinstance(date, str) or not isinstance(time, str): + raise MicronicRackReaderError("Micronic scan result did not include RackID/Date/Time.") + + return RackScanResult(rack_id=rack_id, date=date, time=time, entries=entries) + + def _get_list(self, payload: dict[str, Any], key: str) -> list[Any]: + value = payload.get(key) + if value is None: + return [] + if not isinstance(value, list): + raise MicronicRackReaderError(f"Micronic field {key} was not a list.") + return value + + def _get_required_item(self, items: list[Any], index: int, field_name: str) -> Any: + try: + return items[index] + except IndexError as exc: + raise MicronicRackReaderError( + f"Micronic field {field_name} was missing an item for position index {index}." + ) from exc + + def _get_optional_item(self, items: list[Any], index: int) -> Any: + if index >= len(items): + return None + return items[index] From cf37a3193efb2e217a11d395a54ff7a90fc22871 Mon Sep 17 00:00:00 2001 From: Alex Godfrey <42598293+alexjamesgodfrey@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:44:55 -0700 Subject: [PATCH 02/54] Add rack ID scan helper to rack reader capability --- .../capabilities/rack_reading/rack_reader.py | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/pylabrobot/capabilities/rack_reading/rack_reader.py b/pylabrobot/capabilities/rack_reading/rack_reader.py index 1d7a3c57599..ceff82787c1 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader.py @@ -66,17 +66,39 @@ async def _wait_for_state( await asyncio.sleep(poll_interval) @need_capability_ready - async def scan_rack( + async def wait_for_data_ready( self, timeout: float = 60.0, poll_interval: float = 1.0, - ) -> RackScanResult: - """Trigger a rack scan and return the completed result.""" + ) -> RackReaderState: + """Wait until the reader reports that scan data is ready.""" - await self.backend.trigger_rack_scan() - await self._wait_for_state( + return await self._wait_for_state( target=RackReaderState.DATAREADY, timeout=timeout, poll_interval=poll_interval, ) + + @need_capability_ready + async def scan_rack( + self, + timeout: float = 60.0, + poll_interval: float = 1.0, + ) -> RackScanResult: + """Trigger a rack scan and return the completed result.""" + + await self.backend.trigger_rack_scan() + await self.wait_for_data_ready(timeout=timeout, poll_interval=poll_interval) return await self.backend.get_scan_result() + + @need_capability_ready + async def scan_rack_id( + self, + timeout: float = 60.0, + poll_interval: float = 1.0, + ) -> str: + """Trigger a rack-ID-only scan and return the completed rack identifier.""" + + await self.backend.trigger_tube_scan() + await self.wait_for_data_ready(timeout=timeout, poll_interval=poll_interval) + return await self.backend.get_rack_id() From c049a5f178bc7350da064b85ed05ee66512733ec Mon Sep 17 00:00:00 2001 From: Alex Godfrey <42598293+alexjamesgodfrey@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:45:11 -0700 Subject: [PATCH 03/54] Test rack ID scan helper --- .../capabilities/rack_reading/rack_reader_tests.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index 29316e836e4..4647169c157 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -79,6 +79,19 @@ async def test_scan_rack_triggers_and_returns_result(self): ["trigger_rack_scan", "get_state", "get_scan_result"], ) + async def test_scan_rack_id_triggers_tube_scan_and_returns_rack_id(self): + backend = RecordingRackReaderBackend() + reader = RackReader(backend=backend) + await reader._on_setup() + + rack_id = await reader.scan_rack_id(timeout=1.0, poll_interval=0.01) + + self.assertEqual(rack_id, "5500135415") + self.assertEqual( + backend.calls[:3], + ["trigger_tube_scan", "get_state", "get_rack_id"], + ) + async def test_scan_rack_times_out(self): backend = StuckRackReaderBackend() reader = RackReader(backend=backend) From a92a4bfcbd1b5d31a7e63052c8c9fcc322a24ce7 Mon Sep 17 00:00:00 2001 From: Alex Godfrey <42598293+alexjamesgodfrey@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:45:34 -0700 Subject: [PATCH 04/54] Test Micronic rack ID only scan surface --- pylabrobot/micronic/micronic_tests.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/pylabrobot/micronic/micronic_tests.py b/pylabrobot/micronic/micronic_tests.py index 663292e95d6..be8ee91bc74 100644 --- a/pylabrobot/micronic/micronic_tests.py +++ b/pylabrobot/micronic/micronic_tests.py @@ -119,6 +119,30 @@ async def test_device_exposes_rack_reading_capability(self): self.assertEqual(result.rack_id, "5500135415") scan_rack.assert_called_once_with(timeout=12.0, poll_interval=0.25) + async def test_device_rack_reading_capability_can_scan_rack_id_only(self): + reader = MicronicCodeReader(timeout=12.0, poll_interval=0.25) + with patch.object( + reader.rack_reading.backend, + "get_state", + return_value=RackReaderState.IDLE, + ): + await reader.setup() + try: + with patch.object( + reader.rack_reading, + "scan_rack_id", + return_value="5500135415", + ) as scan_rack_id: + rack_id = await reader.rack_reading.scan_rack_id( + timeout=reader.default_timeout, + poll_interval=reader.default_poll_interval, + ) + finally: + await reader.stop() + + self.assertEqual(rack_id, "5500135415") + scan_rack_id.assert_called_once_with(timeout=12.0, poll_interval=0.25) + if __name__ == "__main__": unittest.main() From cad63e4c67a9d67cfa658ba520c23685ebf0d412 Mon Sep 17 00:00:00 2001 From: Alex Godfrey <42598293+alexjamesgodfrey@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:45:43 -0700 Subject: [PATCH 05/54] Document Micronic rack reading capability usage --- docs/user_guide/micronic/index.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index 15c8bf8d59c..8e6a7350452 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -26,13 +26,19 @@ reader = MicronicCodeReader(host="localhost", port=2500) await reader.setup() try: - result = await reader.scan_rack(timeout=60.0, poll_interval=1.0) + result = await reader.rack_reading.scan_rack(timeout=60.0, poll_interval=1.0) print(result.rack_id) print(result.entries[0].position, result.entries[0].tube_id) finally: await reader.stop() ``` +To retry only the rack barcode without repeating a full rack scan: + +```python +rack_id = await reader.rack_reading.scan_rack_id(timeout=30.0, poll_interval=1.0) +``` + ## Notes - The Micronic server is path-based. Use `POST /scanbox`, not `POST /` with raw text. From 061f5b969278a6edfe070b81ea3f788f6d1a6aa7 Mon Sep 17 00:00:00 2001 From: Alex Godfrey <42598293+alexjamesgodfrey@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:45:53 -0700 Subject: [PATCH 06/54] Document rack ID only scan capability --- docs/user_guide/capabilities/rack-reading.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/user_guide/capabilities/rack-reading.md b/docs/user_guide/capabilities/rack-reading.md index 050fd6f1df3..ca269964058 100644 --- a/docs/user_guide/capabilities/rack-reading.md +++ b/docs/user_guide/capabilities/rack-reading.md @@ -17,11 +17,16 @@ result = await reader.scan_rack(timeout=60.0, poll_interval=1.0) `scan_rack()` is the main public operation. It triggers the scan, waits internally until the reader reaches `dataready`, and then returns a `RackScanResult`. +`scan_rack_id()` triggers only the rack barcode scan, waits for `dataready`, and returns the +reader-reported rack ID. + Lower-level methods are also available: - `get_state()` +- `wait_for_data_ready()` - `trigger_rack_scan()` - `trigger_tube_scan()` +- `scan_rack_id()` - `get_scan_result()` - `get_rack_id()` - `get_layouts()` @@ -37,7 +42,7 @@ reader = MicronicCodeReader(host="localhost", port=2500) await reader.setup() try: - result = await reader.scan_rack(timeout=90.0, poll_interval=1.0) + result = await reader.rack_reading.scan_rack(timeout=90.0, poll_interval=1.0) print(result.rack_id) print(result.entries[0].position, result.entries[0].tube_id) finally: From 96e56f66ef371c498f0559a2b24e43bbe12a2537 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 10:50:01 -0700 Subject: [PATCH 07/54] Update Micronic driver setup for current v1b1 --- pylabrobot/micronic/http_driver.py | 3 ++- pylabrobot/micronic/rack_reading_backend.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pylabrobot/micronic/http_driver.py b/pylabrobot/micronic/http_driver.py index 1da7ce551fd..cf38ea58403 100644 --- a/pylabrobot/micronic/http_driver.py +++ b/pylabrobot/micronic/http_driver.py @@ -7,6 +7,7 @@ from typing import Any, Optional from urllib import error, parse, request +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.rack_reading.standard import RackReaderError from pylabrobot.device import Driver @@ -35,7 +36,7 @@ def __init__( def base_url(self) -> str: return f"http://{self.host}:{self.port}" - async def setup(self) -> None: + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: return None async def stop(self) -> None: diff --git a/pylabrobot/micronic/rack_reading_backend.py b/pylabrobot/micronic/rack_reading_backend.py index 10b4e54ba17..55eefbd517c 100644 --- a/pylabrobot/micronic/rack_reading_backend.py +++ b/pylabrobot/micronic/rack_reading_backend.py @@ -1,8 +1,9 @@ from __future__ import annotations import json -from typing import Any +from typing import Any, Optional +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.rack_reading import ( LayoutInfo, RackReaderBackend, @@ -21,7 +22,7 @@ def __init__(self, driver: MicronicHTTPDriver): super().__init__() self.driver = driver - async def _on_setup(self): + async def _on_setup(self, backend_params: Optional[BackendParams] = None): await self.get_state() async def get_state(self) -> RackReaderState: From c1864a3b2a18cc09ee2f2055ff7aeb1054871703 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 13:07:49 -0700 Subject: [PATCH 08/54] Split Micronic single-tube scanning into barcode_scanning --- docs/api/pylabrobot.micronic.rst | 5 +- docs/user_guide/capabilities/rack-reading.md | 5 - docs/user_guide/micronic/index.md | 28 ++-- .../barcode_scanning_tests.py | 49 ++++++ .../capabilities/rack_reading/backend.py | 4 - .../capabilities/rack_reading/chatterbox.py | 3 - .../capabilities/rack_reading/rack_reader.py | 32 ++-- .../rack_reading/rack_reader_tests.py | 39 ++++- pylabrobot/micronic/__init__.py | 5 +- .../micronic/barcode_scanning_backend.py | 142 +++++++++++++++++ pylabrobot/micronic/code_reader.py | 11 +- pylabrobot/micronic/http_driver.py | 28 ++-- pylabrobot/micronic/micronic_tests.py | 147 +++++++++++++++++- pylabrobot/micronic/rack_reading_backend.py | 55 +++++-- 14 files changed, 478 insertions(+), 75 deletions(-) create mode 100644 pylabrobot/capabilities/barcode_scanning/barcode_scanning_tests.py create mode 100644 pylabrobot/micronic/barcode_scanning_backend.py diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index c0b43495401..a1d0e423102 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -3,7 +3,7 @@ pylabrobot.micronic package =========================== -Micronic Code Reader integration built on the rack-reading capability. +Micronic Code Reader integration built on the rack-reading and barcode-scanning capabilities. .. autosummary:: :toctree: _autosummary @@ -12,5 +12,8 @@ Micronic Code Reader integration built on the rack-reading capability. MicronicCodeReader MicronicHTTPDriver + MicronicError + MicronicBarcodeScannerBackend + MicronicBarcodeScannerError MicronicRackReaderError MicronicRackReadingBackend diff --git a/docs/user_guide/capabilities/rack-reading.md b/docs/user_guide/capabilities/rack-reading.md index ca269964058..3ae6350af66 100644 --- a/docs/user_guide/capabilities/rack-reading.md +++ b/docs/user_guide/capabilities/rack-reading.md @@ -17,16 +17,11 @@ result = await reader.scan_rack(timeout=60.0, poll_interval=1.0) `scan_rack()` is the main public operation. It triggers the scan, waits internally until the reader reaches `dataready`, and then returns a `RackScanResult`. -`scan_rack_id()` triggers only the rack barcode scan, waits for `dataready`, and returns the -reader-reported rack ID. - Lower-level methods are also available: - `get_state()` - `wait_for_data_ready()` - `trigger_rack_scan()` -- `trigger_tube_scan()` -- `scan_rack_id()` - `get_scan_result()` - `get_rack_id()` - `get_layouts()` diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index 8e6a7350452..c93feefca02 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -1,22 +1,31 @@ # Micronic PyLabRobot includes a `v1b1` Micronic integration built on the generic `rack_reading` -capability. +and `barcode_scanning` capabilities. This integration targets the `IO Monitor` HTTP server exposed by the Micronic Code Reader Windows application. ## Supported operations +Rack reading: + - `GET /state` - `POST /scanbox` -- `POST /scantube` - `GET /scanresult` - `GET /rackid` - `GET /layoutlist` - `GET /currentlayout` - `PUT /currentlayout` +Single-tube barcode scanning: + +- `GET /state` +- `POST /scantube` +- `GET /scanresult` +- `GET /rackid` as a compatibility fallback for server variants that expose the decoded + tube value there + ## Example ```python @@ -26,19 +35,16 @@ reader = MicronicCodeReader(host="localhost", port=2500) await reader.setup() try: - result = await reader.rack_reading.scan_rack(timeout=60.0, poll_interval=1.0) - print(result.rack_id) - print(result.entries[0].position, result.entries[0].tube_id) + rack_result = await reader.rack_reading.scan_rack(timeout=60.0, poll_interval=1.0) + print(rack_result.rack_id) + print(rack_result.entries[0].position, rack_result.entries[0].tube_id) + + barcode = await reader.barcode_scanning.scan() + print(barcode.data) finally: await reader.stop() ``` -To retry only the rack barcode without repeating a full rack scan: - -```python -rack_id = await reader.rack_reading.scan_rack_id(timeout=30.0, poll_interval=1.0) -``` - ## Notes - The Micronic server is path-based. Use `POST /scanbox`, not `POST /` with raw text. diff --git a/pylabrobot/capabilities/barcode_scanning/barcode_scanning_tests.py b/pylabrobot/capabilities/barcode_scanning/barcode_scanning_tests.py new file mode 100644 index 00000000000..87cff10990f --- /dev/null +++ b/pylabrobot/capabilities/barcode_scanning/barcode_scanning_tests.py @@ -0,0 +1,49 @@ +import unittest + +from pylabrobot.capabilities.barcode_scanning.backend import BarcodeScannerBackend +from pylabrobot.capabilities.barcode_scanning.barcode_scanning import BarcodeScanner +from pylabrobot.capabilities.barcode_scanning.chatterbox import BarcodeScannerChatterboxBackend +from pylabrobot.resources.barcode import Barcode + + +class RecordingBarcodeScannerBackend(BarcodeScannerBackend): + def __init__(self, barcode: str = "TEST-123"): + self.barcode = barcode + self.calls = 0 + + async def scan_barcode(self) -> Barcode: + self.calls += 1 + return Barcode(data=self.barcode, symbology="Data Matrix", position_on_resource="bottom") + + +class TestBarcodeScanner(unittest.IsolatedAsyncioTestCase): + async def test_scan_returns_barcode(self): + backend = RecordingBarcodeScannerBackend() + scanner = BarcodeScanner(backend=backend) + await scanner._on_setup() + + barcode = await scanner.scan() + + self.assertEqual(backend.calls, 1) + self.assertEqual(barcode.data, "TEST-123") + self.assertEqual(barcode.symbology, "Data Matrix") + self.assertEqual(barcode.position_on_resource, "bottom") + + async def test_scan_requires_setup(self): + backend = RecordingBarcodeScannerBackend() + scanner = BarcodeScanner(backend=backend) + + with self.assertRaises(RuntimeError): + await scanner.scan() + + async def test_chatterbox_backend(self): + scanner = BarcodeScanner(backend=BarcodeScannerChatterboxBackend(barcode="CHATTERBOX-XYZ")) + await scanner._on_setup() + + barcode = await scanner.scan() + + self.assertEqual(barcode.data, "CHATTERBOX-XYZ") + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/rack_reading/backend.py b/pylabrobot/capabilities/rack_reading/backend.py index cbbc0fdec57..ed6eab11527 100644 --- a/pylabrobot/capabilities/rack_reading/backend.py +++ b/pylabrobot/capabilities/rack_reading/backend.py @@ -18,10 +18,6 @@ async def get_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: """Initiate a rack-wide scan.""" - @abstractmethod - async def trigger_tube_scan(self) -> None: - """Initiate a single-tube scan.""" - @abstractmethod async def get_scan_result(self) -> RackScanResult: """Return the most recent rack scan result.""" diff --git a/pylabrobot/capabilities/rack_reading/chatterbox.py b/pylabrobot/capabilities/rack_reading/chatterbox.py index 98e2fc9aeaa..2282070ba16 100644 --- a/pylabrobot/capabilities/rack_reading/chatterbox.py +++ b/pylabrobot/capabilities/rack_reading/chatterbox.py @@ -17,9 +17,6 @@ async def get_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: self._state = RackReaderState.DATAREADY - async def trigger_tube_scan(self) -> None: - self._state = RackReaderState.DATAREADY - async def get_scan_result(self) -> RackScanResult: return RackScanResult( rack_id="CHATTERBOX", diff --git a/pylabrobot/capabilities/rack_reading/rack_reader.py b/pylabrobot/capabilities/rack_reading/rack_reader.py index ceff82787c1..3c6eabe6e0c 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader.py @@ -24,10 +24,6 @@ async def get_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: await self.backend.trigger_rack_scan() - @need_capability_ready - async def trigger_tube_scan(self) -> None: - await self.backend.trigger_tube_scan() - @need_capability_ready async def get_scan_result(self) -> RackScanResult: return await self.backend.get_scan_result() @@ -87,18 +83,22 @@ async def scan_rack( ) -> RackScanResult: """Trigger a rack scan and return the completed result.""" + initial_state = await self.backend.get_state() await self.backend.trigger_rack_scan() - await self.wait_for_data_ready(timeout=timeout, poll_interval=poll_interval) - return await self.backend.get_scan_result() - @need_capability_ready - async def scan_rack_id( - self, - timeout: float = 60.0, - poll_interval: float = 1.0, - ) -> str: - """Trigger a rack-ID-only scan and return the completed rack identifier.""" + require_state_change = initial_state == RackReaderState.DATAREADY + deadline = time.monotonic() + timeout + while True: + state = await self.backend.get_state() + if state != RackReaderState.DATAREADY: + require_state_change = False + elif not require_state_change: + break - await self.backend.trigger_tube_scan() - await self.wait_for_data_ready(timeout=timeout, poll_interval=poll_interval) - return await self.backend.get_rack_id() + if time.monotonic() >= deadline: + raise RackReaderTimeoutError( + f"Timed out waiting for rack reader to reach {RackReaderState.DATAREADY.value}." + ) + await asyncio.sleep(poll_interval) + + return await self.backend.get_scan_result() diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index 4647169c157..5382e3684cc 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -34,10 +34,6 @@ async def trigger_rack_scan(self) -> None: self.calls.append("trigger_rack_scan") self.state = RackReaderState.SCANNING - async def trigger_tube_scan(self) -> None: - self.calls.append("trigger_tube_scan") - self.state = RackReaderState.SCANNING - async def get_scan_result(self) -> RackScanResult: self.calls.append("get_scan_result") return self.result @@ -64,6 +60,26 @@ async def get_state(self) -> RackReaderState: return RackReaderState.SCANNING +class StaleDataReadyRackReaderBackend(RecordingRackReaderBackend): + def __init__(self): + super().__init__() + self.state = RackReaderState.DATAREADY + self._states_after_trigger = [ + RackReaderState.DATAREADY, + RackReaderState.SCANNING, + RackReaderState.DATAREADY, + ] + + async def trigger_rack_scan(self) -> None: + self.calls.append("trigger_rack_scan") + + async def get_state(self) -> RackReaderState: + self.calls.append("get_state") + if self._states_after_trigger: + return self._states_after_trigger.pop(0) + return RackReaderState.DATAREADY + + class TestRackReader(unittest.IsolatedAsyncioTestCase): async def test_scan_rack_triggers_and_returns_result(self): backend = RecordingRackReaderBackend() @@ -76,7 +92,20 @@ async def test_scan_rack_triggers_and_returns_result(self): self.assertEqual(result.entries[0].position, "A01") self.assertEqual( backend.calls[:3], - ["trigger_rack_scan", "get_state", "get_scan_result"], + ["get_state", "trigger_rack_scan", "get_state"], + ) + + async def test_scan_rack_waits_for_new_dataready_cycle(self): + backend = StaleDataReadyRackReaderBackend() + reader = RackReader(backend=backend) + await reader._on_setup() + + result = await reader.scan_rack(timeout=1.0, poll_interval=0.0) + + self.assertEqual(result.rack_id, "5500135415") + self.assertEqual( + backend.calls, + ["get_state", "trigger_rack_scan", "get_state", "get_state", "get_scan_result"], ) async def test_scan_rack_id_triggers_tube_scan_and_returns_rack_id(self): diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index 7a9e52daaef..2df0553033d 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -1,3 +1,4 @@ +from .barcode_scanning_backend import MicronicBarcodeScannerBackend, MicronicBarcodeScannerError from .code_reader import MicronicCodeReader -from .http_driver import MicronicHTTPDriver, MicronicRackReaderError -from .rack_reading_backend import MicronicRackReadingBackend +from .http_driver import MicronicError, MicronicHTTPDriver +from .rack_reading_backend import MicronicRackReaderError, MicronicRackReadingBackend diff --git a/pylabrobot/micronic/barcode_scanning_backend.py b/pylabrobot/micronic/barcode_scanning_backend.py new file mode 100644 index 00000000000..e959e8b162d --- /dev/null +++ b/pylabrobot/micronic/barcode_scanning_backend.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any, Optional + +from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend, BarcodeScannerError +from pylabrobot.capabilities.rack_reading import RackReaderState +from pylabrobot.resources.barcode import Barcode + +from .http_driver import MicronicError, MicronicHTTPDriver + + +class MicronicBarcodeScannerError(MicronicError, BarcodeScannerError): + """Raised when Micronic single-tube barcode scanning fails.""" + + +class MicronicBarcodeScannerBackend(BarcodeScannerBackend): + """Single-tube barcode-scanning backend for the Micronic Code Reader HTTP server.""" + + def __init__( + self, + driver: MicronicHTTPDriver, + timeout: float = 60.0, + poll_interval: float = 1.0, + ): + super().__init__() + self.driver = driver + self.timeout = timeout + self.poll_interval = poll_interval + + async def _on_setup(self): + await self._get_state() + + async def scan_barcode(self) -> Barcode: + initial_state = await self._get_state() + await self._request("POST", "/scantube", data=b"", expect_json=False) + await self._wait_for_dataready(initial_state=initial_state) + data = await self._read_tube_barcode() + return Barcode(data=data, symbology="Data Matrix", position_on_resource="bottom") + + async def _get_state(self) -> RackReaderState: + payload = await self._request_json("GET", "/state") + state = payload.get("state") + if not isinstance(state, str): + raise MicronicBarcodeScannerError("Micronic server response did not contain a valid state.") + try: + return RackReaderState(state) + except ValueError as exc: + raise MicronicBarcodeScannerError(f"Unknown Micronic state: {state}") from exc + + async def _wait_for_dataready(self, initial_state: RackReaderState) -> None: + require_state_change = initial_state == RackReaderState.DATAREADY + deadline = time.monotonic() + self.timeout + + while True: + state = await self._get_state() + if state != RackReaderState.DATAREADY: + require_state_change = False + elif not require_state_change: + return + + if time.monotonic() >= deadline: + raise MicronicBarcodeScannerError( + f"Timed out waiting for barcode scan to reach {RackReaderState.DATAREADY.value}." + ) + await asyncio.sleep(self.poll_interval) + + async def _read_tube_barcode(self) -> str: + scan_result_payload = await self._request_json("GET", "/scanresult") + barcode = self._extract_single_tube_barcode(scan_result_payload) + if barcode is not None: + return barcode + + rack_id_payload = await self._request_json("GET", "/rackid") + barcode = self._extract_named_barcode( + rack_id_payload, + keys=("RackID", "rackid", "rack_id", "Barcode", "barcode", "Code", "code"), + ) + if barcode is not None: + return barcode + + raise MicronicBarcodeScannerError( + "Micronic single-tube scan result had an unexpected shape." + ) + + async def _request_json( + self, + method: str, + path: str, + data: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + ) -> Any: + try: + return await self.driver.request_json(method, path, data=data, headers=headers) + except MicronicError as exc: + raise MicronicBarcodeScannerError(str(exc)) from exc + + async def _request( + self, + method: str, + path: str, + data: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + expect_json: bool = True, + ) -> bytes: + try: + return await self.driver.request( + method, + path, + data=data, + headers=headers, + expect_json=expect_json, + ) + except MicronicError as exc: + raise MicronicBarcodeScannerError(str(exc)) from exc + + def _extract_single_tube_barcode(self, payload: Any) -> Optional[str]: + if isinstance(payload, str) and payload: + return payload + + return self._extract_named_barcode( + payload, + keys=("TubeID", "tubeid", "tube_id", "Barcode", "barcode", "Code", "code", "Data", "data"), + ) + + def _extract_named_barcode(self, payload: Any, keys: tuple[str, ...]) -> Optional[str]: + if not isinstance(payload, dict): + return None + + for key in keys: + barcode = self._coerce_single_barcode(payload.get(key)) + if barcode is not None: + return barcode + return None + + def _coerce_single_barcode(self, value: Any) -> Optional[str]: + if isinstance(value, str): + return value or None + if isinstance(value, list) and len(value) == 1 and value[0] not in (None, ""): + return str(value[0]) + return None diff --git a/pylabrobot/micronic/code_reader.py b/pylabrobot/micronic/code_reader.py index ddd30fb4ad4..cdae3b05903 100644 --- a/pylabrobot/micronic/code_reader.py +++ b/pylabrobot/micronic/code_reader.py @@ -2,9 +2,11 @@ from typing import Optional +from pylabrobot.capabilities.barcode_scanning import BarcodeScanner from pylabrobot.capabilities.rack_reading import RackReader from pylabrobot.device import Device +from .barcode_scanning_backend import MicronicBarcodeScannerBackend from .http_driver import MicronicHTTPDriver from .rack_reading_backend import MicronicRackReadingBackend @@ -27,9 +29,16 @@ def __init__( self.default_timeout = timeout self.default_poll_interval = poll_interval self.rack_reading = RackReader(backend=MicronicRackReadingBackend(driver)) + self.barcode_scanning = BarcodeScanner( + backend=MicronicBarcodeScannerBackend( + driver, + timeout=timeout, + poll_interval=poll_interval, + ) + ) # Temporary alias while the consumer code moves to the capability-centric v1b1 surface. self.rack_reader = self.rack_reading - self._capabilities = [self.rack_reading] + self._capabilities = [self.rack_reading, self.barcode_scanning] def serialize(self) -> dict: return { diff --git a/pylabrobot/micronic/http_driver.py b/pylabrobot/micronic/http_driver.py index cf38ea58403..19e38da577f 100644 --- a/pylabrobot/micronic/http_driver.py +++ b/pylabrobot/micronic/http_driver.py @@ -8,11 +8,10 @@ from urllib import error, parse, request from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.capabilities.rack_reading.standard import RackReaderError from pylabrobot.device import Driver -class MicronicRackReaderError(RackReaderError): +class MicronicError(Exception): """Raised when the Micronic HTTP server returns an error.""" @@ -85,7 +84,7 @@ async def request_json( try: return json.loads(response.decode("utf-8")) except json.JSONDecodeError as exc: - raise MicronicRackReaderError( + raise MicronicError( f"Micronic server returned non-JSON payload for {method} {path}." ) from exc @@ -122,27 +121,32 @@ def _request_sync( body = exc.read() raise self._as_micronic_error(body, fallback=f"HTTP {exc.code} for {method} {path}") from exc except error.URLError as exc: - raise MicronicRackReaderError( - f"Failed to reach Micronic server at {self.base_url}: {exc.reason}" - ) from exc + if self._is_retryable_url_error(exc) and attempt < 2: + time.sleep(0.25) + continue + raise MicronicError(f"Failed to reach Micronic server at {self.base_url}: {exc.reason}") from exc except (ConnectionResetError, http.client.RemoteDisconnected, OSError) as exc: if attempt == 2: - raise MicronicRackReaderError( + raise MicronicError( f"Micronic connection failed for {method} {path}: {exc}" ) from exc time.sleep(0.25) - raise MicronicRackReaderError(f"Micronic request failed for {method} {path}.") + raise MicronicError(f"Micronic request failed for {method} {path}.") - def _as_micronic_error(self, body: bytes, fallback: str) -> MicronicRackReaderError: + def _as_micronic_error(self, body: bytes, fallback: str) -> MicronicError: try: payload = json.loads(body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): - return MicronicRackReaderError(fallback) + return MicronicError(fallback) if isinstance(payload, dict) and "ErrorMsg" in payload: error_code = payload.get("ErrorCode") error_msg = payload.get("ErrorMsg") - return MicronicRackReaderError(f"Micronic error {error_code}: {error_msg}") + return MicronicError(f"Micronic error {error_code}: {error_msg}") - return MicronicRackReaderError(fallback) + return MicronicError(fallback) + + def _is_retryable_url_error(self, exc: error.URLError) -> bool: + reason = exc.reason + return isinstance(reason, (ConnectionResetError, http.client.RemoteDisconnected, OSError)) diff --git a/pylabrobot/micronic/micronic_tests.py b/pylabrobot/micronic/micronic_tests.py index be8ee91bc74..e495f68783d 100644 --- a/pylabrobot/micronic/micronic_tests.py +++ b/pylabrobot/micronic/micronic_tests.py @@ -1,10 +1,17 @@ import json import unittest +from urllib import error from unittest.mock import MagicMock, patch +from pylabrobot.capabilities.barcode_scanning.backend import BarcodeScannerError from pylabrobot.capabilities.rack_reading import RackReaderState +from pylabrobot.resources.barcode import Barcode from pylabrobot.micronic import MicronicCodeReader -from pylabrobot.micronic.http_driver import MicronicHTTPDriver, MicronicRackReaderError +from pylabrobot.micronic.barcode_scanning_backend import ( + MicronicBarcodeScannerBackend, + MicronicBarcodeScannerError, +) +from pylabrobot.micronic.http_driver import MicronicError, MicronicHTTPDriver from pylabrobot.micronic.rack_reading_backend import MicronicRackReadingBackend @@ -30,9 +37,24 @@ async def test_http_error_maps_to_backend_error(self): json.dumps({"ErrorCode": 4, "ErrorMsg": "invalid state"}).encode("utf-8"), fallback="fallback", ) - self.assertIsInstance(err, MicronicRackReaderError) + self.assertIsInstance(err, MicronicError) self.assertIn("invalid state", str(err)) + async def test_request_sync_retries_retryable_urlerror(self): + driver = MicronicHTTPDriver() + response = MagicMock() + response.read.return_value = b'{"state":"idle"}' + response.__enter__.return_value = response + response.__exit__.return_value = False + + with patch( + "pylabrobot.micronic.http_driver.request.urlopen", + side_effect=[error.URLError(ConnectionResetError(104, "reset")), response], + ): + body = driver._request_sync("GET", "/state") + + self.assertEqual(body, b'{"state":"idle"}') + class TestMicronicRackReadingBackend(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: @@ -57,7 +79,13 @@ async def test_get_state(self): async def test_trigger_rack_scan(self): with patch.object(self.driver, "request", return_value=b"") as request_bytes: await self.backend.trigger_rack_scan() - request_bytes.assert_called_once_with("POST", "/scanbox", data=b"", expect_json=False) + request_bytes.assert_called_once_with( + "POST", + "/scanbox", + data=b"", + headers=None, + expect_json=False, + ) async def test_get_scan_result(self): payload = { @@ -93,17 +121,120 @@ async def test_set_current_layout(self): ) +class TestMicronicBarcodeScannerBackend(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + super().setUp() + self.driver = MicronicHTTPDriver() + self.backend = MicronicBarcodeScannerBackend(driver=self.driver, timeout=1.0, poll_interval=0.0) + + async def test_scan_barcode_reads_single_tube_code(self): + with patch.object(self.driver, "request", return_value=b"") as request_bytes, patch.object( + self.driver, + "request_json", + side_effect=[ + {"state": "idle"}, + {"state": "scanning"}, + {"state": "dataready"}, + {"TubeID": ["5007377910"]}, + ], + ): + barcode = await self.backend.scan_barcode() + + request_bytes.assert_called_once_with( + "POST", + "/scantube", + data=b"", + headers=None, + expect_json=False, + ) + self.assertEqual(barcode, Barcode("5007377910", "Data Matrix", "bottom")) + + async def test_scan_barcode_falls_back_to_rackid_payload(self): + with patch.object(self.driver, "request", return_value=b"") as request_bytes, patch.object( + self.driver, + "request_json", + side_effect=[ + {"state": "idle"}, + {"state": "dataready"}, + {"unexpected": "shape"}, + {"RackID": "5007377910"}, + ], + ): + barcode = await self.backend.scan_barcode() + + request_bytes.assert_called_once_with( + "POST", + "/scantube", + data=b"", + headers=None, + expect_json=False, + ) + self.assertEqual(barcode.data, "5007377910") + + async def test_scan_barcode_waits_for_new_dataready_cycle(self): + with patch.object(self.driver, "request", return_value=b"") as request_bytes, patch.object( + self.driver, + "request_json", + side_effect=[ + {"state": "dataready"}, + {"state": "dataready"}, + {"state": "scanning"}, + {"state": "dataready"}, + {"TubeID": ["5007377910"]}, + ], + ) as request_json: + barcode = await self.backend.scan_barcode() + + request_bytes.assert_called_once_with( + "POST", + "/scantube", + data=b"", + headers=None, + expect_json=False, + ) + self.assertEqual(barcode.data, "5007377910") + self.assertEqual(request_json.call_count, 5) + + async def test_scan_barcode_raises_on_unknown_payload(self): + with patch.object(self.driver, "request", return_value=b""), patch.object( + self.driver, + "request_json", + side_effect=[ + {"state": "idle"}, + {"state": "dataready"}, + {"unexpected": "shape"}, + {"still": "bad"}, + ], + ): + with self.assertRaises(MicronicBarcodeScannerError): + await self.backend.scan_barcode() + + async def test_backend_error_is_a_barcode_scanner_error(self): + with patch.object( + self.driver, + "request_json", + side_effect=MicronicError("network failure"), + ): + with self.assertRaises(BarcodeScannerError): + await self.backend.scan_barcode() + + class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): - async def test_device_exposes_rack_reading_capability(self): + async def test_device_exposes_rack_and_barcode_capabilities(self): reader = MicronicCodeReader(timeout=12.0, poll_interval=0.25) with patch.object( reader.rack_reading.backend, "get_state", return_value=RackReaderState.IDLE, + ), patch.object( + reader.barcode_scanning.backend, + "_get_state", + return_value=RackReaderState.IDLE, ): await reader.setup() try: self.assertIs(reader.rack_reader, reader.rack_reading) + self.assertIn(reader.barcode_scanning, reader._capabilities) with patch.object( reader.rack_reading, "scan_rack", @@ -113,11 +244,19 @@ async def test_device_exposes_rack_reading_capability(self): timeout=reader.default_timeout, poll_interval=reader.default_poll_interval, ) + with patch.object( + reader.barcode_scanning, + "scan", + return_value=Barcode(data="5007377910", symbology="Data Matrix", position_on_resource="bottom"), + ) as scan_barcode: + barcode = await reader.barcode_scanning.scan() finally: await reader.stop() self.assertEqual(result.rack_id, "5500135415") + self.assertEqual(barcode.data, "5007377910") scan_rack.assert_called_once_with(timeout=12.0, poll_interval=0.25) + scan_barcode.assert_called_once_with() async def test_device_rack_reading_capability_can_scan_rack_id_only(self): reader = MicronicCodeReader(timeout=12.0, poll_interval=0.25) diff --git a/pylabrobot/micronic/rack_reading_backend.py b/pylabrobot/micronic/rack_reading_backend.py index 55eefbd517c..77ccd72721c 100644 --- a/pylabrobot/micronic/rack_reading_backend.py +++ b/pylabrobot/micronic/rack_reading_backend.py @@ -7,12 +7,17 @@ from pylabrobot.capabilities.rack_reading import ( LayoutInfo, RackReaderBackend, + RackReaderError, RackReaderState, RackScanEntry, RackScanResult, ) -from .http_driver import MicronicHTTPDriver, MicronicRackReaderError +from .http_driver import MicronicError, MicronicHTTPDriver + + +class MicronicRackReaderError(MicronicError, RackReaderError): + """Raised when Micronic rack-reading operations fail.""" class MicronicRackReadingBackend(RackReaderBackend): @@ -26,7 +31,7 @@ async def _on_setup(self, backend_params: Optional[BackendParams] = None): await self.get_state() async def get_state(self) -> RackReaderState: - payload = await self.driver.request_json("GET", "/state") + payload = await self._request_json("GET", "/state") state = payload.get("state") if not isinstance(state, str): raise MicronicRackReaderError("Micronic server response did not contain a valid state.") @@ -36,17 +41,14 @@ async def get_state(self) -> RackReaderState: raise MicronicRackReaderError(f"Unknown Micronic state: {state}") from exc async def trigger_rack_scan(self) -> None: - await self.driver.request("POST", "/scanbox", data=b"", expect_json=False) - - async def trigger_tube_scan(self) -> None: - await self.driver.request("POST", "/scantube", data=b"", expect_json=False) + await self._request("POST", "/scanbox", data=b"", expect_json=False) async def get_scan_result(self) -> RackScanResult: - payload = await self.driver.request_json("GET", "/scanresult") + payload = await self._request_json("GET", "/scanresult") return self._parse_scan_result(payload) async def get_rack_id(self) -> str: - payload = await self.driver.request_json("GET", "/rackid") + payload = await self._request_json("GET", "/rackid") if isinstance(payload, dict): for key in ("RackID", "rackid", "rack_id"): @@ -57,7 +59,7 @@ async def get_rack_id(self) -> str: raise MicronicRackReaderError("Micronic rack ID response had an unexpected shape.") async def get_layouts(self) -> list[LayoutInfo]: - payload = await self.driver.request_json("GET", "/layoutlist") + payload = await self._request_json("GET", "/layoutlist") if isinstance(payload, list): return [LayoutInfo(name=str(item)) for item in payload] @@ -71,7 +73,7 @@ async def get_layouts(self) -> list[LayoutInfo]: raise MicronicRackReaderError("Micronic layout list response had an unexpected shape.") async def get_current_layout(self) -> str: - payload = await self.driver.request_json("GET", "/currentlayout") + payload = await self._request_json("GET", "/currentlayout") if isinstance(payload, str): return payload @@ -85,7 +87,7 @@ async def get_current_layout(self) -> str: raise MicronicRackReaderError("Micronic current layout response had an unexpected shape.") async def set_current_layout(self, layout: str) -> None: - await self.driver.request( + await self._request( "PUT", "/currentlayout", data=json.dumps({"Layout": layout}).encode("utf-8"), @@ -142,3 +144,34 @@ def _get_optional_item(self, items: list[Any], index: int) -> Any: if index >= len(items): return None return items[index] + + async def _request_json( + self, + method: str, + path: str, + data: bytes | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + try: + return await self.driver.request_json(method, path, data=data, headers=headers) + except MicronicError as exc: + raise MicronicRackReaderError(str(exc)) from exc + + async def _request( + self, + method: str, + path: str, + data: bytes | None = None, + headers: dict[str, str] | None = None, + expect_json: bool = True, + ) -> bytes: + try: + return await self.driver.request( + method, + path, + data=data, + headers=headers, + expect_json=expect_json, + ) + except MicronicError as exc: + raise MicronicRackReaderError(str(exc)) from exc From 4ece53d8568c969d5375a0a4c54c31b3dee85ab3 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 13:10:58 -0700 Subject: [PATCH 09/54] Adapt Micronic split to updated PR branch --- .../rack_reading/rack_reader_tests.py | 13 ---------- .../micronic/barcode_scanning_backend.py | 3 ++- pylabrobot/micronic/micronic_tests.py | 24 ------------------- 3 files changed, 2 insertions(+), 38 deletions(-) diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index 5382e3684cc..20e7cb986a6 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -108,19 +108,6 @@ async def test_scan_rack_waits_for_new_dataready_cycle(self): ["get_state", "trigger_rack_scan", "get_state", "get_state", "get_scan_result"], ) - async def test_scan_rack_id_triggers_tube_scan_and_returns_rack_id(self): - backend = RecordingRackReaderBackend() - reader = RackReader(backend=backend) - await reader._on_setup() - - rack_id = await reader.scan_rack_id(timeout=1.0, poll_interval=0.01) - - self.assertEqual(rack_id, "5500135415") - self.assertEqual( - backend.calls[:3], - ["trigger_tube_scan", "get_state", "get_rack_id"], - ) - async def test_scan_rack_times_out(self): backend = StuckRackReaderBackend() reader = RackReader(backend=backend) diff --git a/pylabrobot/micronic/barcode_scanning_backend.py b/pylabrobot/micronic/barcode_scanning_backend.py index e959e8b162d..83db2a5bd6c 100644 --- a/pylabrobot/micronic/barcode_scanning_backend.py +++ b/pylabrobot/micronic/barcode_scanning_backend.py @@ -4,6 +4,7 @@ import time from typing import Any, Optional +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend, BarcodeScannerError from pylabrobot.capabilities.rack_reading import RackReaderState from pylabrobot.resources.barcode import Barcode @@ -29,7 +30,7 @@ def __init__( self.timeout = timeout self.poll_interval = poll_interval - async def _on_setup(self): + async def _on_setup(self, backend_params: Optional[BackendParams] = None): await self._get_state() async def scan_barcode(self) -> Barcode: diff --git a/pylabrobot/micronic/micronic_tests.py b/pylabrobot/micronic/micronic_tests.py index e495f68783d..2397b697289 100644 --- a/pylabrobot/micronic/micronic_tests.py +++ b/pylabrobot/micronic/micronic_tests.py @@ -258,30 +258,6 @@ async def test_device_exposes_rack_and_barcode_capabilities(self): scan_rack.assert_called_once_with(timeout=12.0, poll_interval=0.25) scan_barcode.assert_called_once_with() - async def test_device_rack_reading_capability_can_scan_rack_id_only(self): - reader = MicronicCodeReader(timeout=12.0, poll_interval=0.25) - with patch.object( - reader.rack_reading.backend, - "get_state", - return_value=RackReaderState.IDLE, - ): - await reader.setup() - try: - with patch.object( - reader.rack_reading, - "scan_rack_id", - return_value="5500135415", - ) as scan_rack_id: - rack_id = await reader.rack_reading.scan_rack_id( - timeout=reader.default_timeout, - poll_interval=reader.default_poll_interval, - ) - finally: - await reader.stop() - - self.assertEqual(rack_id, "5500135415") - scan_rack_id.assert_called_once_with(timeout=12.0, poll_interval=0.25) - if __name__ == "__main__": unittest.main() From 915bba5c4c744d3e1653a2a45399bb2c76110d06 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 13:22:31 -0700 Subject: [PATCH 10/54] Expose Micronic rack barcode-only scans --- docs/user_guide/capabilities/rack-reading.md | 8 +++ docs/user_guide/micronic/index.md | 4 ++ .../capabilities/rack_reading/backend.py | 4 ++ .../capabilities/rack_reading/chatterbox.py | 3 + .../capabilities/rack_reading/rack_reader.py | 61 ++++++++++++++----- .../rack_reading/rack_reader_tests.py | 17 ++++++ pylabrobot/micronic/micronic_tests.py | 39 ++++++++++++ pylabrobot/micronic/rack_reading_backend.py | 4 ++ 8 files changed, 125 insertions(+), 15 deletions(-) diff --git a/docs/user_guide/capabilities/rack-reading.md b/docs/user_guide/capabilities/rack-reading.md index 3ae6350af66..1c6c1a6eb0b 100644 --- a/docs/user_guide/capabilities/rack-reading.md +++ b/docs/user_guide/capabilities/rack-reading.md @@ -17,11 +17,16 @@ result = await reader.scan_rack(timeout=60.0, poll_interval=1.0) `scan_rack()` is the main public operation. It triggers the scan, waits internally until the reader reaches `dataready`, and then returns a `RackScanResult`. +If the hardware supports reading just the rack barcode without decoding all tube positions, +`scan_rack_id()` exposes that as a rack-reading operation and returns the rack identifier only. + Lower-level methods are also available: - `get_state()` - `wait_for_data_ready()` - `trigger_rack_scan()` +- `trigger_rack_id_scan()` +- `scan_rack_id()` - `get_scan_result()` - `get_rack_id()` - `get_layouts()` @@ -40,6 +45,9 @@ try: result = await reader.rack_reading.scan_rack(timeout=90.0, poll_interval=1.0) print(result.rack_id) print(result.entries[0].position, result.entries[0].tube_id) + + rack_id = await reader.rack_reading.scan_rack_id(timeout=30.0, poll_interval=1.0) + print(rack_id) finally: await reader.stop() ``` diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index c93feefca02..0897d3f0559 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -12,6 +12,7 @@ Rack reading: - `GET /state` - `POST /scanbox` +- `POST /scantube` for rack-barcode-only scans - `GET /scanresult` - `GET /rackid` - `GET /layoutlist` @@ -39,6 +40,9 @@ try: print(rack_result.rack_id) print(rack_result.entries[0].position, rack_result.entries[0].tube_id) + rack_id = await reader.rack_reading.scan_rack_id(timeout=30.0, poll_interval=1.0) + print(rack_id) + barcode = await reader.barcode_scanning.scan() print(barcode.data) finally: diff --git a/pylabrobot/capabilities/rack_reading/backend.py b/pylabrobot/capabilities/rack_reading/backend.py index ed6eab11527..4dc514e9e35 100644 --- a/pylabrobot/capabilities/rack_reading/backend.py +++ b/pylabrobot/capabilities/rack_reading/backend.py @@ -18,6 +18,10 @@ async def get_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: """Initiate a rack-wide scan.""" + @abstractmethod + async def trigger_rack_id_scan(self) -> None: + """Initiate a rack-barcode-only scan.""" + @abstractmethod async def get_scan_result(self) -> RackScanResult: """Return the most recent rack scan result.""" diff --git a/pylabrobot/capabilities/rack_reading/chatterbox.py b/pylabrobot/capabilities/rack_reading/chatterbox.py index 2282070ba16..b3f454ac23d 100644 --- a/pylabrobot/capabilities/rack_reading/chatterbox.py +++ b/pylabrobot/capabilities/rack_reading/chatterbox.py @@ -17,6 +17,9 @@ async def get_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: self._state = RackReaderState.DATAREADY + async def trigger_rack_id_scan(self) -> None: + self._state = RackReaderState.DATAREADY + async def get_scan_result(self) -> RackScanResult: return RackScanResult( rack_id="CHATTERBOX", diff --git a/pylabrobot/capabilities/rack_reading/rack_reader.py b/pylabrobot/capabilities/rack_reading/rack_reader.py index 3c6eabe6e0c..87b6f700033 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader.py @@ -24,6 +24,10 @@ async def get_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: await self.backend.trigger_rack_scan() + @need_capability_ready + async def trigger_rack_id_scan(self) -> None: + await self.backend.trigger_rack_id_scan() + @need_capability_ready async def get_scan_result(self) -> RackScanResult: return await self.backend.get_scan_result() @@ -61,6 +65,27 @@ async def _wait_for_state( ) await asyncio.sleep(poll_interval) + async def _wait_for_fresh_data_ready( + self, + initial_state: RackReaderState, + timeout: float, + poll_interval: float, + ) -> None: + require_state_change = initial_state == RackReaderState.DATAREADY + deadline = time.monotonic() + timeout + while True: + state = await self.backend.get_state() + if state != RackReaderState.DATAREADY: + require_state_change = False + elif not require_state_change: + return + + if time.monotonic() >= deadline: + raise RackReaderTimeoutError( + f"Timed out waiting for rack reader to reach {RackReaderState.DATAREADY.value}." + ) + await asyncio.sleep(poll_interval) + @need_capability_ready async def wait_for_data_ready( self, @@ -85,20 +110,26 @@ async def scan_rack( initial_state = await self.backend.get_state() await self.backend.trigger_rack_scan() + await self._wait_for_fresh_data_ready( + initial_state=initial_state, + timeout=timeout, + poll_interval=poll_interval, + ) + return await self.backend.get_scan_result() - require_state_change = initial_state == RackReaderState.DATAREADY - deadline = time.monotonic() + timeout - while True: - state = await self.backend.get_state() - if state != RackReaderState.DATAREADY: - require_state_change = False - elif not require_state_change: - break - - if time.monotonic() >= deadline: - raise RackReaderTimeoutError( - f"Timed out waiting for rack reader to reach {RackReaderState.DATAREADY.value}." - ) - await asyncio.sleep(poll_interval) + @need_capability_ready + async def scan_rack_id( + self, + timeout: float = 60.0, + poll_interval: float = 1.0, + ) -> str: + """Trigger a rack-barcode-only scan and return the completed rack identifier.""" - return await self.backend.get_scan_result() + initial_state = await self.backend.get_state() + await self.backend.trigger_rack_id_scan() + await self._wait_for_fresh_data_ready( + initial_state=initial_state, + timeout=timeout, + poll_interval=poll_interval, + ) + return await self.backend.get_rack_id() diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index 20e7cb986a6..03fa0c99169 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -34,6 +34,10 @@ async def trigger_rack_scan(self) -> None: self.calls.append("trigger_rack_scan") self.state = RackReaderState.SCANNING + async def trigger_rack_id_scan(self) -> None: + self.calls.append("trigger_rack_id_scan") + self.state = RackReaderState.SCANNING + async def get_scan_result(self) -> RackScanResult: self.calls.append("get_scan_result") return self.result @@ -108,6 +112,19 @@ async def test_scan_rack_waits_for_new_dataready_cycle(self): ["get_state", "trigger_rack_scan", "get_state", "get_state", "get_scan_result"], ) + async def test_scan_rack_id_triggers_and_returns_rack_id(self): + backend = RecordingRackReaderBackend() + reader = RackReader(backend=backend) + await reader._on_setup() + + rack_id = await reader.scan_rack_id(timeout=1.0, poll_interval=0.01) + + self.assertEqual(rack_id, "5500135415") + self.assertEqual( + backend.calls[:4], + ["get_state", "trigger_rack_id_scan", "get_state", "get_rack_id"], + ) + async def test_scan_rack_times_out(self): backend = StuckRackReaderBackend() reader = RackReader(backend=backend) diff --git a/pylabrobot/micronic/micronic_tests.py b/pylabrobot/micronic/micronic_tests.py index 2397b697289..b8fcc936a33 100644 --- a/pylabrobot/micronic/micronic_tests.py +++ b/pylabrobot/micronic/micronic_tests.py @@ -87,6 +87,17 @@ async def test_trigger_rack_scan(self): expect_json=False, ) + async def test_trigger_rack_id_scan(self): + with patch.object(self.driver, "request", return_value=b"") as request_bytes: + await self.backend.trigger_rack_id_scan() + request_bytes.assert_called_once_with( + "POST", + "/scantube", + data=b"", + headers=None, + expect_json=False, + ) + async def test_get_scan_result(self): payload = { "RackID": "3000756455", @@ -258,6 +269,34 @@ async def test_device_exposes_rack_and_barcode_capabilities(self): scan_rack.assert_called_once_with(timeout=12.0, poll_interval=0.25) scan_barcode.assert_called_once_with() + async def test_device_exposes_rack_id_only_scan_on_rack_reading(self): + reader = MicronicCodeReader(timeout=12.0, poll_interval=0.25) + with patch.object( + reader.rack_reading.backend, + "get_state", + return_value=RackReaderState.IDLE, + ), patch.object( + reader.barcode_scanning.backend, + "_get_state", + return_value=RackReaderState.IDLE, + ): + await reader.setup() + try: + with patch.object( + reader.rack_reading, + "scan_rack_id", + return_value="5500135415", + ) as scan_rack_id: + rack_id = await reader.rack_reading.scan_rack_id( + timeout=reader.default_timeout, + poll_interval=reader.default_poll_interval, + ) + finally: + await reader.stop() + + self.assertEqual(rack_id, "5500135415") + scan_rack_id.assert_called_once_with(timeout=12.0, poll_interval=0.25) + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/micronic/rack_reading_backend.py b/pylabrobot/micronic/rack_reading_backend.py index 77ccd72721c..95e7a46b466 100644 --- a/pylabrobot/micronic/rack_reading_backend.py +++ b/pylabrobot/micronic/rack_reading_backend.py @@ -43,6 +43,10 @@ async def get_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: await self._request("POST", "/scanbox", data=b"", expect_json=False) + async def trigger_rack_id_scan(self) -> None: + # Micronic exposes the rack-barcode-only trigger on a separate endpoint from full rack scans. + await self._request("POST", "/scantube", data=b"", expect_json=False) + async def get_scan_result(self) -> RackScanResult: payload = await self._request_json("GET", "/scanresult") return self._parse_scan_result(payload) From ca7fd336792890d3d21a2f3454ed5d03dc4068c2 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 14:11:52 -0700 Subject: [PATCH 11/54] fix: consistency with micronic io and v1b1 --- docs/api/pylabrobot.capabilities.rst | 19 ++- docs/api/pylabrobot.micronic.rst | 52 ++++++- .../capabilities/rack_reading/standard.py | 7 +- pylabrobot/micronic/__init__.py | 14 +- .../micronic/barcode_scanning_backend.py | 143 ------------------ pylabrobot/micronic/code_reader/__init__.py | 14 ++ .../code_reader/barcode_scanning_backend.py | 56 +++++++ .../micronic/{ => code_reader}/code_reader.py | 20 +-- .../{http_driver.py => code_reader/driver.py} | 97 +++++++++++- .../{ => code_reader}/micronic_tests.py | 121 ++++++++++----- .../{ => code_reader}/rack_reading_backend.py | 38 +++-- 11 files changed, 363 insertions(+), 218 deletions(-) delete mode 100644 pylabrobot/micronic/barcode_scanning_backend.py create mode 100644 pylabrobot/micronic/code_reader/__init__.py create mode 100644 pylabrobot/micronic/code_reader/barcode_scanning_backend.py rename pylabrobot/micronic/{ => code_reader}/code_reader.py (62%) rename pylabrobot/micronic/{http_driver.py => code_reader/driver.py} (54%) rename pylabrobot/micronic/{ => code_reader}/micronic_tests.py (71%) rename pylabrobot/micronic/{ => code_reader}/rack_reading_backend.py (83%) diff --git a/docs/api/pylabrobot.capabilities.rst b/docs/api/pylabrobot.capabilities.rst index 448546c918d..3fc66738bac 100644 --- a/docs/api/pylabrobot.capabilities.rst +++ b/docs/api/pylabrobot.capabilities.rst @@ -195,7 +195,7 @@ Barcode Scanning Rack Reading ------------ -.. currentmodule:: pylabrobot.capabilities.rack_reading +.. currentmodule:: pylabrobot.capabilities.rack_reading.rack_reader .. autosummary:: :toctree: _autosummary @@ -203,8 +203,25 @@ Rack Reading :recursive: RackReader + +.. currentmodule:: pylabrobot.capabilities.rack_reading.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + RackReaderBackend + +.. currentmodule:: pylabrobot.capabilities.rack_reading.standard + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + RackReaderState + RackReaderError RackReaderTimeoutError RackScanEntry RackScanResult diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index a1d0e423102..046345ca9ea 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -5,15 +5,53 @@ pylabrobot.micronic package Micronic Code Reader integration built on the rack-reading and barcode-scanning capabilities. +Device +------ + +.. currentmodule:: pylabrobot.micronic.code_reader.code_reader + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MicronicCodeReader + + +Driver +------ + +.. currentmodule:: pylabrobot.micronic.code_reader.driver + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MicronicIOMonitorDriver + MicronicIOMonitorState + MicronicError + + +Capabilities +------------ + +.. currentmodule:: pylabrobot.micronic.code_reader.rack_reading_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MicronicIOMonitorRackReadingBackend + MicronicRackReaderError + +.. currentmodule:: pylabrobot.micronic.code_reader.barcode_scanning_backend + .. autosummary:: :toctree: _autosummary :nosignatures: :recursive: - MicronicCodeReader - MicronicHTTPDriver - MicronicError - MicronicBarcodeScannerBackend - MicronicBarcodeScannerError - MicronicRackReaderError - MicronicRackReadingBackend + MicronicIOMonitorBarcodeScannerBackend + MicronicBarcodeScannerError diff --git a/pylabrobot/capabilities/rack_reading/standard.py b/pylabrobot/capabilities/rack_reading/standard.py index 8be8ae8bdd6..fc46cf0d3b8 100644 --- a/pylabrobot/capabilities/rack_reading/standard.py +++ b/pylabrobot/capabilities/rack_reading/standard.py @@ -43,6 +43,11 @@ class RackScanResult: @dataclass class LayoutInfo: - """One rack layout supported by the reader.""" + """One rack layout supported by the reader. + + Wraps a single ``name`` field today so backends can grow the metadata they + attach to a layout (rows, columns, tube type, vendor-specific attributes) + without changing the capability surface. + """ name: str diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index 2df0553033d..b712a9c18ae 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -1,4 +1,10 @@ -from .barcode_scanning_backend import MicronicBarcodeScannerBackend, MicronicBarcodeScannerError -from .code_reader import MicronicCodeReader -from .http_driver import MicronicError, MicronicHTTPDriver -from .rack_reading_backend import MicronicRackReaderError, MicronicRackReadingBackend +from pylabrobot.micronic.code_reader import ( + MicronicBarcodeScannerError, + MicronicCodeReader, + MicronicError, + MicronicIOMonitorBarcodeScannerBackend, + MicronicIOMonitorDriver, + MicronicIOMonitorRackReadingBackend, + MicronicIOMonitorState, + MicronicRackReaderError, +) diff --git a/pylabrobot/micronic/barcode_scanning_backend.py b/pylabrobot/micronic/barcode_scanning_backend.py deleted file mode 100644 index 83db2a5bd6c..00000000000 --- a/pylabrobot/micronic/barcode_scanning_backend.py +++ /dev/null @@ -1,143 +0,0 @@ -from __future__ import annotations - -import asyncio -import time -from typing import Any, Optional - -from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend, BarcodeScannerError -from pylabrobot.capabilities.rack_reading import RackReaderState -from pylabrobot.resources.barcode import Barcode - -from .http_driver import MicronicError, MicronicHTTPDriver - - -class MicronicBarcodeScannerError(MicronicError, BarcodeScannerError): - """Raised when Micronic single-tube barcode scanning fails.""" - - -class MicronicBarcodeScannerBackend(BarcodeScannerBackend): - """Single-tube barcode-scanning backend for the Micronic Code Reader HTTP server.""" - - def __init__( - self, - driver: MicronicHTTPDriver, - timeout: float = 60.0, - poll_interval: float = 1.0, - ): - super().__init__() - self.driver = driver - self.timeout = timeout - self.poll_interval = poll_interval - - async def _on_setup(self, backend_params: Optional[BackendParams] = None): - await self._get_state() - - async def scan_barcode(self) -> Barcode: - initial_state = await self._get_state() - await self._request("POST", "/scantube", data=b"", expect_json=False) - await self._wait_for_dataready(initial_state=initial_state) - data = await self._read_tube_barcode() - return Barcode(data=data, symbology="Data Matrix", position_on_resource="bottom") - - async def _get_state(self) -> RackReaderState: - payload = await self._request_json("GET", "/state") - state = payload.get("state") - if not isinstance(state, str): - raise MicronicBarcodeScannerError("Micronic server response did not contain a valid state.") - try: - return RackReaderState(state) - except ValueError as exc: - raise MicronicBarcodeScannerError(f"Unknown Micronic state: {state}") from exc - - async def _wait_for_dataready(self, initial_state: RackReaderState) -> None: - require_state_change = initial_state == RackReaderState.DATAREADY - deadline = time.monotonic() + self.timeout - - while True: - state = await self._get_state() - if state != RackReaderState.DATAREADY: - require_state_change = False - elif not require_state_change: - return - - if time.monotonic() >= deadline: - raise MicronicBarcodeScannerError( - f"Timed out waiting for barcode scan to reach {RackReaderState.DATAREADY.value}." - ) - await asyncio.sleep(self.poll_interval) - - async def _read_tube_barcode(self) -> str: - scan_result_payload = await self._request_json("GET", "/scanresult") - barcode = self._extract_single_tube_barcode(scan_result_payload) - if barcode is not None: - return barcode - - rack_id_payload = await self._request_json("GET", "/rackid") - barcode = self._extract_named_barcode( - rack_id_payload, - keys=("RackID", "rackid", "rack_id", "Barcode", "barcode", "Code", "code"), - ) - if barcode is not None: - return barcode - - raise MicronicBarcodeScannerError( - "Micronic single-tube scan result had an unexpected shape." - ) - - async def _request_json( - self, - method: str, - path: str, - data: Optional[bytes] = None, - headers: Optional[dict[str, str]] = None, - ) -> Any: - try: - return await self.driver.request_json(method, path, data=data, headers=headers) - except MicronicError as exc: - raise MicronicBarcodeScannerError(str(exc)) from exc - - async def _request( - self, - method: str, - path: str, - data: Optional[bytes] = None, - headers: Optional[dict[str, str]] = None, - expect_json: bool = True, - ) -> bytes: - try: - return await self.driver.request( - method, - path, - data=data, - headers=headers, - expect_json=expect_json, - ) - except MicronicError as exc: - raise MicronicBarcodeScannerError(str(exc)) from exc - - def _extract_single_tube_barcode(self, payload: Any) -> Optional[str]: - if isinstance(payload, str) and payload: - return payload - - return self._extract_named_barcode( - payload, - keys=("TubeID", "tubeid", "tube_id", "Barcode", "barcode", "Code", "code", "Data", "data"), - ) - - def _extract_named_barcode(self, payload: Any, keys: tuple[str, ...]) -> Optional[str]: - if not isinstance(payload, dict): - return None - - for key in keys: - barcode = self._coerce_single_barcode(payload.get(key)) - if barcode is not None: - return barcode - return None - - def _coerce_single_barcode(self, value: Any) -> Optional[str]: - if isinstance(value, str): - return value or None - if isinstance(value, list) and len(value) == 1 and value[0] not in (None, ""): - return str(value[0]) - return None diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py new file mode 100644 index 00000000000..b518f640bc5 --- /dev/null +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -0,0 +1,14 @@ +from pylabrobot.micronic.code_reader.barcode_scanning_backend import ( + MicronicBarcodeScannerError, + MicronicIOMonitorBarcodeScannerBackend, +) +from pylabrobot.micronic.code_reader.code_reader import MicronicCodeReader +from pylabrobot.micronic.code_reader.driver import ( + MicronicError, + MicronicIOMonitorDriver, + MicronicIOMonitorState, +) +from pylabrobot.micronic.code_reader.rack_reading_backend import ( + MicronicIOMonitorRackReadingBackend, + MicronicRackReaderError, +) diff --git a/pylabrobot/micronic/code_reader/barcode_scanning_backend.py b/pylabrobot/micronic/code_reader/barcode_scanning_backend.py new file mode 100644 index 00000000000..b0422c4ffe4 --- /dev/null +++ b/pylabrobot/micronic/code_reader/barcode_scanning_backend.py @@ -0,0 +1,56 @@ +"""Single-tube barcode-scanning backend for the Micronic Code Reader IO Monitor server.""" + +from __future__ import annotations + +from typing import Optional + +from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend, BarcodeScannerError +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources.barcode import Barcode + +from .driver import MicronicError, MicronicIOMonitorDriver + + +class MicronicBarcodeScannerError(MicronicError, BarcodeScannerError): + """Raised when Micronic single-tube barcode scanning fails.""" + + +class MicronicIOMonitorBarcodeScannerBackend(BarcodeScannerBackend): + """Single-tube barcode-scanning backend for the Micronic Code Reader IO Monitor server.""" + + def __init__( + self, + driver: MicronicIOMonitorDriver, + timeout: float = 60.0, + poll_interval: float = 1.0, + ): + super().__init__() + self.driver = driver + self.timeout = timeout + self.poll_interval = poll_interval + + async def _on_setup(self, backend_params: Optional[BackendParams] = None): + try: + await self.driver.get_iomonitor_state() + except MicronicError as exc: + raise MicronicBarcodeScannerError(str(exc)) from exc + + async def scan_barcode(self) -> Barcode: + try: + initial_state = await self.driver.get_iomonitor_state() + await self.driver.request( + "POST", + "/scantube", + data=b"", + headers=None, + expect_json=False, + ) + await self.driver.wait_for_fresh_data_ready( + initial_state=initial_state, + timeout=self.timeout, + poll_interval=self.poll_interval, + ) + data = await self.driver.get_single_tube_barcode() + except MicronicError as exc: + raise MicronicBarcodeScannerError(str(exc)) from exc + return Barcode(data=data, symbology="Data Matrix", position_on_resource="bottom") diff --git a/pylabrobot/micronic/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py similarity index 62% rename from pylabrobot/micronic/code_reader.py rename to pylabrobot/micronic/code_reader/code_reader.py index cdae3b05903..e62375a5ee8 100644 --- a/pylabrobot/micronic/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -1,3 +1,5 @@ +"""Micronic Code Reader device.""" + from __future__ import annotations from typing import Optional @@ -6,9 +8,9 @@ from pylabrobot.capabilities.rack_reading import RackReader from pylabrobot.device import Device -from .barcode_scanning_backend import MicronicBarcodeScannerBackend -from .http_driver import MicronicHTTPDriver -from .rack_reading_backend import MicronicRackReadingBackend +from .barcode_scanning_backend import MicronicIOMonitorBarcodeScannerBackend +from .driver import MicronicIOMonitorDriver +from .rack_reading_backend import MicronicIOMonitorRackReadingBackend class MicronicCodeReader(Device): @@ -20,24 +22,22 @@ def __init__( port: int = 2500, timeout: float = 60.0, poll_interval: float = 1.0, - driver: Optional[MicronicHTTPDriver] = None, + driver: Optional[MicronicIOMonitorDriver] = None, ): if driver is None: - driver = MicronicHTTPDriver(host=host, port=port, timeout=timeout) + driver = MicronicIOMonitorDriver(host=host, port=port, timeout=timeout) super().__init__(driver=driver) - self.driver: MicronicHTTPDriver = driver + self.driver: MicronicIOMonitorDriver = driver self.default_timeout = timeout self.default_poll_interval = poll_interval - self.rack_reading = RackReader(backend=MicronicRackReadingBackend(driver)) + self.rack_reading = RackReader(backend=MicronicIOMonitorRackReadingBackend(driver)) self.barcode_scanning = BarcodeScanner( - backend=MicronicBarcodeScannerBackend( + backend=MicronicIOMonitorBarcodeScannerBackend( driver, timeout=timeout, poll_interval=poll_interval, ) ) - # Temporary alias while the consumer code moves to the capability-centric v1b1 surface. - self.rack_reader = self.rack_reading self._capabilities = [self.rack_reading, self.barcode_scanning] def serialize(self) -> dict: diff --git a/pylabrobot/micronic/http_driver.py b/pylabrobot/micronic/code_reader/driver.py similarity index 54% rename from pylabrobot/micronic/http_driver.py rename to pylabrobot/micronic/code_reader/driver.py index 19e38da577f..9512084eb23 100644 --- a/pylabrobot/micronic/http_driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -1,6 +1,9 @@ +"""HTTP driver for the Micronic Code Reader IO Monitor server.""" + from __future__ import annotations import asyncio +import enum import http.client import json import time @@ -12,10 +15,22 @@ class MicronicError(Exception): - """Raised when the Micronic HTTP server returns an error.""" + """Raised when the Micronic IO Monitor HTTP server returns an error.""" + + +class MicronicIOMonitorState(enum.Enum): + """Normalized IO Monitor device state reported by GET /state. + + IO Monitor exposes a single state machine that governs both rack scans and + single-tube scans, so this enum is shared across all IO Monitor backends. + """ + IDLE = "idle" + SCANNING = "scanning" + DATAREADY = "dataready" -class MicronicHTTPDriver(Driver): + +class MicronicIOMonitorDriver(Driver): """HTTP transport for the Micronic Code Reader IO Monitor server.""" def __init__( @@ -50,6 +65,57 @@ def serialize(self) -> dict: "user_agent": self.user_agent, } + async def get_iomonitor_state(self) -> MicronicIOMonitorState: + payload = await self.request_json("GET", "/state") + state = payload.get("state") if isinstance(payload, dict) else None + if not isinstance(state, str): + raise MicronicError("Micronic server response did not contain a valid state.") + try: + return MicronicIOMonitorState(state) + except ValueError as exc: + raise MicronicError(f"Unknown Micronic state: {state}") from exc + + async def wait_for_fresh_data_ready( + self, + initial_state: MicronicIOMonitorState, + timeout: float, + poll_interval: float, + ) -> None: + # If we started at dataready, require a state change before accepting the next + # dataready, otherwise we'd return the previous scan's stale result. + require_state_change = initial_state == MicronicIOMonitorState.DATAREADY + deadline = time.monotonic() + timeout + while True: + state = await self.get_iomonitor_state() + if state != MicronicIOMonitorState.DATAREADY: + require_state_change = False + elif not require_state_change: + return + if time.monotonic() >= deadline: + raise MicronicError( + f"Timed out waiting for IO Monitor to reach {MicronicIOMonitorState.DATAREADY.value}." + ) + await asyncio.sleep(poll_interval) + + async def get_single_tube_barcode(self) -> str: + # Prefers the decoded tube value from GET /scanresult. Older IO Monitor builds + # expose that value on GET /rackid instead, so fall back to /rackid for + # cross-version compatibility. + scan_result_payload = await self.request_json("GET", "/scanresult") + barcode = _extract_single_tube_barcode(scan_result_payload) + if barcode is not None: + return barcode + + rack_id_payload = await self.request_json("GET", "/rackid") + barcode = _extract_named_barcode( + rack_id_payload, + keys=("RackID", "rackid", "rack_id", "Barcode", "barcode", "Code", "code"), + ) + if barcode is not None: + return barcode + + raise MicronicError("Micronic single-tube scan result had an unexpected shape.") + async def request( self, method: str, @@ -150,3 +216,30 @@ def _as_micronic_error(self, body: bytes, fallback: str) -> MicronicError: def _is_retryable_url_error(self, exc: error.URLError) -> bool: reason = exc.reason return isinstance(reason, (ConnectionResetError, http.client.RemoteDisconnected, OSError)) + + +def _extract_single_tube_barcode(payload: Any) -> Optional[str]: + if isinstance(payload, str) and payload: + return payload + return _extract_named_barcode( + payload, + keys=("TubeID", "tubeid", "tube_id", "Barcode", "barcode", "Code", "code", "Data", "data"), + ) + + +def _extract_named_barcode(payload: Any, keys: tuple[str, ...]) -> Optional[str]: + if not isinstance(payload, dict): + return None + for key in keys: + barcode = _coerce_single_barcode(payload.get(key)) + if barcode is not None: + return barcode + return None + + +def _coerce_single_barcode(value: Any) -> Optional[str]: + if isinstance(value, str): + return value or None + if isinstance(value, list) and len(value) == 1 and value[0] not in (None, ""): + return str(value[0]) + return None diff --git a/pylabrobot/micronic/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py similarity index 71% rename from pylabrobot/micronic/micronic_tests.py rename to pylabrobot/micronic/code_reader/micronic_tests.py index b8fcc936a33..c0d571f93c3 100644 --- a/pylabrobot/micronic/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -1,30 +1,36 @@ import json import unittest -from urllib import error from unittest.mock import MagicMock, patch +from urllib import error from pylabrobot.capabilities.barcode_scanning.backend import BarcodeScannerError from pylabrobot.capabilities.rack_reading import RackReaderState -from pylabrobot.resources.barcode import Barcode from pylabrobot.micronic import MicronicCodeReader -from pylabrobot.micronic.barcode_scanning_backend import ( - MicronicBarcodeScannerBackend, +from pylabrobot.micronic.code_reader.barcode_scanning_backend import ( MicronicBarcodeScannerError, + MicronicIOMonitorBarcodeScannerBackend, +) +from pylabrobot.micronic.code_reader.driver import ( + MicronicError, + MicronicIOMonitorDriver, + MicronicIOMonitorState, ) -from pylabrobot.micronic.http_driver import MicronicError, MicronicHTTPDriver -from pylabrobot.micronic.rack_reading_backend import MicronicRackReadingBackend +from pylabrobot.micronic.code_reader.rack_reading_backend import ( + MicronicIOMonitorRackReadingBackend, +) +from pylabrobot.resources.barcode import Barcode -class TestMicronicHTTPDriver(unittest.IsolatedAsyncioTestCase): +class TestMicronicIOMonitorDriver(unittest.IsolatedAsyncioTestCase): async def test_request_sync_retries_connection_reset(self): - driver = MicronicHTTPDriver() + driver = MicronicIOMonitorDriver() response = MagicMock() response.read.return_value = b'{"state":"idle"}' response.__enter__.return_value = response response.__exit__.return_value = False with patch( - "pylabrobot.micronic.http_driver.request.urlopen", + "pylabrobot.micronic.code_reader.driver.request.urlopen", side_effect=[ConnectionResetError(104, "reset"), response], ): body = driver._request_sync("GET", "/state") @@ -32,7 +38,7 @@ async def test_request_sync_retries_connection_reset(self): self.assertEqual(body, b'{"state":"idle"}') async def test_http_error_maps_to_backend_error(self): - driver = MicronicHTTPDriver() + driver = MicronicIOMonitorDriver() err = driver._as_micronic_error( json.dumps({"ErrorCode": 4, "ErrorMsg": "invalid state"}).encode("utf-8"), fallback="fallback", @@ -41,26 +47,76 @@ async def test_http_error_maps_to_backend_error(self): self.assertIn("invalid state", str(err)) async def test_request_sync_retries_retryable_urlerror(self): - driver = MicronicHTTPDriver() + driver = MicronicIOMonitorDriver() response = MagicMock() response.read.return_value = b'{"state":"idle"}' response.__enter__.return_value = response response.__exit__.return_value = False with patch( - "pylabrobot.micronic.http_driver.request.urlopen", + "pylabrobot.micronic.code_reader.driver.request.urlopen", side_effect=[error.URLError(ConnectionResetError(104, "reset")), response], ): body = driver._request_sync("GET", "/state") self.assertEqual(body, b'{"state":"idle"}') + async def test_get_iomonitor_state_parses_payload(self): + driver = MicronicIOMonitorDriver() + with patch.object(driver, "request_json", return_value={"state": "dataready"}): + state = await driver.get_iomonitor_state() + self.assertEqual(state, MicronicIOMonitorState.DATAREADY) + + async def test_get_iomonitor_state_rejects_unknown(self): + driver = MicronicIOMonitorDriver() + with patch.object(driver, "request_json", return_value={"state": "weird"}): + with self.assertRaises(MicronicError): + await driver.get_iomonitor_state() -class TestMicronicRackReadingBackend(unittest.IsolatedAsyncioTestCase): + async def test_wait_for_fresh_data_ready_requires_state_change_when_starting_ready(self): + driver = MicronicIOMonitorDriver() + with patch.object( + driver, + "request_json", + side_effect=[ + {"state": "dataready"}, + {"state": "scanning"}, + {"state": "dataready"}, + ], + ) as request_json: + await driver.wait_for_fresh_data_ready( + initial_state=MicronicIOMonitorState.DATAREADY, + timeout=1.0, + poll_interval=0.0, + ) + self.assertEqual(request_json.call_count, 3) + + async def test_get_single_tube_barcode_prefers_scanresult(self): + driver = MicronicIOMonitorDriver() + with patch.object( + driver, + "request_json", + side_effect=[{"TubeID": ["5007377910"]}], + ): + barcode = await driver.get_single_tube_barcode() + self.assertEqual(barcode, "5007377910") + + async def test_get_single_tube_barcode_falls_back_to_rackid(self): + driver = MicronicIOMonitorDriver() + with patch.object( + driver, + "request_json", + side_effect=[{"unexpected": "shape"}, {"RackID": "5007377910"}], + ): + barcode = await driver.get_single_tube_barcode() + self.assertEqual(barcode, "5007377910") + + +class TestMicronicIOMonitorRackReadingBackend(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: super().setUp() - self.driver = MicronicHTTPDriver() - self.backend = MicronicRackReadingBackend(driver=self.driver) + self.driver = MicronicIOMonitorDriver() + self.backend = MicronicIOMonitorRackReadingBackend(driver=self.driver) async def test_on_setup_checks_state(self): with patch.object(self.backend, "get_state", return_value=RackReaderState.IDLE) as get_state: @@ -132,11 +188,13 @@ async def test_set_current_layout(self): ) -class TestMicronicBarcodeScannerBackend(unittest.IsolatedAsyncioTestCase): +class TestMicronicIOMonitorBarcodeScannerBackend(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: super().setUp() - self.driver = MicronicHTTPDriver() - self.backend = MicronicBarcodeScannerBackend(driver=self.driver, timeout=1.0, poll_interval=0.0) + self.driver = MicronicIOMonitorDriver() + self.backend = MicronicIOMonitorBarcodeScannerBackend( + driver=self.driver, timeout=1.0, poll_interval=0.0 + ) async def test_scan_barcode_reads_single_tube_code(self): with patch.object(self.driver, "request", return_value=b"") as request_bytes, patch.object( @@ -234,18 +292,15 @@ class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): async def test_device_exposes_rack_and_barcode_capabilities(self): reader = MicronicCodeReader(timeout=12.0, poll_interval=0.25) with patch.object( - reader.rack_reading.backend, - "get_state", - return_value=RackReaderState.IDLE, - ), patch.object( - reader.barcode_scanning.backend, - "_get_state", - return_value=RackReaderState.IDLE, + reader.driver, + "get_iomonitor_state", + return_value=MicronicIOMonitorState.IDLE, ): await reader.setup() try: - self.assertIs(reader.rack_reader, reader.rack_reading) + self.assertIn(reader.rack_reading, reader._capabilities) self.assertIn(reader.barcode_scanning, reader._capabilities) + self.assertFalse(hasattr(reader, "rack_reader")) with patch.object( reader.rack_reading, "scan_rack", @@ -258,7 +313,9 @@ async def test_device_exposes_rack_and_barcode_capabilities(self): with patch.object( reader.barcode_scanning, "scan", - return_value=Barcode(data="5007377910", symbology="Data Matrix", position_on_resource="bottom"), + return_value=Barcode( + data="5007377910", symbology="Data Matrix", position_on_resource="bottom" + ), ) as scan_barcode: barcode = await reader.barcode_scanning.scan() finally: @@ -272,13 +329,9 @@ async def test_device_exposes_rack_and_barcode_capabilities(self): async def test_device_exposes_rack_id_only_scan_on_rack_reading(self): reader = MicronicCodeReader(timeout=12.0, poll_interval=0.25) with patch.object( - reader.rack_reading.backend, - "get_state", - return_value=RackReaderState.IDLE, - ), patch.object( - reader.barcode_scanning.backend, - "_get_state", - return_value=RackReaderState.IDLE, + reader.driver, + "get_iomonitor_state", + return_value=MicronicIOMonitorState.IDLE, ): await reader.setup() try: diff --git a/pylabrobot/micronic/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py similarity index 83% rename from pylabrobot/micronic/rack_reading_backend.py rename to pylabrobot/micronic/code_reader/rack_reading_backend.py index 95e7a46b466..6d891dcb24d 100644 --- a/pylabrobot/micronic/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -1,3 +1,5 @@ +"""Rack-reading backend for the Micronic Code Reader IO Monitor server.""" + from __future__ import annotations import json @@ -13,17 +15,24 @@ RackScanResult, ) -from .http_driver import MicronicError, MicronicHTTPDriver +from .driver import MicronicError, MicronicIOMonitorDriver, MicronicIOMonitorState class MicronicRackReaderError(MicronicError, RackReaderError): """Raised when Micronic rack-reading operations fail.""" -class MicronicRackReadingBackend(RackReaderBackend): - """Rack-reading backend for the Micronic Code Reader HTTP server.""" +_IOMONITOR_TO_RACK_READER_STATE = { + MicronicIOMonitorState.IDLE: RackReaderState.IDLE, + MicronicIOMonitorState.SCANNING: RackReaderState.SCANNING, + MicronicIOMonitorState.DATAREADY: RackReaderState.DATAREADY, +} + + +class MicronicIOMonitorRackReadingBackend(RackReaderBackend): + """Rack-reading backend for the Micronic Code Reader IO Monitor server.""" - def __init__(self, driver: MicronicHTTPDriver): + def __init__(self, driver: MicronicIOMonitorDriver): super().__init__() self.driver = driver @@ -31,20 +40,17 @@ async def _on_setup(self, backend_params: Optional[BackendParams] = None): await self.get_state() async def get_state(self) -> RackReaderState: - payload = await self._request_json("GET", "/state") - state = payload.get("state") - if not isinstance(state, str): - raise MicronicRackReaderError("Micronic server response did not contain a valid state.") try: - return RackReaderState(state) - except ValueError as exc: - raise MicronicRackReaderError(f"Unknown Micronic state: {state}") from exc + iomonitor_state = await self.driver.get_iomonitor_state() + except MicronicError as exc: + raise MicronicRackReaderError(str(exc)) from exc + return _IOMONITOR_TO_RACK_READER_STATE[iomonitor_state] async def trigger_rack_scan(self) -> None: await self._request("POST", "/scanbox", data=b"", expect_json=False) async def trigger_rack_id_scan(self) -> None: - # Micronic exposes the rack-barcode-only trigger on a separate endpoint from full rack scans. + # IO Monitor exposes the rack-barcode-only trigger on /scantube, distinct from full rack scans. await self._request("POST", "/scantube", data=b"", expect_json=False) async def get_scan_result(self) -> RackScanResult: @@ -153,8 +159,8 @@ async def _request_json( self, method: str, path: str, - data: bytes | None = None, - headers: dict[str, str] | None = None, + data: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, ) -> Any: try: return await self.driver.request_json(method, path, data=data, headers=headers) @@ -165,8 +171,8 @@ async def _request( self, method: str, path: str, - data: bytes | None = None, - headers: dict[str, str] | None = None, + data: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, expect_json: bool = True, ) -> bytes: try: From c2d8e0da876649bfa1be2187a0231dc2bbf7cea9 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 16:15:13 -0700 Subject: [PATCH 12/54] Refactor rack_id scan into one-shot backend method - Replace abstract trigger_rack_id_scan() with composite scan_rack_id(timeout, poll_interval) -> str - Capability layer delegates directly; backend chooses one-shot vs trigger+poll - Micronic backend now uses GET /rackid (one-shot) instead of POST /scantube (which is the single-tube scanner, not the rack-barcode reader) - Update chatterbox, tests, and docs to match --- docs/user_guide/capabilities/rack-reading.md | 3 +-- docs/user_guide/micronic/index.md | 16 ++++++++------- .../capabilities/rack_reading/backend.py | 9 +++++++-- .../capabilities/rack_reading/chatterbox.py | 3 ++- .../capabilities/rack_reading/rack_reader.py | 20 +++++++------------ .../rack_reading/rack_reader_tests.py | 13 +++++------- .../micronic/code_reader/micronic_tests.py | 20 +++++++++---------- .../code_reader/rack_reading_backend.py | 8 +++++--- 8 files changed, 46 insertions(+), 46 deletions(-) diff --git a/docs/user_guide/capabilities/rack-reading.md b/docs/user_guide/capabilities/rack-reading.md index 1c6c1a6eb0b..651b2b72327 100644 --- a/docs/user_guide/capabilities/rack-reading.md +++ b/docs/user_guide/capabilities/rack-reading.md @@ -25,7 +25,6 @@ Lower-level methods are also available: - `get_state()` - `wait_for_data_ready()` - `trigger_rack_scan()` -- `trigger_rack_id_scan()` - `scan_rack_id()` - `get_scan_result()` - `get_rack_id()` @@ -46,7 +45,7 @@ try: print(result.rack_id) print(result.entries[0].position, result.entries[0].tube_id) - rack_id = await reader.rack_reading.scan_rack_id(timeout=30.0, poll_interval=1.0) + rack_id = await reader.rack_reading.scan_rack_id(timeout=60.0, poll_interval=1.0) print(rack_id) finally: await reader.stop() diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index 0897d3f0559..4cf49d61638 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -8,18 +8,17 @@ Windows application. ## Supported operations -Rack reading: +Rack reading (large scanner that decodes 96 tubes plus the side rack barcode): - `GET /state` -- `POST /scanbox` -- `POST /scantube` for rack-barcode-only scans -- `GET /scanresult` -- `GET /rackid` +- `POST /scanbox` to trigger a full rack scan +- `GET /scanresult` to read the decoded grid +- `GET /rackid` for a rack-barcode-only read on the side reader (one-shot trigger+result) - `GET /layoutlist` - `GET /currentlayout` - `PUT /currentlayout` -Single-tube barcode scanning: +Single-tube barcode scanning (small spot, separate from the rack scanner): - `GET /state` - `POST /scantube` @@ -40,7 +39,7 @@ try: print(rack_result.rack_id) print(rack_result.entries[0].position, rack_result.entries[0].tube_id) - rack_id = await reader.rack_reading.scan_rack_id(timeout=30.0, poll_interval=1.0) + rack_id = await reader.rack_reading.scan_rack_id(timeout=10.0, poll_interval=0.5) print(rack_id) barcode = await reader.barcode_scanning.scan() @@ -55,3 +54,6 @@ finally: - The Micronic application must have the HTTP server enabled in `IO Monitor`. - The reader only supports one external client at a time. - `localhost` is typically safer than `127.0.0.1` on the Windows host. +- `scan_rack` reads every tube barcode and finishes by reading the rack ID, so + it typically takes tens of seconds. `scan_rack_id` only reads the rack + barcode and completes in a few seconds. diff --git a/pylabrobot/capabilities/rack_reading/backend.py b/pylabrobot/capabilities/rack_reading/backend.py index 4dc514e9e35..e65c2725409 100644 --- a/pylabrobot/capabilities/rack_reading/backend.py +++ b/pylabrobot/capabilities/rack_reading/backend.py @@ -19,8 +19,13 @@ async def trigger_rack_scan(self) -> None: """Initiate a rack-wide scan.""" @abstractmethod - async def trigger_rack_id_scan(self) -> None: - """Initiate a rack-barcode-only scan.""" + async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: + """Perform a rack-barcode-only scan and return the rack identifier. + + Backends whose hardware exposes a one-shot rack-id read may ignore + ``timeout`` and ``poll_interval``; backends that need a trigger/poll cycle + should respect them. + """ @abstractmethod async def get_scan_result(self) -> RackScanResult: diff --git a/pylabrobot/capabilities/rack_reading/chatterbox.py b/pylabrobot/capabilities/rack_reading/chatterbox.py index b3f454ac23d..ac3276ca6ea 100644 --- a/pylabrobot/capabilities/rack_reading/chatterbox.py +++ b/pylabrobot/capabilities/rack_reading/chatterbox.py @@ -17,8 +17,9 @@ async def get_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: self._state = RackReaderState.DATAREADY - async def trigger_rack_id_scan(self) -> None: + async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: self._state = RackReaderState.DATAREADY + return "CHATTERBOX" async def get_scan_result(self) -> RackScanResult: return RackScanResult( diff --git a/pylabrobot/capabilities/rack_reading/rack_reader.py b/pylabrobot/capabilities/rack_reading/rack_reader.py index 87b6f700033..9c2510b9b21 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader.py @@ -24,10 +24,6 @@ async def get_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: await self.backend.trigger_rack_scan() - @need_capability_ready - async def trigger_rack_id_scan(self) -> None: - await self.backend.trigger_rack_id_scan() - @need_capability_ready async def get_scan_result(self) -> RackScanResult: return await self.backend.get_scan_result() @@ -123,13 +119,11 @@ async def scan_rack_id( timeout: float = 60.0, poll_interval: float = 1.0, ) -> str: - """Trigger a rack-barcode-only scan and return the completed rack identifier.""" + """Perform a rack-barcode-only scan and return the rack identifier. - initial_state = await self.backend.get_state() - await self.backend.trigger_rack_id_scan() - await self._wait_for_fresh_data_ready( - initial_state=initial_state, - timeout=timeout, - poll_interval=poll_interval, - ) - return await self.backend.get_rack_id() + The backend decides whether this is a one-shot read or a trigger/poll cycle; + ``timeout`` and ``poll_interval`` are forwarded so backends that poll can + honor them. + """ + + return await self.backend.scan_rack_id(timeout=timeout, poll_interval=poll_interval) diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index 03fa0c99169..9b2dc1167ce 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -34,9 +34,9 @@ async def trigger_rack_scan(self) -> None: self.calls.append("trigger_rack_scan") self.state = RackReaderState.SCANNING - async def trigger_rack_id_scan(self) -> None: - self.calls.append("trigger_rack_id_scan") - self.state = RackReaderState.SCANNING + async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: + self.calls.append(f"scan_rack_id:{timeout}:{poll_interval}") + return self.result.rack_id async def get_scan_result(self) -> RackScanResult: self.calls.append("get_scan_result") @@ -112,7 +112,7 @@ async def test_scan_rack_waits_for_new_dataready_cycle(self): ["get_state", "trigger_rack_scan", "get_state", "get_state", "get_scan_result"], ) - async def test_scan_rack_id_triggers_and_returns_rack_id(self): + async def test_scan_rack_id_delegates_to_backend(self): backend = RecordingRackReaderBackend() reader = RackReader(backend=backend) await reader._on_setup() @@ -120,10 +120,7 @@ async def test_scan_rack_id_triggers_and_returns_rack_id(self): rack_id = await reader.scan_rack_id(timeout=1.0, poll_interval=0.01) self.assertEqual(rack_id, "5500135415") - self.assertEqual( - backend.calls[:4], - ["get_state", "trigger_rack_id_scan", "get_state", "get_rack_id"], - ) + self.assertEqual(backend.calls, ["scan_rack_id:1.0:0.01"]) async def test_scan_rack_times_out(self): backend = StuckRackReaderBackend() diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index c0d571f93c3..f44b68f8e8e 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -143,16 +143,16 @@ async def test_trigger_rack_scan(self): expect_json=False, ) - async def test_trigger_rack_id_scan(self): - with patch.object(self.driver, "request", return_value=b"") as request_bytes: - await self.backend.trigger_rack_id_scan() - request_bytes.assert_called_once_with( - "POST", - "/scantube", - data=b"", - headers=None, - expect_json=False, - ) + async def test_scan_rack_id_uses_rackid_endpoint(self): + with patch.object( + self.driver, + "request_json", + return_value={"RackID": "5500135415"}, + ) as request_json: + rack_id = await self.backend.scan_rack_id(timeout=10.0, poll_interval=0.5) + + request_json.assert_called_once_with("GET", "/rackid", data=None, headers=None) + self.assertEqual(rack_id, "5500135415") async def test_get_scan_result(self): payload = { diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index 6d891dcb24d..d0f20e3cbe8 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -49,9 +49,11 @@ async def get_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: await self._request("POST", "/scanbox", data=b"", expect_json=False) - async def trigger_rack_id_scan(self) -> None: - # IO Monitor exposes the rack-barcode-only trigger on /scantube, distinct from full rack scans. - await self._request("POST", "/scantube", data=b"", expect_json=False) + async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: + # IO Monitor's GET /rackid is a one-shot trigger+result on the side barcode reader, + # so timeout/poll_interval are unused here (the driver enforces its own HTTP timeout). + del timeout, poll_interval + return await self.get_rack_id() async def get_scan_result(self) -> RackScanResult: payload = await self._request_json("GET", "/scanresult") From 9124d9d943d6e2a4b17b2ee3d8254a2595c2c3e8 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 16:26:37 -0700 Subject: [PATCH 13/54] Format Micronic+rack-reading files and tighten driver type - ruff format on rack_reader.py, driver.py, micronic_tests.py - annotate response.read() result so mypy --check-untyped-defs is clean --- .../capabilities/rack_reading/rack_reader.py | 4 +- pylabrobot/micronic/code_reader/driver.py | 15 ++-- .../micronic/code_reader/micronic_tests.py | 88 +++++++++++-------- 3 files changed, 60 insertions(+), 47 deletions(-) diff --git a/pylabrobot/capabilities/rack_reading/rack_reader.py b/pylabrobot/capabilities/rack_reading/rack_reader.py index 9c2510b9b21..48a87e55574 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader.py @@ -56,9 +56,7 @@ async def _wait_for_state( if state == target: return state if time.monotonic() >= deadline: - raise RackReaderTimeoutError( - f"Timed out waiting for rack reader to reach {target.value}." - ) + raise RackReaderTimeoutError(f"Timed out waiting for rack reader to reach {target.value}.") await asyncio.sleep(poll_interval) async def _wait_for_fresh_data_ready( diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 9512084eb23..9d172e04a69 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -182,20 +182,23 @@ def _request_sync( for attempt in range(3): try: with request.urlopen(req, timeout=self.timeout) as response: - return response.read() + body: bytes = response.read() + return body except error.HTTPError as exc: body = exc.read() - raise self._as_micronic_error(body, fallback=f"HTTP {exc.code} for {method} {path}") from exc + raise self._as_micronic_error( + body, fallback=f"HTTP {exc.code} for {method} {path}" + ) from exc except error.URLError as exc: if self._is_retryable_url_error(exc) and attempt < 2: time.sleep(0.25) continue - raise MicronicError(f"Failed to reach Micronic server at {self.base_url}: {exc.reason}") from exc + raise MicronicError( + f"Failed to reach Micronic server at {self.base_url}: {exc.reason}" + ) from exc except (ConnectionResetError, http.client.RemoteDisconnected, OSError) as exc: if attempt == 2: - raise MicronicError( - f"Micronic connection failed for {method} {path}: {exc}" - ) from exc + raise MicronicError(f"Micronic connection failed for {method} {path}: {exc}") from exc time.sleep(0.25) raise MicronicError(f"Micronic request failed for {method} {path}.") diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index f44b68f8e8e..00f7b3ef367 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -197,15 +197,18 @@ def setUp(self) -> None: ) async def test_scan_barcode_reads_single_tube_code(self): - with patch.object(self.driver, "request", return_value=b"") as request_bytes, patch.object( - self.driver, - "request_json", - side_effect=[ - {"state": "idle"}, - {"state": "scanning"}, - {"state": "dataready"}, - {"TubeID": ["5007377910"]}, - ], + with ( + patch.object(self.driver, "request", return_value=b"") as request_bytes, + patch.object( + self.driver, + "request_json", + side_effect=[ + {"state": "idle"}, + {"state": "scanning"}, + {"state": "dataready"}, + {"TubeID": ["5007377910"]}, + ], + ), ): barcode = await self.backend.scan_barcode() @@ -219,15 +222,18 @@ async def test_scan_barcode_reads_single_tube_code(self): self.assertEqual(barcode, Barcode("5007377910", "Data Matrix", "bottom")) async def test_scan_barcode_falls_back_to_rackid_payload(self): - with patch.object(self.driver, "request", return_value=b"") as request_bytes, patch.object( - self.driver, - "request_json", - side_effect=[ - {"state": "idle"}, - {"state": "dataready"}, - {"unexpected": "shape"}, - {"RackID": "5007377910"}, - ], + with ( + patch.object(self.driver, "request", return_value=b"") as request_bytes, + patch.object( + self.driver, + "request_json", + side_effect=[ + {"state": "idle"}, + {"state": "dataready"}, + {"unexpected": "shape"}, + {"RackID": "5007377910"}, + ], + ), ): barcode = await self.backend.scan_barcode() @@ -241,17 +247,20 @@ async def test_scan_barcode_falls_back_to_rackid_payload(self): self.assertEqual(barcode.data, "5007377910") async def test_scan_barcode_waits_for_new_dataready_cycle(self): - with patch.object(self.driver, "request", return_value=b"") as request_bytes, patch.object( - self.driver, - "request_json", - side_effect=[ - {"state": "dataready"}, - {"state": "dataready"}, - {"state": "scanning"}, - {"state": "dataready"}, - {"TubeID": ["5007377910"]}, - ], - ) as request_json: + with ( + patch.object(self.driver, "request", return_value=b"") as request_bytes, + patch.object( + self.driver, + "request_json", + side_effect=[ + {"state": "dataready"}, + {"state": "dataready"}, + {"state": "scanning"}, + {"state": "dataready"}, + {"TubeID": ["5007377910"]}, + ], + ) as request_json, + ): barcode = await self.backend.scan_barcode() request_bytes.assert_called_once_with( @@ -265,15 +274,18 @@ async def test_scan_barcode_waits_for_new_dataready_cycle(self): self.assertEqual(request_json.call_count, 5) async def test_scan_barcode_raises_on_unknown_payload(self): - with patch.object(self.driver, "request", return_value=b""), patch.object( - self.driver, - "request_json", - side_effect=[ - {"state": "idle"}, - {"state": "dataready"}, - {"unexpected": "shape"}, - {"still": "bad"}, - ], + with ( + patch.object(self.driver, "request", return_value=b""), + patch.object( + self.driver, + "request_json", + side_effect=[ + {"state": "idle"}, + {"state": "dataready"}, + {"unexpected": "shape"}, + {"still": "bad"}, + ], + ), ): with self.assertRaises(MicronicBarcodeScannerError): await self.backend.scan_barcode() From 18b7f92ff2795f8e77473eca2e0a19059eaad589 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 6 May 2026 09:59:54 -0700 Subject: [PATCH 14/54] Add direct Micronic rack reader driver --- docs/api/pylabrobot.micronic.rst | 16 +- docs/user_guide/micronic/index.md | 61 +- pylabrobot/micronic/__init__.py | 6 + pylabrobot/micronic/code_reader/__init__.py | 8 + .../micronic/code_reader/code_reader.py | 81 ++- .../micronic/code_reader/direct_driver.py | 598 ++++++++++++++++++ pylabrobot/micronic/code_reader/driver.py | 170 ++++- .../micronic/code_reader/micronic_tests.py | 93 ++- .../code_reader/native/twain_scan.cpp | 419 ++++++++++++ .../code_reader/native/twain_scan.exe | Bin 0 -> 248275 bytes .../code_reader/rack_reading_backend.py | 207 ++---- pyproject.toml | 2 +- 12 files changed, 1497 insertions(+), 164 deletions(-) create mode 100644 pylabrobot/micronic/code_reader/direct_driver.py create mode 100644 pylabrobot/micronic/code_reader/native/twain_scan.cpp create mode 100644 pylabrobot/micronic/code_reader/native/twain_scan.exe diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index 046345ca9ea..7df7f4b2599 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -3,7 +3,7 @@ pylabrobot.micronic package =========================== -Micronic Code Reader integration built on the rack-reading and barcode-scanning capabilities. +Micronic integrations built on the rack-reading and barcode-scanning capabilities. Device ------ @@ -16,6 +16,7 @@ Device :recursive: MicronicCodeReader + MicronicDirectCodeReader Driver @@ -30,8 +31,19 @@ Driver MicronicIOMonitorDriver MicronicIOMonitorState + MicronicRackReaderDriver MicronicError +.. currentmodule:: pylabrobot.micronic.code_reader.direct_driver + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MicronicDirectDriver + MicronicDirectRackReaderError + Capabilities ------------ @@ -44,6 +56,8 @@ Capabilities :recursive: MicronicIOMonitorRackReadingBackend + MicronicRackReadingBackend + MicronicDirectRackReadingBackend MicronicRackReaderError .. currentmodule:: pylabrobot.micronic.code_reader.barcode_scanning_backend diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index 4cf49d61638..ed6f3cf31dd 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -1,10 +1,24 @@ # Micronic -PyLabRobot includes a `v1b1` Micronic integration built on the generic `rack_reading` -and `barcode_scanning` capabilities. +PyLabRobot includes `v1b1` Micronic integrations built on the generic +`rack_reading` and `barcode_scanning` capabilities. -This integration targets the `IO Monitor` HTTP server exposed by the Micronic Code Reader -Windows application. +There are two rack-reader drivers: + +- `MicronicIOMonitorDriver` + targets the `IO Monitor` HTTP server exposed by the Micronic Code Reader + Windows application. It supports rack reading and single-tube barcode + scanning. +- `MicronicDirectDriver` + controls the local Windows hardware directly. It acquires the rack image + through the Avision TWAIN source, reads the side rack barcode through the + serial reader, decodes tube DataMatrix codes locally, and returns the same + `RackScanResult` shape through the standard `rack_reading` capability. It + does not call Micronic Code Reader or IO Monitor. + +Both drivers plug into `MicronicCodeReader` through the same `rack_reading` +capability. `MicronicDirectCodeReader` is a convenience frontend that constructs +`MicronicCodeReader` with `MicronicDirectDriver`. ## Supported operations @@ -26,12 +40,12 @@ Single-tube barcode scanning (small spot, separate from the rack scanner): - `GET /rackid` as a compatibility fallback for server variants that expose the decoded tube value there -## Example +## IO Monitor example ```python -from pylabrobot.micronic import MicronicCodeReader +from pylabrobot.micronic import MicronicCodeReader, MicronicIOMonitorDriver -reader = MicronicCodeReader(host="localhost", port=2500) +reader = MicronicCodeReader(driver=MicronicIOMonitorDriver(host="localhost", port=2500)) await reader.setup() try: @@ -48,6 +62,36 @@ finally: await reader.stop() ``` +## Direct hardware example + +Use `MicronicDirectDriver` when the Windows host should own scanner +acquisition, rack-ID reads, and tube decoding without the Micronic application. +The direct path exposes `rack_reading`; it does not expose `barcode_scanning`. + +```python +from pylabrobot.micronic import MicronicCodeReader, MicronicDirectDriver + +reader = MicronicCodeReader( + driver=MicronicDirectDriver( + twain_source="AVA6PlusG", + image_dir=r"C:\ProgramData\Alakascan\data\direct-images", + serial_port="COM4", + keep_images=True, + ) +) +await reader.setup() + +try: + rack_result = await reader.rack_reading.scan_rack(timeout=90.0, poll_interval=1.0) + print(rack_result.rack_id) + print(len([entry for entry in rack_result.entries if entry.tube_id])) + + rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5) + print(rack_id) +finally: + await reader.stop() +``` + ## Notes - The Micronic server is path-based. Use `POST /scanbox`, not `POST /` with raw text. @@ -57,3 +101,6 @@ finally: - `scan_rack` reads every tube barcode and finishes by reading the rack ID, so it typically takes tens of seconds. `scan_rack_id` only reads the rack barcode and completes in a few seconds. +- The direct reader is Windows-only for live hardware scans because it calls the + installed TWAIN stack and the Windows serial-port APIs. Use `image_input` for + offline decode checks. diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index b712a9c18ae..f4656a04708 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -1,10 +1,16 @@ from pylabrobot.micronic.code_reader import ( MicronicBarcodeScannerError, MicronicCodeReader, + MicronicDirectCodeReader, + MicronicDirectDriver, + MicronicDirectRackReaderError, + MicronicDirectRackReadingBackend, MicronicError, MicronicIOMonitorBarcodeScannerBackend, MicronicIOMonitorDriver, MicronicIOMonitorRackReadingBackend, MicronicIOMonitorState, + MicronicRackReaderDriver, + MicronicRackReadingBackend, MicronicRackReaderError, ) diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index b518f640bc5..4705b575e11 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -3,12 +3,20 @@ MicronicIOMonitorBarcodeScannerBackend, ) from pylabrobot.micronic.code_reader.code_reader import MicronicCodeReader +from pylabrobot.micronic.code_reader.code_reader import MicronicDirectCodeReader +from pylabrobot.micronic.code_reader.direct_driver import ( + MicronicDirectDriver, + MicronicDirectRackReaderError, +) from pylabrobot.micronic.code_reader.driver import ( MicronicError, MicronicIOMonitorDriver, MicronicIOMonitorState, + MicronicRackReaderDriver, ) from pylabrobot.micronic.code_reader.rack_reading_backend import ( + MicronicDirectRackReadingBackend, MicronicIOMonitorRackReadingBackend, + MicronicRackReadingBackend, MicronicRackReaderError, ) diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index e62375a5ee8..0f9522eddb6 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -9,12 +9,18 @@ from pylabrobot.device import Device from .barcode_scanning_backend import MicronicIOMonitorBarcodeScannerBackend -from .driver import MicronicIOMonitorDriver -from .rack_reading_backend import MicronicIOMonitorRackReadingBackend +from .direct_driver import MicronicDirectDriver +from .driver import MicronicIOMonitorDriver, MicronicRackReaderDriver +from .rack_reading_backend import MicronicRackReadingBackend class MicronicCodeReader(Device): - """Micronic Code Reader device using the IO Monitor HTTP server.""" + """Micronic rack reader device. + + The rack-reading capability is driven by ``driver``. By default this uses the + Micronic IO Monitor HTTP server, but a ``MicronicDirectDriver`` can be supplied + to control the local scanner hardware directly. + """ def __init__( self, @@ -22,23 +28,72 @@ def __init__( port: int = 2500, timeout: float = 60.0, poll_interval: float = 1.0, - driver: Optional[MicronicIOMonitorDriver] = None, + driver: Optional[MicronicRackReaderDriver] = None, ): if driver is None: driver = MicronicIOMonitorDriver(host=host, port=port, timeout=timeout) super().__init__(driver=driver) - self.driver: MicronicIOMonitorDriver = driver + self.driver: MicronicRackReaderDriver = driver self.default_timeout = timeout self.default_poll_interval = poll_interval - self.rack_reading = RackReader(backend=MicronicIOMonitorRackReadingBackend(driver)) - self.barcode_scanning = BarcodeScanner( - backend=MicronicIOMonitorBarcodeScannerBackend( - driver, - timeout=timeout, - poll_interval=poll_interval, + self.rack_reading = RackReader(backend=MicronicRackReadingBackend(driver)) + self._capabilities = [self.rack_reading] + if isinstance(driver, MicronicIOMonitorDriver): + self.barcode_scanning = BarcodeScanner( + backend=MicronicIOMonitorBarcodeScannerBackend( + driver, + timeout=timeout, + poll_interval=poll_interval, + ) + ) + self._capabilities.append(self.barcode_scanning) + + def serialize(self) -> dict: + return { + **super().serialize(), + "timeout": self.default_timeout, + "poll_interval": self.default_poll_interval, + } + + +class MicronicDirectCodeReader(MicronicCodeReader): + """Micronic rack reader that controls scanner hardware directly. + + This frontend follows the same v1b1 rack-reading capability surface as + ``MicronicCodeReader`` but uses the direct hardware driver instead of the + Micronic IO Monitor HTTP server. + """ + + def __init__( + self, + twain_scanner_path: Optional[str] = None, + twain_source: str = "AVA6PlusG", + image_dir: Optional[str] = None, + serial_port: str = "COM4", + timeout: float = 90.0, + poll_interval: float = 1.0, + serial_timeout_ms: int = 2500, + min_wells: int = 96, + keep_images: bool = False, + image_input: Optional[str] = None, + rack_id_override: Optional[str] = None, + driver: Optional[MicronicDirectDriver] = None, + ): + if driver is None: + driver = MicronicDirectDriver( + twain_scanner_path=twain_scanner_path, + twain_source=twain_source, + image_dir=image_dir, + serial_port=serial_port, + scanner_timeout_ms=int(timeout * 1000), + serial_timeout_ms=serial_timeout_ms, + min_wells=min_wells, + keep_images=keep_images, + image_input=image_input, + rack_id_override=rack_id_override, ) - ) - self._capabilities = [self.rack_reading, self.barcode_scanning] + super().__init__(timeout=timeout, poll_interval=poll_interval, driver=driver) + self.driver: MicronicDirectDriver = driver def serialize(self) -> dict: return { diff --git a/pylabrobot/micronic/code_reader/direct_driver.py b/pylabrobot/micronic/code_reader/direct_driver.py new file mode 100644 index 00000000000..7d3217b7d5c --- /dev/null +++ b/pylabrobot/micronic/code_reader/direct_driver.py @@ -0,0 +1,598 @@ +"""Direct hardware driver for the Micronic rack scanner. + +This driver does not call Micronic Code Reader or IO Monitor. It owns the +local Windows scanner path directly: + +- acquire a rack image through the installed Avision TWAIN source, +- read the rack ID through the side serial barcode reader, +- decode tube DataMatrix codes locally, and +- return the standard PLR rack-reading result. +""" + +from __future__ import annotations + +import asyncio +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Iterable, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.rack_reading import ( + LayoutInfo, + RackReaderState, + RackScanEntry, + RackScanResult, +) + +from .driver import MicronicError, MicronicRackReaderDriver + + +ROWS = "ABCDEFGH" +COLS = 12 +RACK_ROWS = 8 +RACK_COLS = 12 + + +class MicronicDirectRackReaderError(MicronicError): + """Raised when direct Micronic hardware control fails.""" + + +@dataclass(frozen=True) +class DecodeResult: + tube_id: str + method: str + + +class MicronicDirectDriver(MicronicRackReaderDriver): + """Driver that controls the Micronic scanner without the OEM app.""" + + def __init__( + self, + twain_scanner_path: Optional[str] = None, + twain_source: str = "AVA6PlusG", + image_dir: Optional[str] = None, + serial_port: str = "COM4", + scanner_timeout_ms: int = 90000, + serial_timeout_ms: int = 2500, + min_wells: int = 96, + keep_images: bool = False, + image_input: Optional[str] = None, + rack_id_override: Optional[str] = None, + ): + super().__init__() + self.twain_scanner_path = twain_scanner_path or str( + Path(__file__).resolve().parent / "native" / "twain_scan.exe" + ) + self.twain_source = twain_source + self.image_dir = ( + Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "alakascan-direct" + ) + self.serial_port = serial_port + self.scanner_timeout_ms = scanner_timeout_ms + self.serial_timeout_ms = serial_timeout_ms + self.min_wells = min_wells + self.keep_images = keep_images + self.image_input = image_input + self.rack_id_override = rack_id_override + self._state = RackReaderState.IDLE + self._last_result: Optional[RackScanResult] = None + self.last_image_path: Optional[Path] = None + self.last_scan_metadata: dict[str, object] = {} + self.last_decode_metadata: dict[str, object] = {} + + async def setup(self, backend_params: Optional[BackendParams] = None): + del backend_params + self.image_dir.mkdir(parents=True, exist_ok=True) + + async def stop(self): + pass + + def serialize(self) -> dict: + return { + **super().serialize(), + "twain_scanner_path": self.twain_scanner_path, + "twain_source": self.twain_source, + "image_dir": str(self.image_dir), + "serial_port": self.serial_port, + "scanner_timeout_ms": self.scanner_timeout_ms, + "serial_timeout_ms": self.serial_timeout_ms, + "min_wells": self.min_wells, + "keep_images": self.keep_images, + "image_input": self.image_input, + "rack_id_override": self.rack_id_override, + } + + async def get_rack_reader_state(self) -> RackReaderState: + return self._state + + async def trigger_rack_scan(self) -> None: + self._state = RackReaderState.SCANNING + try: + self._last_result = await asyncio.to_thread(self._scan_rack_blocking) + self._state = RackReaderState.DATAREADY + except Exception: + self._state = RackReaderState.IDLE + raise + + async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: + del timeout, poll_interval + return await asyncio.to_thread( + read_rack_id, + serial_port=self.serial_port, + timeout_ms=self.serial_timeout_ms, + rack_id_override=self.rack_id_override, + ) + + async def get_scan_result(self) -> RackScanResult: + if self._last_result is None: + raise MicronicDirectRackReaderError("No direct Micronic rack scan has completed yet.") + return self._last_result + + async def get_rack_id(self) -> str: + if self._last_result is not None: + return self._last_result.rack_id + return await self.scan_rack_id(timeout=0, poll_interval=0) + + async def get_layouts(self) -> list[LayoutInfo]: + return [LayoutInfo(name="8x12")] + + async def get_current_layout(self) -> str: + return "8x12" + + async def set_current_layout(self, layout: str) -> None: + normalized = layout.strip().lower().replace(" ", "") + if normalized not in {"8x12", "96(8x12)", "96"}: + raise MicronicDirectRackReaderError(f"Unsupported direct Micronic rack layout: {layout}") + + def _scan_rack_blocking(self) -> RackScanResult: + self.image_dir.mkdir(parents=True, exist_ok=True) + image_path = ( + self.image_dir / f"micronic_direct_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.bmp" + ) + + self.last_scan_metadata = run_scan( + twain_scanner_path=self.twain_scanner_path, + twain_source=self.twain_source, + output_path=image_path, + timeout_ms=self.scanner_timeout_ms, + image_input=self.image_input, + ) + self.last_image_path = image_path + + rack_id = read_rack_id( + serial_port=self.serial_port, + timeout_ms=self.serial_timeout_ms, + rack_id_override=self.rack_id_override, + ) + decoded, self.last_decode_metadata = decode_image(image_path) + if len(decoded) < self.min_wells: + missing = ", ".join(position for position in iter_positions() if position not in decoded) + raise MicronicDirectRackReaderError( + f"Direct Micronic decode found {len(decoded)} wells; expected at least {self.min_wells}. " + f"Missing: {missing}" + ) + + now = datetime.now() + date_text = now.strftime("%Y%m%d") + time_text = now.strftime("%H%M%S") + entries = [ + RackScanEntry( + position=position, + tube_id=decoded[position].tube_id if position in decoded else None, + status="OK" if position in decoded else "NOREAD", + free_text=decoded[position].method if position in decoded else "", + ) + for position in iter_positions() + ] + + if not self.keep_images and self.image_input is None: + try: + image_path.unlink() + self.last_image_path = None + except OSError: + pass + + return RackScanResult( + rack_id=rack_id, + date=date_text, + time=time_text, + entries=entries, + ) + + +def run_scan( + twain_scanner_path: str, + twain_source: str, + output_path: Path, + timeout_ms: int, + image_input: Optional[str] = None, +) -> dict[str, object]: + if image_input: + source_path = Path(image_input) + if not source_path.exists(): + raise MicronicDirectRackReaderError(f"Image input does not exist: {source_path}") + output_path.write_bytes(source_path.read_bytes()) + return {"stdout": "", "stderr": "", "source": str(source_path)} + + completed = subprocess.run( + [twain_scanner_path, str(output_path), twain_source, str(timeout_ms)], + check=False, + capture_output=True, + text=True, + timeout=(timeout_ms / 1000) + 15, + ) + if completed.returncode != 0: + raise MicronicDirectRackReaderError( + "TWAIN scan failed with exit code " + f"{completed.returncode}: {completed.stderr.strip() or completed.stdout.strip()}" + ) + return { + "stdout": completed.stdout.strip(), + "stderr": completed.stderr.strip(), + "source": twain_source, + } + + +def read_rack_id( + serial_port: str = "COM4", + timeout_ms: int = 2500, + rack_id_override: Optional[str] = None, +) -> str: + if rack_id_override: + return rack_id_override + + if os.name != "nt": + raise MicronicDirectRackReaderError("Rack ID serial read is only supported on Windows.") + + ps_script = rf""" +$ErrorActionPreference = 'Stop' +$port = New-Object System.IO.Ports.SerialPort '{serial_port}', 9600, ([System.IO.Ports.Parity]::Even), 7, ([System.IO.Ports.StopBits]::One) +$port.ReadTimeout = 100 +$port.WriteTimeout = 1000 +$port.Open() +try {{ + $port.DiscardInBuffer() + $bytes = [byte[]](60,116,62,13,10) + $port.Write($bytes, 0, $bytes.Length) + $sw = [Diagnostics.Stopwatch]::StartNew() + $chars = New-Object System.Collections.Generic.List[char] + while ($sw.ElapsedMilliseconds -lt {timeout_ms}) {{ + try {{ + $value = $port.ReadByte() + if ($value -ge 0) {{ + $chars.Add([char]$value) + if ($value -eq 10) {{ break }} + }} + }} catch [System.TimeoutException] {{ + }} + }} + -join $chars +}} finally {{ + if ($port.IsOpen) {{ $port.Close() }} +}} +""" + completed = subprocess.run( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_script], + check=False, + capture_output=True, + text=True, + timeout=(timeout_ms / 1000) + 5, + ) + if completed.returncode != 0: + raise MicronicDirectRackReaderError(f"Rack ID serial read failed: {completed.stderr.strip()}") + + match = re.search(r"\d{6,}", completed.stdout) + return match.group(0) if match else "NOREAD" + + +def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, object]]: + cv2, np, zxingcpp, Image, ImageOps = import_decode_dependencies() + image = Image.open(image_path).convert("L") + full_results = zxingcpp.read_barcodes( + image, + formats=zxingcpp.BarcodeFormat.DataMatrix, + try_rotate=True, + try_downscale=True, + try_invert=True, + ) + + detected: list[tuple[float, float, str]] = [] + for result in full_results: + if not is_tube_id(result.text): + continue + corners = [ + result.position.top_left, + result.position.top_right, + result.position.bottom_right, + result.position.bottom_left, + ] + detected.append( + ( + sum(corner.x for corner in corners) / 4, + sum(corner.y for corner in corners) / 4, + result.text, + ) + ) + + if len(detected) < 24: + raise MicronicDirectRackReaderError( + f"Only {len(detected)} DataMatrix codes were found in the full image." + ) + + xs = fitted_axis(cluster_axis([item[0] for item in detected], RACK_ROWS, 90), RACK_ROWS) + ys = fitted_axis(cluster_axis([item[1] for item in detected], RACK_COLS, 90), RACK_COLS) + x_pitch = abs(xs[-1] - xs[0]) / (RACK_ROWS - 1) + y_pitch = abs(ys[-1] - ys[0]) / (RACK_COLS - 1) + + decoded: dict[str, DecodeResult] = {} + for x, y, tube_id in detected: + scan_col = min(range(RACK_ROWS), key=lambda index: abs(xs[index] - x)) + scan_row = min(range(RACK_COLS), key=lambda index: abs(ys[index] - y)) + if abs(xs[scan_col] - x) > x_pitch * 0.45 or abs(ys[scan_row] - y) > y_pitch * 0.45: + continue + decoded[rack_position(scan_row, scan_col)] = DecodeResult(tube_id=tube_id, method="full-image") + + for scan_row in range(RACK_COLS): + for scan_col in range(RACK_ROWS): + position = rack_position(scan_row, scan_col) + if position in decoded: + continue + crop_result = decode_well_crop( + image, + xs[scan_col], + ys[scan_row], + cv2, + np, + zxingcpp, + Image, + ImageOps, + ) + if crop_result: + decoded[position] = crop_result + + duplicate_ids = find_duplicate_ids(decoded) + if duplicate_ids: + raise MicronicDirectRackReaderError( + f"Duplicate tube IDs decoded from more than one well: {', '.join(duplicate_ids)}" + ) + + metadata = { + "imageSize": image.size, + "fullImageDecoded": len(detected), + "gridX": [round(value, 1) for value in xs], + "gridY": [round(value, 1) for value in ys], + "decodedWells": len(decoded), + "missing": [position for position in iter_positions() if position not in decoded], + } + return decoded, metadata + + +def import_decode_dependencies(): + try: + import cv2 # type: ignore + import numpy as np # type: ignore + import zxingcpp # type: ignore + from PIL import Image, ImageOps # type: ignore + except ImportError as exc: + raise MicronicDirectRackReaderError( + "Direct Micronic decode dependencies are missing. Install pillow, " + "opencv-python-headless, numpy, and zxing-cpp." + ) from exc + return cv2, np, zxingcpp, Image, ImageOps + + +def cluster_axis(values: list[float], expected_count: int, tolerance: float) -> list[float]: + if not values: + raise MicronicDirectRackReaderError( + "No decoded barcode positions are available for grid calibration." + ) + + clusters: list[list[float]] = [] + for value in sorted(values): + if not clusters: + clusters.append([value]) + continue + mean = sum(clusters[-1]) / len(clusters[-1]) + if abs(value - mean) > tolerance: + clusters.append([value]) + else: + clusters[-1].append(value) + + means = [sum(cluster) / len(cluster) for cluster in clusters] + if len(means) == expected_count: + return means + if len(means) >= 2: + return [ + means[0] + index * (means[-1] - means[0]) / (expected_count - 1) + for index in range(expected_count) + ] + raise MicronicDirectRackReaderError( + f"Could not fit {expected_count} grid clusters from {len(values)} decoded positions." + ) + + +def fitted_axis(means: list[float], expected_count: int) -> list[float]: + return [ + means[0] + index * (means[-1] - means[0]) / (expected_count - 1) + for index in range(expected_count) + ] + + +def rack_position(scan_row: int, scan_col: int) -> str: + return f"{ROWS[RACK_ROWS - 1 - scan_col]}{RACK_COLS - scan_row:02d}" + + +def iter_positions() -> Iterable[str]: + for row in ROWS: + for column in range(1, COLS + 1): + yield f"{row}{column:02d}" + + +def is_tube_id(value: object) -> bool: + return isinstance(value, str) and value.isdigit() and len(value) == 10 + + +def decode_well_crop( + image, center_x, center_y, cv2, np, zxingcpp, Image, ImageOps +) -> Optional[DecodeResult]: + for size in [150, 160, 180, 200, 220, 240]: + crop = centered_crop(image, center_x, center_y, size) + decoded = decode_pil_variants(crop, zxingcpp, ImageOps) + if decoded: + return DecodeResult(tube_id=decoded, method=f"crop-{size}") + + for size in [100, 120, 140, 160]: + crop = centered_crop(image, center_x, center_y, size) + decoded = decode_perspective_crop(crop, cv2, np, zxingcpp, Image, ImageOps) + if decoded: + return DecodeResult(tube_id=decoded, method=f"perspective-{size}") + + return None + + +def centered_crop(image, center_x: float, center_y: float, size: int): + half = size / 2 + return image.crop( + ( + int(round(center_x - half)), + int(round(center_y - half)), + int(round(center_x + half)), + int(round(center_y + half)), + ) + ) + + +def decode_pil_variants(crop, zxingcpp, ImageOps) -> Optional[str]: + for variant in [crop, ImageOps.autocontrast(crop), ImageOps.equalize(crop)]: + decoded = decode_with_zxing(variant, zxingcpp, ImageOps) + if decoded: + return decoded + return None + + +def decode_with_zxing(image, zxingcpp, ImageOps) -> Optional[str]: + binarizers = [ + zxingcpp.Binarizer.LocalAverage, + zxingcpp.Binarizer.GlobalHistogram, + zxingcpp.Binarizer.FixedThreshold, + ] + for scale in [1, 2, 3, 4]: + scaled = image if scale == 1 else image.resize((image.width * scale, image.height * scale)) + for invert in [False, True]: + candidate = ImageOps.invert(scaled) if invert else scaled + for border in [0, 20, 50]: + padded = ImageOps.expand(candidate, border=border, fill=255) if border else candidate + for binarizer in binarizers: + for pure in [False, True]: + results = zxingcpp.read_barcodes( + padded, + formats=zxingcpp.BarcodeFormat.DataMatrix, + try_rotate=True, + try_downscale=False, + try_invert=True, + binarizer=binarizer, + is_pure=pure, + ) + for result in results: + if is_tube_id(result.text): + return str(result.text) + return None + + +def order_box(points, np): + points = np.array(points, dtype=np.float32) + sums = points.sum(axis=1) + diffs = np.diff(points, axis=1).ravel() + return np.array( + [ + points[np.argmin(sums)], + points[np.argmin(diffs)], + points[np.argmax(sums)], + points[np.argmax(diffs)], + ], + dtype=np.float32, + ) + + +def decode_perspective_crop(crop, cv2, np, zxingcpp, Image, ImageOps) -> Optional[str]: + crop_array = np.array(crop) + for threshold in [30, 40, 50, 60, 70, 80, 90, 100, 120, 140]: + mask = (crop_array < threshold).astype(np.uint8) * 255 + for candidate_mask in candidate_masks(mask, cv2, np): + if not candidate_mask.any(): + continue + points = np.column_stack(np.where(candidate_mask > 0))[:, ::-1].astype(np.float32) + if len(points) < 40: + continue + rect = cv2.minAreaRect(points) + (rect_x, rect_y), (rect_w, rect_h), _angle = rect + if rect_w < 25 or rect_h < 25 or rect_w > crop.width * 0.9 or rect_h > crop.height * 0.9: + continue + if max(rect_w, rect_h) / max(1, min(rect_w, rect_h)) > 2: + continue + + box = cv2.boxPoints(rect) + center = np.array([rect_x, rect_y], dtype=np.float32) + for margin in [0.9, 1.0, 1.1, 1.2, 1.35]: + source = order_box((box - center) * margin + center, np) + for output_size in [60, 80, 100, 120, 160]: + destination = np.array( + [ + [0, 0], + [output_size - 1, 0], + [output_size - 1, output_size - 1], + [0, output_size - 1], + ], + dtype=np.float32, + ) + matrix = cv2.getPerspectiveTransform(source, destination) + warped = cv2.warpPerspective( + crop_array, matrix, (output_size, output_size), borderValue=255 + ) + for mode_array in perspective_variants(warped, threshold, cv2, Image, ImageOps): + decoded = decode_with_zxing(mode_array, zxingcpp, ImageOps) + if decoded: + return decoded + return None + + +def candidate_masks(mask, cv2, np): + yield mask + number, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, 8) + combined = np.zeros_like(mask) + size = mask.shape[0] + for index in range(1, number): + _x, _y, width, height, area = stats[index] + center_x, center_y = centroids[index] + if area < 15 or width < 8 or height < 8: + continue + if abs(center_x - size / 2) > size * 0.33 or abs(center_y - size / 2) > size * 0.33: + continue + if width > size * 0.85 or height > size * 0.85: + continue + combined[labels == index] = 255 + yield combined + + +def perspective_variants(warped, threshold: int, cv2, Image, ImageOps): + yield Image.fromarray(warped) + yield ImageOps.autocontrast(Image.fromarray(warped)) + _, binary = cv2.threshold(warped, min(220, threshold + 70), 255, cv2.THRESH_BINARY) + yield Image.fromarray(binary) + yield Image.fromarray(255 - binary) + + +def find_duplicate_ids(decoded: dict[str, DecodeResult]) -> list[str]: + seen: dict[str, str] = {} + duplicates: list[str] = [] + for position, result in decoded.items(): + previous = seen.get(result.tube_id) + if previous and previous != position: + duplicates.append(result.tube_id) + seen[result.tube_id] = position + return sorted(set(duplicates)) diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 9d172e04a69..9569d0e3ed0 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -1,4 +1,4 @@ -"""HTTP driver for the Micronic Code Reader IO Monitor server.""" +"""Drivers for Micronic Code Reader rack and barcode integrations.""" from __future__ import annotations @@ -7,15 +7,22 @@ import http.client import json import time +from abc import abstractmethod from typing import Any, Optional from urllib import error, parse, request from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.rack_reading import ( + LayoutInfo, + RackReaderState, + RackScanEntry, + RackScanResult, +) from pylabrobot.device import Driver class MicronicError(Exception): - """Raised when the Micronic IO Monitor HTTP server returns an error.""" + """Raised when Micronic driver operations fail.""" class MicronicIOMonitorState(enum.Enum): @@ -30,7 +37,50 @@ class MicronicIOMonitorState(enum.Enum): DATAREADY = "dataready" -class MicronicIOMonitorDriver(Driver): +class MicronicRackReaderDriver(Driver): + """Driver contract used by the Micronic rack-reading backend.""" + + @abstractmethod + async def get_rack_reader_state(self) -> RackReaderState: + """Return the current rack-reader state.""" + + @abstractmethod + async def trigger_rack_scan(self) -> None: + """Initiate a rack-wide scan.""" + + @abstractmethod + async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: + """Perform a rack-barcode-only scan and return the rack identifier.""" + + @abstractmethod + async def get_scan_result(self) -> RackScanResult: + """Return the most recent rack scan result.""" + + @abstractmethod + async def get_rack_id(self) -> str: + """Return the rack identifier reported by the scanner.""" + + @abstractmethod + async def get_layouts(self) -> list[LayoutInfo]: + """Return supported layouts.""" + + @abstractmethod + async def get_current_layout(self) -> str: + """Return the active layout.""" + + @abstractmethod + async def set_current_layout(self, layout: str) -> None: + """Set the active layout.""" + + +_IOMONITOR_TO_RACK_READER_STATE = { + MicronicIOMonitorState.IDLE: RackReaderState.IDLE, + MicronicIOMonitorState.SCANNING: RackReaderState.SCANNING, + MicronicIOMonitorState.DATAREADY: RackReaderState.DATAREADY, +} + + +class MicronicIOMonitorDriver(MicronicRackReaderDriver): """HTTP transport for the Micronic Code Reader IO Monitor server.""" def __init__( @@ -75,6 +125,70 @@ async def get_iomonitor_state(self) -> MicronicIOMonitorState: except ValueError as exc: raise MicronicError(f"Unknown Micronic state: {state}") from exc + async def get_rack_reader_state(self) -> RackReaderState: + return _IOMONITOR_TO_RACK_READER_STATE[await self.get_iomonitor_state()] + + async def trigger_rack_scan(self) -> None: + await self.request("POST", "/scanbox", data=b"", headers=None, expect_json=False) + + async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: + # IO Monitor's GET /rackid is a one-shot trigger+result on the side barcode reader, + # so timeout/poll_interval are unused here (the HTTP timeout controls the read). + del timeout, poll_interval + return await self.get_rack_id() + + async def get_scan_result(self) -> RackScanResult: + payload = await self.request_json("GET", "/scanresult") + return self._parse_scan_result(payload) + + async def get_rack_id(self) -> str: + payload = await self.request_json("GET", "/rackid", data=None, headers=None) + + if isinstance(payload, dict): + for key in ("RackID", "rackid", "rack_id"): + value = payload.get(key) + if isinstance(value, str): + return value + + raise MicronicError("Micronic rack ID response had an unexpected shape.") + + async def get_layouts(self) -> list[LayoutInfo]: + payload = await self.request_json("GET", "/layoutlist") + + if isinstance(payload, list): + return [LayoutInfo(name=str(item)) for item in payload] + + if isinstance(payload, dict): + for key in ("Layout", "layouts", "layoutlist", "data"): + value = payload.get(key) + if isinstance(value, list): + return [LayoutInfo(name=str(item)) for item in value] + + raise MicronicError("Micronic layout list response had an unexpected shape.") + + async def get_current_layout(self) -> str: + payload = await self.request_json("GET", "/currentlayout") + + if isinstance(payload, str): + return payload + + if isinstance(payload, dict): + for key in ("Layout", "layout", "currentlayout", "name"): + value = payload.get(key) + if isinstance(value, str): + return value + + raise MicronicError("Micronic current layout response had an unexpected shape.") + + async def set_current_layout(self, layout: str) -> None: + await self.request( + "PUT", + "/currentlayout", + data=json.dumps({"Layout": layout}).encode("utf-8"), + headers={"Content-Type": "application/json; charset=utf-8"}, + expect_json=False, + ) + async def wait_for_fresh_data_ready( self, initial_state: MicronicIOMonitorState, @@ -116,6 +230,56 @@ async def get_single_tube_barcode(self) -> str: raise MicronicError("Micronic single-tube scan result had an unexpected shape.") + def _parse_scan_result(self, payload: dict[str, Any]) -> RackScanResult: + positions = self._get_list(payload, "Position") + tube_ids = self._get_list(payload, "TubeID") + statuses = self._get_list(payload, "Status") + free_texts = self._get_list(payload, "FreeText") + + if not positions: + raise MicronicError("Micronic scan result did not include any positions.") + + entries: list[RackScanEntry] = [] + for idx, position in enumerate(positions): + tube_id = self._get_optional_item(tube_ids, idx) + entries.append( + RackScanEntry( + position=str(position), + tube_id=None if tube_id in (None, "") else str(tube_id), + status=str(self._get_required_item(statuses, idx, "Status")), + free_text=str(self._get_optional_item(free_texts, idx) or ""), + ) + ) + + rack_id = payload.get("RackID") + date = payload.get("Date") + time = payload.get("Time") + if not isinstance(rack_id, str) or not isinstance(date, str) or not isinstance(time, str): + raise MicronicError("Micronic scan result did not include RackID/Date/Time.") + + return RackScanResult(rack_id=rack_id, date=date, time=time, entries=entries) + + def _get_list(self, payload: dict[str, Any], key: str) -> list[Any]: + value = payload.get(key) + if value is None: + return [] + if not isinstance(value, list): + raise MicronicError(f"Micronic field {key} was not a list.") + return value + + def _get_required_item(self, items: list[Any], index: int, field_name: str) -> Any: + try: + return items[index] + except IndexError as exc: + raise MicronicError( + f"Micronic field {field_name} was missing an item for position index {index}." + ) from exc + + def _get_optional_item(self, items: list[Any], index: int) -> Any: + if index >= len(items): + return None + return items[index] + async def request( self, method: str, diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 00f7b3ef367..06609a228d6 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -1,15 +1,21 @@ import json +import tempfile import unittest from unittest.mock import MagicMock, patch from urllib import error from pylabrobot.capabilities.barcode_scanning.backend import BarcodeScannerError from pylabrobot.capabilities.rack_reading import RackReaderState -from pylabrobot.micronic import MicronicCodeReader +from pylabrobot.micronic import MicronicCodeReader, MicronicDirectCodeReader from pylabrobot.micronic.code_reader.barcode_scanning_backend import ( MicronicBarcodeScannerError, MicronicIOMonitorBarcodeScannerBackend, ) +from pylabrobot.micronic.code_reader.direct_driver import ( + DecodeResult, + MicronicDirectDriver, + MicronicDirectRackReaderError, +) from pylabrobot.micronic.code_reader.driver import ( MicronicError, MicronicIOMonitorDriver, @@ -17,6 +23,7 @@ ) from pylabrobot.micronic.code_reader.rack_reading_backend import ( MicronicIOMonitorRackReadingBackend, + MicronicRackReadingBackend, ) from pylabrobot.resources.barcode import Barcode @@ -188,6 +195,66 @@ async def test_set_current_layout(self): ) +class TestMicronicDirectDriver(unittest.IsolatedAsyncioTestCase): + async def test_direct_driver_scan_populates_standard_rack_result(self): + with tempfile.TemporaryDirectory() as image_dir: + driver = MicronicDirectDriver( + image_dir=image_dir, + min_wells=2, + keep_images=True, + ) + decoded = { + "A01": DecodeResult(tube_id="1111111111", method="test"), + "A02": DecodeResult(tube_id="2222222222", method="test"), + } + with ( + patch( + "pylabrobot.micronic.code_reader.direct_driver.run_scan", + return_value={"source": "test"}, + ) as run_scan, + patch( + "pylabrobot.micronic.code_reader.direct_driver.read_rack_id", + return_value="9500017722", + ) as read_rack_id, + patch( + "pylabrobot.micronic.code_reader.direct_driver.decode_image", + return_value=(decoded, {"decodedWells": 2}), + ) as decode_image, + ): + await driver.setup() + await driver.trigger_rack_scan() + result = await driver.get_scan_result() + + self.assertEqual(await driver.get_rack_reader_state(), RackReaderState.DATAREADY) + self.assertEqual(result.rack_id, "9500017722") + self.assertEqual(result.entries[0].position, "A01") + self.assertEqual(result.entries[0].tube_id, "1111111111") + self.assertEqual(result.entries[1].tube_id, "2222222222") + self.assertEqual(driver.last_scan_metadata, {"source": "test"}) + self.assertEqual(driver.last_decode_metadata, {"decodedWells": 2}) + run_scan.assert_called_once() + read_rack_id.assert_called_once() + decode_image.assert_called_once() + + async def test_direct_driver_raises_when_scan_result_is_not_ready(self): + driver = MicronicDirectDriver() + with self.assertRaises(MicronicDirectRackReaderError): + await driver.get_scan_result() + + async def test_direct_driver_rejects_unknown_layout(self): + driver = MicronicDirectDriver() + with self.assertRaises(MicronicDirectRackReaderError): + await driver.set_current_layout("384") + + async def test_generic_backend_delegates_to_direct_driver(self): + driver = MicronicDirectDriver(rack_id_override="9500017722") + backend = MicronicRackReadingBackend(driver=driver) + with patch.object(driver, "scan_rack_id", return_value="9500017722") as scan_rack_id: + rack_id = await backend.scan_rack_id(timeout=5.0, poll_interval=0.5) + self.assertEqual(rack_id, "9500017722") + scan_rack_id.assert_called_once_with(timeout=5.0, poll_interval=0.5) + + class TestMicronicIOMonitorBarcodeScannerBackend(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: super().setUp() @@ -362,6 +429,30 @@ async def test_device_exposes_rack_id_only_scan_on_rack_reading(self): self.assertEqual(rack_id, "5500135415") scan_rack_id.assert_called_once_with(timeout=12.0, poll_interval=0.25) + async def test_device_accepts_direct_driver_without_barcode_capability(self): + with tempfile.TemporaryDirectory() as image_dir: + reader = MicronicCodeReader( + timeout=12.0, + poll_interval=0.25, + driver=MicronicDirectDriver(image_dir=image_dir), + ) + with patch.object( + reader.driver, + "get_rack_reader_state", + return_value=RackReaderState.IDLE, + ): + await reader.setup() + try: + self.assertIn(reader.rack_reading, reader._capabilities) + self.assertFalse(hasattr(reader, "barcode_scanning")) + finally: + await reader.stop() + + async def test_direct_frontend_uses_direct_driver(self): + reader = MicronicDirectCodeReader(rack_id_override="9500017722") + self.assertIsInstance(reader.driver, MicronicDirectDriver) + self.assertFalse(hasattr(reader, "barcode_scanning")) + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/micronic/code_reader/native/twain_scan.cpp b/pylabrobot/micronic/code_reader/native/twain_scan.cpp new file mode 100644 index 00000000000..a4b553fa1e0 --- /dev/null +++ b/pylabrobot/micronic/code_reader/native/twain_scan.cpp @@ -0,0 +1,419 @@ +// Minimal TWAIN native-transfer scanner for the Avision AVA6PlusG source. +// +// This is intentionally independent of Micronic Code Reader. It talks to the +// installed TWAIN source manager and the Avision TWAIN data source, then saves +// the transferred DIB as a BMP. + +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include +#include + +#pragma pack(push, 2) +using TW_INT16 = int16_t; +using TW_UINT16 = uint16_t; +using TW_INT32 = int32_t; +using TW_UINT32 = uint32_t; +using TW_BOOL = uint16_t; +using TW_HANDLE = void*; +using TW_MEMREF = void*; + +using TW_STR32 = char[34]; + +struct TW_VERSION { + TW_UINT16 MajorNum; + TW_UINT16 MinorNum; + TW_UINT16 Language; + TW_UINT16 Country; + TW_STR32 Info; +}; + +struct TW_IDENTITY { + TW_UINT32 Id; + TW_VERSION Version; + TW_UINT16 ProtocolMajor; + TW_UINT16 ProtocolMinor; + TW_UINT32 SupportedGroups; + TW_STR32 Manufacturer; + TW_STR32 ProductFamily; + TW_STR32 ProductName; +}; + +struct TW_USERINTERFACE { + TW_BOOL ShowUI; + TW_BOOL ModalUI; + TW_HANDLE hParent; +}; + +struct TW_EVENT { + TW_MEMREF pEvent; + TW_UINT16 TWMessage; +}; + +struct TW_PENDINGXFERS { + TW_UINT16 Count; + TW_UINT32 EOJ; +}; + +struct TW_CAPABILITY { + TW_UINT16 Cap; + TW_UINT16 ConType; + TW_HANDLE hContainer; +}; + +struct TW_ONEVALUE { + TW_UINT16 ItemType; + TW_UINT32 Item; +}; +#pragma pack(pop) + +using DSMEntry = TW_UINT16(WINAPI*)( + TW_IDENTITY* origin, + TW_IDENTITY* dest, + TW_UINT32 dg, + TW_UINT16 dat, + TW_UINT16 msg, + TW_MEMREF data +); + +static constexpr TW_UINT32 DG_CONTROL = 0x0001; +static constexpr TW_UINT32 DG_IMAGE = 0x0002; + +static constexpr TW_UINT16 DAT_CAPABILITY = 0x0001; +static constexpr TW_UINT16 DAT_EVENT = 0x0002; +static constexpr TW_UINT16 DAT_IDENTITY = 0x0003; +static constexpr TW_UINT16 DAT_PARENT = 0x0004; +static constexpr TW_UINT16 DAT_PENDINGXFERS = 0x0005; +static constexpr TW_UINT16 DAT_USERINTERFACE = 0x0009; +static constexpr TW_UINT16 DAT_IMAGENATIVEXFER = 0x0104; + +static constexpr TW_UINT16 MSG_GETFIRST = 0x0004; +static constexpr TW_UINT16 MSG_GETNEXT = 0x0005; +static constexpr TW_UINT16 MSG_OPENDSM = 0x0301; +static constexpr TW_UINT16 MSG_CLOSEDSM = 0x0302; +static constexpr TW_UINT16 MSG_OPENDS = 0x0401; +static constexpr TW_UINT16 MSG_CLOSEDS = 0x0402; +static constexpr TW_UINT16 MSG_DISABLEDS = 0x0501; +static constexpr TW_UINT16 MSG_ENABLEDS = 0x0502; +static constexpr TW_UINT16 MSG_PROCESSEVENT = 0x0601; +static constexpr TW_UINT16 MSG_ENDXFER = 0x0701; +static constexpr TW_UINT16 MSG_GET = 0x0001; +static constexpr TW_UINT16 MSG_SET = 0x0006; + +static constexpr TW_UINT16 MSG_XFERREADY = 0x0101; +static constexpr TW_UINT16 MSG_CLOSEDSREQ = 0x0102; +static constexpr TW_UINT16 MSG_CLOSEDSOK = 0x0103; + +static constexpr TW_UINT16 TWRC_SUCCESS = 0; +static constexpr TW_UINT16 TWRC_FAILURE = 1; +static constexpr TW_UINT16 TWRC_DSEVENT = 4; +static constexpr TW_UINT16 TWRC_NOTDSEVENT = 5; +static constexpr TW_UINT16 TWRC_XFERDONE = 6; +static constexpr TW_UINT16 TWRC_ENDOFLIST = 7; + +static constexpr TW_UINT16 TWON_PROTOCOLMAJOR = 1; +static constexpr TW_UINT16 TWON_PROTOCOLMINOR = 9; +static constexpr TW_UINT16 TWON_ONEVALUE = 0x0005; + +static constexpr TW_UINT16 TWTY_INT16 = 0x0001; +static constexpr TW_UINT16 TWTY_UINT16 = 0x0004; +static constexpr TW_UINT16 TWTY_FIX32 = 0x0007; + +static constexpr TW_UINT16 CAP_XFERCOUNT = 0x0001; +static constexpr TW_UINT16 ICAP_PIXELTYPE = 0x0101; +static constexpr TW_UINT16 ICAP_XFERMECH = 0x0103; +static constexpr TW_UINT16 ICAP_XRESOLUTION = 0x1118; +static constexpr TW_UINT16 ICAP_YRESOLUTION = 0x1119; +static constexpr TW_UINT16 ICAP_BITDEPTH = 0x112b; + +static constexpr TW_UINT16 TWPT_BW = 0; +static constexpr TW_UINT16 TWPT_GRAY = 1; +static constexpr TW_UINT16 TWPT_RGB = 2; +static constexpr TW_UINT16 TWSX_NATIVE = 0; + +static HWND g_hwnd = nullptr; + +static void copy_twstr(TW_STR32 target, const char* source) { + std::memset(target, 0, sizeof(TW_STR32)); + std::strncpy(target, source, sizeof(TW_STR32) - 1); +} + +static const char* rc_name(TW_UINT16 rc) { + switch (rc) { + case TWRC_SUCCESS: return "SUCCESS"; + case TWRC_FAILURE: return "FAILURE"; + case TWRC_DSEVENT: return "DSEVENT"; + case TWRC_NOTDSEVENT: return "NOTDSEVENT"; + case TWRC_XFERDONE: return "XFERDONE"; + case TWRC_ENDOFLIST: return "ENDOFLIST"; + default: return "OTHER"; + } +} + +static bool write_bmp_from_dib(HGLOBAL h_dib, const char* output_path) { + void* data = GlobalLock(h_dib); + if (data == nullptr) { + std::fprintf(stderr, "GlobalLock failed: %lu\n", GetLastError()); + return false; + } + + auto* bih = static_cast(data); + if (bih->biSize < sizeof(BITMAPINFOHEADER)) { + std::fprintf(stderr, "Unexpected DIB header size: %lu\n", static_cast(bih->biSize)); + GlobalUnlock(h_dib); + return false; + } + + const DWORD color_count = bih->biClrUsed + ? bih->biClrUsed + : (bih->biBitCount <= 8 ? (1u << bih->biBitCount) : 0u); + const DWORD palette_bytes = color_count * sizeof(RGBQUAD); + const DWORD pixel_offset = sizeof(BITMAPFILEHEADER) + bih->biSize + palette_bytes; + + DWORD image_bytes = bih->biSizeImage; + if (image_bytes == 0) { + const DWORD width = static_cast(bih->biWidth < 0 ? -bih->biWidth : bih->biWidth); + const DWORD height = static_cast(bih->biHeight < 0 ? -bih->biHeight : bih->biHeight); + const DWORD row_bytes = ((width * bih->biBitCount + 31u) / 32u) * 4u; + image_bytes = row_bytes * height; + } + + BITMAPFILEHEADER bfh{}; + bfh.bfType = 0x4d42; + bfh.bfOffBits = pixel_offset; + bfh.bfSize = pixel_offset + image_bytes; + + HANDLE file = CreateFileA( + output_path, + GENERIC_WRITE, + 0, + nullptr, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr + ); + if (file == INVALID_HANDLE_VALUE) { + std::fprintf(stderr, "CreateFile failed for %s: %lu\n", output_path, GetLastError()); + GlobalUnlock(h_dib); + return false; + } + + DWORD written = 0; + const bool ok = + WriteFile(file, &bfh, sizeof(bfh), &written, nullptr) && + WriteFile(file, data, bih->biSize + palette_bytes + image_bytes, &written, nullptr); + CloseHandle(file); + GlobalUnlock(h_dib); + + if (!ok) { + std::fprintf(stderr, "WriteFile failed: %lu\n", GetLastError()); + return false; + } + + std::printf( + "saved %s width=%ld height=%ld bpp=%u bytes=%lu\n", + output_path, + static_cast(bih->biWidth), + static_cast(bih->biHeight), + static_cast(bih->biBitCount), + static_cast(bfh.bfSize) + ); + return true; +} + +static LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { + return DefWindowProcA(hwnd, msg, wparam, lparam); +} + +static TW_UINT32 fix32_item(TW_INT16 whole) { + return static_cast(whole); +} + +static bool set_onevalue_cap( + DSMEntry dsm_entry, + TW_IDENTITY* app, + TW_IDENTITY* source, + TW_UINT16 cap_id, + TW_UINT16 item_type, + TW_UINT32 item +) { + HGLOBAL handle = GlobalAlloc(GHND, sizeof(TW_ONEVALUE)); + if (handle == nullptr) { + std::fprintf(stderr, "GlobalAlloc failed for cap %u\n", cap_id); + return false; + } + auto* value = static_cast(GlobalLock(handle)); + value->ItemType = item_type; + value->Item = item; + GlobalUnlock(handle); + + TW_CAPABILITY cap{}; + cap.Cap = cap_id; + cap.ConType = TWON_ONEVALUE; + cap.hContainer = handle; + TW_UINT16 rc = dsm_entry(app, source, DG_CONTROL, DAT_CAPABILITY, MSG_SET, &cap); + std::printf("SET cap=%u item=%lu rc=%s(%u)\n", cap_id, static_cast(item), rc_name(rc), rc); + GlobalFree(handle); + return rc == TWRC_SUCCESS; +} + +static HWND create_hidden_parent() { + WNDCLASSA wc{}; + wc.lpfnWndProc = wnd_proc; + wc.hInstance = GetModuleHandleA(nullptr); + wc.lpszClassName = "MoleculesTwainHiddenParent"; + RegisterClassA(&wc); + return CreateWindowExA( + 0, + wc.lpszClassName, + "Molecules TWAIN Hidden Parent", + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, + CW_USEDEFAULT, + 320, + 200, + nullptr, + nullptr, + wc.hInstance, + nullptr + ); +} + +int main(int argc, char** argv) { + const char* output_path = argc > 1 ? argv[1] : "twain_scan.bmp"; + const char* source_match = argc > 2 ? argv[2] : "AVA6PlusG"; + const DWORD timeout_ms = argc > 3 ? static_cast(std::strtoul(argv[3], nullptr, 10)) : 90000u; + + HMODULE twain = LoadLibraryA("TWAIN_32.DLL"); + if (twain == nullptr) { + twain = LoadLibraryA("TWAINDSM.DLL"); + } + if (twain == nullptr) { + std::fprintf(stderr, "Could not load TWAIN source manager: %lu\n", GetLastError()); + return 2; + } + + auto dsm_entry = reinterpret_cast(GetProcAddress(twain, "DSM_Entry")); + if (dsm_entry == nullptr) { + std::fprintf(stderr, "Could not find DSM_Entry: %lu\n", GetLastError()); + return 2; + } + + g_hwnd = create_hidden_parent(); + if (g_hwnd == nullptr) { + std::fprintf(stderr, "Could not create hidden parent window: %lu\n", GetLastError()); + return 2; + } + + TW_IDENTITY app{}; + app.Id = 0; + app.Version.MajorNum = 1; + app.Version.MinorNum = 0; + copy_twstr(app.Version.Info, "Molecules Alakascan"); + app.ProtocolMajor = TWON_PROTOCOLMAJOR; + app.ProtocolMinor = TWON_PROTOCOLMINOR; + app.SupportedGroups = DG_CONTROL | DG_IMAGE; + copy_twstr(app.Manufacturer, "Molecules"); + copy_twstr(app.ProductFamily, "Alakascan"); + copy_twstr(app.ProductName, "alakascan-twain-scan"); + + TW_UINT16 rc = dsm_entry(&app, nullptr, DG_CONTROL, DAT_PARENT, MSG_OPENDSM, &g_hwnd); + std::printf("OPENDSM rc=%s(%u)\n", rc_name(rc), rc); + if (rc != TWRC_SUCCESS) { + return 3; + } + + TW_IDENTITY source{}; + TW_IDENTITY selected{}; + bool found = false; + rc = dsm_entry(&app, nullptr, DG_CONTROL, DAT_IDENTITY, MSG_GETFIRST, &source); + while (rc == TWRC_SUCCESS) { + std::printf("source: %s / %s / %s\n", source.Manufacturer, source.ProductFamily, source.ProductName); + if (std::strstr(source.ProductName, source_match) != nullptr) { + selected = source; + found = true; + } + rc = dsm_entry(&app, nullptr, DG_CONTROL, DAT_IDENTITY, MSG_GETNEXT, &source); + } + if (!found) { + std::fprintf(stderr, "No TWAIN source matching '%s'\n", source_match); + dsm_entry(&app, nullptr, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, &g_hwnd); + return 4; + } + + rc = dsm_entry(&app, nullptr, DG_CONTROL, DAT_IDENTITY, MSG_OPENDS, &selected); + std::printf("OPENDS rc=%s(%u)\n", rc_name(rc), rc); + if (rc != TWRC_SUCCESS) { + dsm_entry(&app, nullptr, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, &g_hwnd); + return 5; + } + + set_onevalue_cap(dsm_entry, &app, &selected, CAP_XFERCOUNT, TWTY_INT16, 1); + set_onevalue_cap(dsm_entry, &app, &selected, ICAP_XFERMECH, TWTY_UINT16, TWSX_NATIVE); + set_onevalue_cap(dsm_entry, &app, &selected, ICAP_PIXELTYPE, TWTY_UINT16, TWPT_GRAY); + set_onevalue_cap(dsm_entry, &app, &selected, ICAP_BITDEPTH, TWTY_UINT16, 8); + set_onevalue_cap(dsm_entry, &app, &selected, ICAP_XRESOLUTION, TWTY_FIX32, fix32_item(600)); + set_onevalue_cap(dsm_entry, &app, &selected, ICAP_YRESOLUTION, TWTY_FIX32, fix32_item(600)); + + TW_USERINTERFACE ui{}; + ui.ShowUI = 0; + ui.ModalUI = 0; + ui.hParent = g_hwnd; + rc = dsm_entry(&app, &selected, DG_CONTROL, DAT_USERINTERFACE, MSG_ENABLEDS, &ui); + std::printf("ENABLEDS rc=%s(%u)\n", rc_name(rc), rc); + if (rc != TWRC_SUCCESS) { + dsm_entry(&app, &selected, DG_CONTROL, DAT_IDENTITY, MSG_CLOSEDS, &selected); + dsm_entry(&app, nullptr, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, &g_hwnd); + return 6; + } + + bool transferred = false; + bool close_requested = false; + const DWORD start = GetTickCount(); + while (!transferred && !close_requested && GetTickCount() - start < timeout_ms) { + MSG msg; + while (PeekMessageA(&msg, nullptr, 0, 0, PM_REMOVE)) { + TW_EVENT event{}; + event.pEvent = &msg; + event.TWMessage = 0; + rc = dsm_entry(&app, &selected, DG_CONTROL, DAT_EVENT, MSG_PROCESSEVENT, &event); + if (rc == TWRC_DSEVENT) { + if (event.TWMessage == MSG_XFERREADY) { + std::printf("XFERREADY\n"); + HGLOBAL h_dib = nullptr; + rc = dsm_entry(&app, &selected, DG_IMAGE, DAT_IMAGENATIVEXFER, MSG_GET, &h_dib); + std::printf("IMAGENATIVEXFER rc=%s(%u) handle=%p\n", rc_name(rc), rc, h_dib); + if ((rc == TWRC_XFERDONE || rc == TWRC_SUCCESS) && h_dib != nullptr) { + transferred = write_bmp_from_dib(h_dib, output_path); + GlobalFree(h_dib); + } + TW_PENDINGXFERS pending{}; + TW_UINT16 erc = dsm_entry(&app, &selected, DG_CONTROL, DAT_PENDINGXFERS, MSG_ENDXFER, &pending); + std::printf("ENDXFER rc=%s(%u) pending=%u\n", rc_name(erc), erc, pending.Count); + break; + } + if (event.TWMessage == MSG_CLOSEDSREQ || event.TWMessage == MSG_CLOSEDSOK) { + std::printf("close requested by source message=%u\n", event.TWMessage); + close_requested = true; + } + } else { + TranslateMessage(&msg); + DispatchMessageA(&msg); + } + } + Sleep(20); + } + + if (!transferred) { + std::fprintf(stderr, "Timed out or no transfer after %lu ms\n", static_cast(timeout_ms)); + } + + dsm_entry(&app, &selected, DG_CONTROL, DAT_USERINTERFACE, MSG_DISABLEDS, &ui); + dsm_entry(&app, &selected, DG_CONTROL, DAT_IDENTITY, MSG_CLOSEDS, &selected); + dsm_entry(&app, nullptr, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, &g_hwnd); + DestroyWindow(g_hwnd); + return transferred ? 0 : 7; +} diff --git a/pylabrobot/micronic/code_reader/native/twain_scan.exe b/pylabrobot/micronic/code_reader/native/twain_scan.exe new file mode 100644 index 0000000000000000000000000000000000000000..e85d712526331dc70afd5a7838d3c6654dcccc1b GIT binary patch literal 248275 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 <>ZBKUg(iJ`}O!glmQud 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 literal 0 HcmV?d00001 diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index d0f20e3cbe8..9963b2d9676 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -1,9 +1,8 @@ -"""Rack-reading backend for the Micronic Code Reader IO Monitor server.""" +"""Rack-reading backend for Micronic rack-reader drivers.""" from __future__ import annotations -import json -from typing import Any, Optional +from typing import Optional from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.rack_reading import ( @@ -11,28 +10,21 @@ RackReaderBackend, RackReaderError, RackReaderState, - RackScanEntry, RackScanResult, ) -from .driver import MicronicError, MicronicIOMonitorDriver, MicronicIOMonitorState +from .direct_driver import MicronicDirectDriver +from .driver import MicronicError, MicronicIOMonitorDriver, MicronicRackReaderDriver class MicronicRackReaderError(MicronicError, RackReaderError): """Raised when Micronic rack-reading operations fail.""" -_IOMONITOR_TO_RACK_READER_STATE = { - MicronicIOMonitorState.IDLE: RackReaderState.IDLE, - MicronicIOMonitorState.SCANNING: RackReaderState.SCANNING, - MicronicIOMonitorState.DATAREADY: RackReaderState.DATAREADY, -} +class MicronicRackReadingBackend(RackReaderBackend): + """Rack-reading backend that delegates to a Micronic rack-reader driver.""" - -class MicronicIOMonitorRackReadingBackend(RackReaderBackend): - """Rack-reading backend for the Micronic Code Reader IO Monitor server.""" - - def __init__(self, driver: MicronicIOMonitorDriver): + def __init__(self, driver: MicronicRackReaderDriver): super().__init__() self.driver = driver @@ -41,149 +33,88 @@ async def _on_setup(self, backend_params: Optional[BackendParams] = None): async def get_state(self) -> RackReaderState: try: - iomonitor_state = await self.driver.get_iomonitor_state() + return await self.driver.get_rack_reader_state() except MicronicError as exc: raise MicronicRackReaderError(str(exc)) from exc - return _IOMONITOR_TO_RACK_READER_STATE[iomonitor_state] async def trigger_rack_scan(self) -> None: - await self._request("POST", "/scanbox", data=b"", expect_json=False) + try: + await self.driver.trigger_rack_scan() + except MicronicError as exc: + raise MicronicRackReaderError(str(exc)) from exc async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - # IO Monitor's GET /rackid is a one-shot trigger+result on the side barcode reader, - # so timeout/poll_interval are unused here (the driver enforces its own HTTP timeout). - del timeout, poll_interval - return await self.get_rack_id() + try: + return await self.driver.scan_rack_id(timeout=timeout, poll_interval=poll_interval) + except MicronicError as exc: + raise MicronicRackReaderError(str(exc)) from exc async def get_scan_result(self) -> RackScanResult: - payload = await self._request_json("GET", "/scanresult") - return self._parse_scan_result(payload) + try: + return await self.driver.get_scan_result() + except MicronicError as exc: + raise MicronicRackReaderError(str(exc)) from exc async def get_rack_id(self) -> str: - payload = await self._request_json("GET", "/rackid") - - if isinstance(payload, dict): - for key in ("RackID", "rackid", "rack_id"): - value = payload.get(key) - if isinstance(value, str): - return value - - raise MicronicRackReaderError("Micronic rack ID response had an unexpected shape.") + try: + return await self.driver.get_rack_id() + except MicronicError as exc: + raise MicronicRackReaderError(str(exc)) from exc async def get_layouts(self) -> list[LayoutInfo]: - payload = await self._request_json("GET", "/layoutlist") - - if isinstance(payload, list): - return [LayoutInfo(name=str(item)) for item in payload] - - if isinstance(payload, dict): - for key in ("Layout", "layouts", "layoutlist", "data"): - value = payload.get(key) - if isinstance(value, list): - return [LayoutInfo(name=str(item)) for item in value] - - raise MicronicRackReaderError("Micronic layout list response had an unexpected shape.") + try: + return await self.driver.get_layouts() + except MicronicError as exc: + raise MicronicRackReaderError(str(exc)) from exc async def get_current_layout(self) -> str: - payload = await self._request_json("GET", "/currentlayout") - - if isinstance(payload, str): - return payload - - if isinstance(payload, dict): - for key in ("Layout", "layout", "currentlayout", "name"): - value = payload.get(key) - if isinstance(value, str): - return value - - raise MicronicRackReaderError("Micronic current layout response had an unexpected shape.") + try: + return await self.driver.get_current_layout() + except MicronicError as exc: + raise MicronicRackReaderError(str(exc)) from exc async def set_current_layout(self, layout: str) -> None: - await self._request( - "PUT", - "/currentlayout", - data=json.dumps({"Layout": layout}).encode("utf-8"), - headers={"Content-Type": "application/json; charset=utf-8"}, - expect_json=False, - ) - - def _parse_scan_result(self, payload: dict[str, Any]) -> RackScanResult: - positions = self._get_list(payload, "Position") - tube_ids = self._get_list(payload, "TubeID") - statuses = self._get_list(payload, "Status") - free_texts = self._get_list(payload, "FreeText") - - if not positions: - raise MicronicRackReaderError("Micronic scan result did not include any positions.") - - entries: list[RackScanEntry] = [] - for idx, position in enumerate(positions): - tube_id = self._get_optional_item(tube_ids, idx) - entries.append( - RackScanEntry( - position=str(position), - tube_id=None if tube_id in (None, "") else str(tube_id), - status=str(self._get_required_item(statuses, idx, "Status")), - free_text=str(self._get_optional_item(free_texts, idx) or ""), - ) - ) + try: + await self.driver.set_current_layout(layout) + except MicronicError as exc: + raise MicronicRackReaderError(str(exc)) from exc - rack_id = payload.get("RackID") - date = payload.get("Date") - time = payload.get("Time") - if not isinstance(rack_id, str) or not isinstance(date, str) or not isinstance(time, str): - raise MicronicRackReaderError("Micronic scan result did not include RackID/Date/Time.") - return RackScanResult(rack_id=rack_id, date=date, time=time, entries=entries) +class MicronicIOMonitorRackReadingBackend(MicronicRackReadingBackend): + """Rack-reading backend for the Micronic Code Reader IO Monitor server.""" - def _get_list(self, payload: dict[str, Any], key: str) -> list[Any]: - value = payload.get(key) - if value is None: - return [] - if not isinstance(value, list): - raise MicronicRackReaderError(f"Micronic field {key} was not a list.") - return value + def __init__(self, driver: MicronicIOMonitorDriver): + super().__init__(driver=driver) - def _get_required_item(self, items: list[Any], index: int, field_name: str) -> Any: - try: - return items[index] - except IndexError as exc: - raise MicronicRackReaderError( - f"Micronic field {field_name} was missing an item for position index {index}." - ) from exc - - def _get_optional_item(self, items: list[Any], index: int) -> Any: - if index >= len(items): - return None - return items[index] - - async def _request_json( - self, - method: str, - path: str, - data: Optional[bytes] = None, - headers: Optional[dict[str, str]] = None, - ) -> Any: - try: - return await self.driver.request_json(method, path, data=data, headers=headers) - except MicronicError as exc: - raise MicronicRackReaderError(str(exc)) from exc - async def _request( +class MicronicDirectRackReadingBackend(MicronicRackReadingBackend): + """Rack-reading backend for direct Micronic hardware control.""" + + def __init__( self, - method: str, - path: str, - data: Optional[bytes] = None, - headers: Optional[dict[str, str]] = None, - expect_json: bool = True, - ) -> bytes: - try: - return await self.driver.request( - method, - path, - data=data, - headers=headers, - expect_json=expect_json, + driver: Optional[MicronicDirectDriver] = None, + twain_scanner_path: Optional[str] = None, + twain_source: str = "AVA6PlusG", + image_dir: Optional[str] = None, + serial_port: str = "COM4", + scanner_timeout_ms: int = 90000, + serial_timeout_ms: int = 2500, + min_wells: int = 96, + keep_images: bool = False, + image_input: Optional[str] = None, + rack_id_override: Optional[str] = None, + ): + if driver is None: + driver = MicronicDirectDriver( + twain_scanner_path=twain_scanner_path, + twain_source=twain_source, + image_dir=image_dir, + serial_port=serial_port, + scanner_timeout_ms=scanner_timeout_ms, + serial_timeout_ms=serial_timeout_ms, + min_wells=min_wells, + keep_images=keep_images, + image_input=image_input, + rack_id_override=rack_id_override, ) - except MicronicError as exc: - raise MicronicRackReaderError(str(exc)) from exc + super().__init__(driver=driver) diff --git a/pyproject.toml b/pyproject.toml index 06d86e3aef2..209e85b0d98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ namespaces = false exclude = ["tools*", "docs*"] [tool.setuptools.package-data] -pylabrobot = ["visualizer/*", "visualizer/img/*", "version.txt"] +pylabrobot = ["visualizer/*", "visualizer/img/*", "version.txt", "micronic/code_reader/native/*"] [tool.ruff] line-length = 100 From 4815cd9cb63f867f1b03cfb0a418f4f5d779dc3a Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 6 May 2026 11:29:09 -0700 Subject: [PATCH 15/54] Remove bundled Micronic scanner helper --- docs/user_guide/micronic/index.md | 47 +- .../micronic/code_reader/code_reader.py | 12 +- .../micronic/code_reader/direct_driver.py | 267 ++++++++++- .../micronic/code_reader/micronic_tests.py | 89 ++++ .../code_reader/native/twain_scan.cpp | 419 ------------------ .../code_reader/native/twain_scan.exe | Bin 248275 -> 0 bytes .../code_reader/rack_reading_backend.py | 12 +- pyproject.toml | 2 +- 8 files changed, 395 insertions(+), 453 deletions(-) delete mode 100644 pylabrobot/micronic/code_reader/native/twain_scan.cpp delete mode 100644 pylabrobot/micronic/code_reader/native/twain_scan.exe diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index ed6f3cf31dd..6906438e566 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -10,11 +10,12 @@ There are two rack-reader drivers: Windows application. It supports rack reading and single-tube barcode scanning. - `MicronicDirectDriver` - controls the local Windows hardware directly. It acquires the rack image - through the Avision TWAIN source, reads the side rack barcode through the - serial reader, decodes tube DataMatrix codes locally, and returns the same - `RackScanResult` shape through the standard `rack_reading` capability. It - does not call Micronic Code Reader or IO Monitor. + controls the local hardware directly. It acquires the rack image through a + configured scanner command, a Windows TWAIN helper available on PATH, or + Ubuntu/Linux SANE `scanimage`; reads the side rack barcode through the serial + reader; decodes tube DataMatrix codes locally; and returns the same + `RackScanResult` shape through the standard `rack_reading` capability. It does + not call Micronic Code Reader or IO Monitor. Both drivers plug into `MicronicCodeReader` through the same `rack_reading` capability. `MicronicDirectCodeReader` is a convenience frontend that constructs @@ -64,15 +65,17 @@ finally: ## Direct hardware example -Use `MicronicDirectDriver` when the Windows host should own scanner -acquisition, rack-ID reads, and tube decoding without the Micronic application. -The direct path exposes `rack_reading`; it does not expose `barcode_scanning`. +Use `MicronicDirectDriver` when the host should own scanner acquisition, rack-ID +reads, and tube decoding without the Micronic application. The direct path +exposes `rack_reading`; it does not expose `barcode_scanning`. ```python from pylabrobot.micronic import MicronicCodeReader, MicronicDirectDriver reader = MicronicCodeReader( driver=MicronicDirectDriver( + scanner_backend="twain", + twain_scanner_path=r"C:\Tools\twain_scan.exe", twain_source="AVA6PlusG", image_dir=r"C:\ProgramData\Alakascan\data\direct-images", serial_port="COM4", @@ -92,6 +95,24 @@ finally: await reader.stop() ``` +On Ubuntu/Linux, use SANE if the scanner is exposed by a SANE backend: + +```python +reader = MicronicCodeReader( + driver=MicronicDirectDriver( + scanner_backend="sane", + sane_device="avision:libusb:001:004", + serial_port="/dev/ttyUSB0", + image_extension="tiff", + ) +) +``` + +For any other acquisition stack, pass `scan_command`. Each command argument is +formatted with `{output_path}`, `{timeout_ms}`, `{twain_source}`, and +`{sane_device}` before execution. The command must write the rack image to +`{output_path}`. + ## Notes - The Micronic server is path-based. Use `POST /scanbox`, not `POST /` with raw text. @@ -101,6 +122,10 @@ finally: - `scan_rack` reads every tube barcode and finishes by reading the rack ID, so it typically takes tens of seconds. `scan_rack_id` only reads the rack barcode and completes in a few seconds. -- The direct reader is Windows-only for live hardware scans because it calls the - installed TWAIN stack and the Windows serial-port APIs. Use `image_input` for - offline decode checks. +- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN + bridge binary; configure `twain_scanner_path`, set `MICRONIC_TWAIN_SCANNER_PATH`, + or put a local helper named `twain_scan`/`twain_scan.exe` on PATH when using + the `twain` backend. +- Ubuntu/Linux scanner control should use SANE `scanimage` or a custom + `scan_command`. Rack-ID reads use `pyserial` on non-Windows systems. +- Use `image_input` for offline decode checks without touching scanner hardware. diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index 0f9522eddb6..f93da0eeb78 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Sequence from pylabrobot.capabilities.barcode_scanning import BarcodeScanner from pylabrobot.capabilities.rack_reading import RackReader @@ -68,8 +68,13 @@ def __init__( self, twain_scanner_path: Optional[str] = None, twain_source: str = "AVA6PlusG", + sane_device: Optional[str] = None, + scanner_backend: str = "auto", + scan_command: Optional[Sequence[str]] = None, + image_extension: Optional[str] = None, image_dir: Optional[str] = None, serial_port: str = "COM4", + rack_id_command: Optional[Sequence[str]] = None, timeout: float = 90.0, poll_interval: float = 1.0, serial_timeout_ms: int = 2500, @@ -83,8 +88,13 @@ def __init__( driver = MicronicDirectDriver( twain_scanner_path=twain_scanner_path, twain_source=twain_source, + sane_device=sane_device, + scanner_backend=scanner_backend, + scan_command=scan_command, + image_extension=image_extension, image_dir=image_dir, serial_port=serial_port, + rack_id_command=rack_id_command, scanner_timeout_ms=int(timeout * 1000), serial_timeout_ms=serial_timeout_ms, min_wells=min_wells, diff --git a/pylabrobot/micronic/code_reader/direct_driver.py b/pylabrobot/micronic/code_reader/direct_driver.py index 7d3217b7d5c..9bcace3f227 100644 --- a/pylabrobot/micronic/code_reader/direct_driver.py +++ b/pylabrobot/micronic/code_reader/direct_driver.py @@ -1,9 +1,10 @@ """Direct hardware driver for the Micronic rack scanner. -This driver does not call Micronic Code Reader or IO Monitor. It owns the -local Windows scanner path directly: +This driver does not call Micronic Code Reader or IO Monitor. It owns the local +scanner path directly: -- acquire a rack image through the installed Avision TWAIN source, +- acquire a rack image through a configured scan command, Windows TWAIN helper, + or SANE ``scanimage`` command, - read the rack ID through the side serial barcode reader, - decode tube DataMatrix codes locally, and - return the standard PLR rack-reading result. @@ -14,12 +15,14 @@ import asyncio import os import re +import shutil import subprocess import tempfile +import time from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Iterable, Optional +from typing import Iterable, Optional, Sequence from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.rack_reading import ( @@ -55,8 +58,13 @@ def __init__( self, twain_scanner_path: Optional[str] = None, twain_source: str = "AVA6PlusG", + sane_device: Optional[str] = None, + scanner_backend: str = "auto", + scan_command: Optional[Sequence[str]] = None, + image_extension: Optional[str] = None, image_dir: Optional[str] = None, serial_port: str = "COM4", + rack_id_command: Optional[Sequence[str]] = None, scanner_timeout_ms: int = 90000, serial_timeout_ms: int = 2500, min_wells: int = 96, @@ -65,14 +73,17 @@ def __init__( rack_id_override: Optional[str] = None, ): super().__init__() - self.twain_scanner_path = twain_scanner_path or str( - Path(__file__).resolve().parent / "native" / "twain_scan.exe" - ) + self.twain_scanner_path = twain_scanner_path self.twain_source = twain_source + self.sane_device = sane_device + self.scanner_backend = scanner_backend + self.scan_command = list(scan_command) if scan_command is not None else None + self.image_extension = normalize_image_extension(image_extension) if image_extension else None self.image_dir = ( Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "alakascan-direct" ) self.serial_port = serial_port + self.rack_id_command = list(rack_id_command) if rack_id_command is not None else None self.scanner_timeout_ms = scanner_timeout_ms self.serial_timeout_ms = serial_timeout_ms self.min_wells = min_wells @@ -97,8 +108,13 @@ def serialize(self) -> dict: **super().serialize(), "twain_scanner_path": self.twain_scanner_path, "twain_source": self.twain_source, + "sane_device": self.sane_device, + "scanner_backend": self.scanner_backend, + "scan_command": self.scan_command, + "image_extension": self.image_extension, "image_dir": str(self.image_dir), "serial_port": self.serial_port, + "rack_id_command": self.rack_id_command, "scanner_timeout_ms": self.scanner_timeout_ms, "serial_timeout_ms": self.serial_timeout_ms, "min_wells": self.min_wells, @@ -151,13 +167,25 @@ async def set_current_layout(self, layout: str) -> None: def _scan_rack_blocking(self) -> RackScanResult: self.image_dir.mkdir(parents=True, exist_ok=True) + image_extension = choose_image_extension( + image_extension=self.image_extension, + image_input=self.image_input, + scanner_backend=self.scanner_backend, + scan_command=self.scan_command, + twain_scanner_path=self.twain_scanner_path, + sane_device=self.sane_device, + ) image_path = ( - self.image_dir / f"micronic_direct_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.bmp" + self.image_dir + / f"micronic_direct_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.{image_extension}" ) self.last_scan_metadata = run_scan( twain_scanner_path=self.twain_scanner_path, twain_source=self.twain_source, + sane_device=self.sane_device, + scanner_backend=self.scanner_backend, + scan_command=self.scan_command, output_path=image_path, timeout_ms=self.scanner_timeout_ms, image_input=self.image_input, @@ -168,6 +196,7 @@ def _scan_rack_blocking(self) -> RackScanResult: serial_port=self.serial_port, timeout_ms=self.serial_timeout_ms, rack_id_override=self.rack_id_override, + rack_id_command=self.rack_id_command, ) decoded, self.last_decode_metadata = decode_image(image_path) if len(decoded) < self.min_wells: @@ -206,10 +235,13 @@ def _scan_rack_blocking(self) -> RackScanResult: def run_scan( - twain_scanner_path: str, - twain_source: str, output_path: Path, timeout_ms: int, + twain_scanner_path: Optional[str] = None, + twain_source: str = "AVA6PlusG", + sane_device: Optional[str] = None, + scanner_backend: str = "auto", + scan_command: Optional[Sequence[str]] = None, image_input: Optional[str] = None, ) -> dict[str, object]: if image_input: @@ -219,22 +251,85 @@ def run_scan( output_path.write_bytes(source_path.read_bytes()) return {"stdout": "", "stderr": "", "source": str(source_path)} - completed = subprocess.run( - [twain_scanner_path, str(output_path), twain_source, str(timeout_ms)], - check=False, - capture_output=True, - text=True, - timeout=(timeout_ms / 1000) + 15, + if scan_command is not None: + command = format_command( + scan_command, + output_path=output_path, + timeout_ms=timeout_ms, + twain_source=twain_source, + sane_device=sane_device or "", + ) + return run_scan_command(command, output_path, timeout_ms, source="command") + + backend = normalize_scanner_backend(scanner_backend) + if backend == "command": + raise MicronicDirectRackReaderError( + "Command scan requested, but scan_command was not configured." + ) + + if backend in {"auto", "twain"}: + resolved_twain_path = resolve_twain_scanner_path(twain_scanner_path) + if resolved_twain_path is not None: + return run_scan_command( + [resolved_twain_path, str(output_path), twain_source, str(timeout_ms)], + output_path, + timeout_ms, + source="twain", + ) + if backend == "twain": + raise MicronicDirectRackReaderError( + "TWAIN scan requested, but no TWAIN helper was configured. Set " + "twain_scanner_path, MICRONIC_TWAIN_SCANNER_PATH, or put twain_scan on PATH." + ) + + if backend in {"auto", "sane"}: + scanimage_path = shutil.which("scanimage") + if scanimage_path is not None: + command = [scanimage_path] + if sane_device: + command.extend(["--device-name", sane_device]) + command.extend(["--format=tiff", "--output-file", str(output_path)]) + return run_scan_command(command, output_path, timeout_ms, source="sane") + if backend == "sane": + raise MicronicDirectRackReaderError( + "SANE scan requested, but scanimage was not found on PATH." + ) + + raise MicronicDirectRackReaderError( + "No direct scan acquisition method is available. Configure scan_command, " + "twain_scanner_path/MICRONIC_TWAIN_SCANNER_PATH, or install SANE scanimage." ) + + +def run_scan_command( + command: Sequence[str], + output_path: Path, + timeout_ms: int, + source: str, +) -> dict[str, object]: + try: + completed = subprocess.run( + list(command), + check=False, + capture_output=True, + text=True, + timeout=(timeout_ms / 1000) + 15, + ) + except FileNotFoundError as exc: + raise MicronicDirectRackReaderError(f"Scan command was not found: {command[0]}") from exc + if completed.returncode != 0: raise MicronicDirectRackReaderError( - "TWAIN scan failed with exit code " + "Scan command failed with exit code " f"{completed.returncode}: {completed.stderr.strip() or completed.stdout.strip()}" ) + if not output_path.exists(): + raise MicronicDirectRackReaderError(f"Scan command did not create image: {output_path}") return { "stdout": completed.stdout.strip(), "stderr": completed.stderr.strip(), - "source": twain_source, + "source": source, + "command": list(command), } @@ -242,12 +337,84 @@ def read_rack_id( serial_port: str = "COM4", timeout_ms: int = 2500, rack_id_override: Optional[str] = None, + rack_id_command: Optional[Sequence[str]] = None, ) -> str: if rack_id_override: return rack_id_override + if rack_id_command is not None: + command = format_command( + rack_id_command, + serial_port=serial_port, + timeout_ms=timeout_ms, + ) + return read_rack_id_command(command, timeout_ms) + + try: + return read_rack_id_pyserial(serial_port=serial_port, timeout_ms=timeout_ms) + except ImportError as exc: + if os.name != "nt": + raise MicronicDirectRackReaderError( + "Rack ID serial read requires pyserial on non-Windows systems." + ) from exc + + return read_rack_id_powershell(serial_port=serial_port, timeout_ms=timeout_ms) + + +def read_rack_id_pyserial(serial_port: str, timeout_ms: int) -> str: + import serial # type: ignore + + deadline = time.monotonic() + timeout_ms / 1000 + chunks: list[bytes] = [] + try: + with serial.Serial( + port=serial_port, + baudrate=9600, + bytesize=serial.SEVENBITS, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_ONE, + timeout=0.1, + write_timeout=1.0, + ) as port: + port.reset_input_buffer() + port.write(b"\r\n") + while time.monotonic() < deadline: + value = port.read(1) + if value: + chunks.append(value) + if value in {b"\r", b"\n"}: + break + except Exception as exc: + raise MicronicDirectRackReaderError(f"Rack ID serial read failed: {exc}") from exc + + return extract_rack_id(b"".join(chunks).decode("utf-8", errors="ignore")) + + +def read_rack_id_command(command: Sequence[str], timeout_ms: int) -> str: + try: + completed = subprocess.run( + list(command), + check=False, + capture_output=True, + text=True, + timeout=(timeout_ms / 1000) + 5, + ) + except FileNotFoundError as exc: + raise MicronicDirectRackReaderError(f"Rack ID command was not found: {command[0]}") from exc + + if completed.returncode != 0: + raise MicronicDirectRackReaderError( + "Rack ID command failed with exit code " + f"{completed.returncode}: {completed.stderr.strip() or completed.stdout.strip()}" + ) + return extract_rack_id(completed.stdout) + + +def read_rack_id_powershell(serial_port: str, timeout_ms: int) -> str: if os.name != "nt": - raise MicronicDirectRackReaderError("Rack ID serial read is only supported on Windows.") + raise MicronicDirectRackReaderError( + "PowerShell rack ID serial read is only supported on Windows." + ) ps_script = rf""" $ErrorActionPreference = 'Stop' @@ -286,10 +453,70 @@ def read_rack_id( if completed.returncode != 0: raise MicronicDirectRackReaderError(f"Rack ID serial read failed: {completed.stderr.strip()}") - match = re.search(r"\d{6,}", completed.stdout) + return extract_rack_id(completed.stdout) + + +def extract_rack_id(text: str) -> str: + match = re.search(r"\d{6,}", text) return match.group(0) if match else "NOREAD" +def normalize_scanner_backend(scanner_backend: str) -> str: + backend = scanner_backend.strip().lower() + if backend not in {"auto", "twain", "sane", "command"}: + raise MicronicDirectRackReaderError( + "Unsupported scanner backend " + f"{scanner_backend!r}; expected 'auto', 'twain', 'sane', or 'command'." + ) + return backend + + +def normalize_image_extension(image_extension: str) -> str: + normalized = image_extension.strip().lstrip(".") + if not normalized: + raise MicronicDirectRackReaderError("image_extension must not be empty.") + return normalized + + +def choose_image_extension( + image_extension: Optional[str], + image_input: Optional[str], + scanner_backend: str, + scan_command: Optional[Sequence[str]], + twain_scanner_path: Optional[str], + sane_device: Optional[str], +) -> str: + if image_extension: + return normalize_image_extension(image_extension) + if image_input and Path(image_input).suffix: + return normalize_image_extension(Path(image_input).suffix) + + backend = normalize_scanner_backend(scanner_backend) + if backend == "sane" or ( + backend == "auto" + and scan_command is None + and twain_scanner_path is None + and (sane_device is not None or os.name != "nt") + ): + return "tiff" + return "bmp" + + +def resolve_twain_scanner_path(twain_scanner_path: Optional[str]) -> Optional[str]: + if twain_scanner_path: + return twain_scanner_path + + env_path = os.environ.get("MICRONIC_TWAIN_SCANNER_PATH") + if env_path: + return env_path + + return shutil.which("twain_scan.exe") or shutil.which("twain_scan") + + +def format_command(command: Sequence[str], **values: object) -> list[str]: + return [part.format(**values) for part in command] + + def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, object]]: cv2, np, zxingcpp, Image, ImageOps = import_decode_dependencies() image = Image.open(image_path).convert("L") diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 06609a228d6..52bdc2d37bc 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -1,6 +1,8 @@ import json +import sys import tempfile import unittest +from pathlib import Path from unittest.mock import MagicMock, patch from urllib import error @@ -15,6 +17,9 @@ DecodeResult, MicronicDirectDriver, MicronicDirectRackReaderError, + choose_image_extension, + read_rack_id, + run_scan, ) from pylabrobot.micronic.code_reader.driver import ( MicronicError, @@ -196,6 +201,11 @@ async def test_set_current_layout(self): class TestMicronicDirectDriver(unittest.IsolatedAsyncioTestCase): + def test_direct_driver_does_not_default_to_packaged_twain_helper(self): + driver = MicronicDirectDriver() + self.assertIsNone(driver.twain_scanner_path) + self.assertIsNone(driver.scan_command) + async def test_direct_driver_scan_populates_standard_rack_result(self): with tempfile.TemporaryDirectory() as image_dir: driver = MicronicDirectDriver( @@ -236,6 +246,85 @@ async def test_direct_driver_scan_populates_standard_rack_result(self): read_rack_id.assert_called_once() decode_image.assert_called_once() + async def test_run_scan_uses_explicit_command(self): + with tempfile.TemporaryDirectory() as image_dir: + output_path = Path(image_dir) / "rack.bmp" + metadata = run_scan( + output_path=output_path, + timeout_ms=1000, + scan_command=[ + sys.executable, + "-c", + "from pathlib import Path; Path(r'{output_path}').write_bytes(b'image')", + ], + ) + + self.assertEqual(metadata["source"], "command") + self.assertTrue(output_path.exists()) + + async def test_run_scan_uses_sane_scanimage_when_requested(self): + output_path = Path("/tmp/micronic-test.tiff") + with ( + patch( + "pylabrobot.micronic.code_reader.direct_driver.shutil.which", + return_value="/usr/bin/scanimage", + ), + patch( + "pylabrobot.micronic.code_reader.direct_driver.run_scan_command", + return_value={"source": "sane"}, + ) as run_scan_command, + ): + metadata = run_scan( + output_path=output_path, + timeout_ms=1000, + scanner_backend="sane", + sane_device="avision:libusb:001:004", + ) + + self.assertEqual(metadata["source"], "sane") + run_scan_command.assert_called_once_with( + [ + "/usr/bin/scanimage", + "--device-name", + "avision:libusb:001:004", + "--format=tiff", + "--output-file", + str(output_path), + ], + output_path, + 1000, + source="sane", + ) + + async def test_run_scan_requires_configured_acquisition(self): + with ( + patch("pylabrobot.micronic.code_reader.direct_driver.shutil.which", return_value=None), + self.assertRaises(MicronicDirectRackReaderError), + ): + run_scan( + output_path=Path("/tmp/micronic-test.bmp"), + timeout_ms=1000, + scanner_backend="twain", + ) + + async def test_choose_image_extension_prefers_sane_tiff_on_non_windows_auto(self): + extension = choose_image_extension( + image_extension=None, + image_input=None, + scanner_backend="auto", + scan_command=None, + twain_scanner_path=None, + sane_device="avision:libusb:001:004", + ) + self.assertEqual(extension, "tiff") + + async def test_read_rack_id_uses_configured_command(self): + rack_id = read_rack_id( + timeout_ms=1000, + rack_id_command=[sys.executable, "-c", "print('rack 9500017722')"], + ) + self.assertEqual(rack_id, "9500017722") + async def test_direct_driver_raises_when_scan_result_is_not_ready(self): driver = MicronicDirectDriver() with self.assertRaises(MicronicDirectRackReaderError): diff --git a/pylabrobot/micronic/code_reader/native/twain_scan.cpp b/pylabrobot/micronic/code_reader/native/twain_scan.cpp deleted file mode 100644 index a4b553fa1e0..00000000000 --- a/pylabrobot/micronic/code_reader/native/twain_scan.cpp +++ /dev/null @@ -1,419 +0,0 @@ -// Minimal TWAIN native-transfer scanner for the Avision AVA6PlusG source. -// -// This is intentionally independent of Micronic Code Reader. It talks to the -// installed TWAIN source manager and the Avision TWAIN data source, then saves -// the transferred DIB as a BMP. - -#define WIN32_LEAN_AND_MEAN -#include - -#include -#include -#include -#include - -#pragma pack(push, 2) -using TW_INT16 = int16_t; -using TW_UINT16 = uint16_t; -using TW_INT32 = int32_t; -using TW_UINT32 = uint32_t; -using TW_BOOL = uint16_t; -using TW_HANDLE = void*; -using TW_MEMREF = void*; - -using TW_STR32 = char[34]; - -struct TW_VERSION { - TW_UINT16 MajorNum; - TW_UINT16 MinorNum; - TW_UINT16 Language; - TW_UINT16 Country; - TW_STR32 Info; -}; - -struct TW_IDENTITY { - TW_UINT32 Id; - TW_VERSION Version; - TW_UINT16 ProtocolMajor; - TW_UINT16 ProtocolMinor; - TW_UINT32 SupportedGroups; - TW_STR32 Manufacturer; - TW_STR32 ProductFamily; - TW_STR32 ProductName; -}; - -struct TW_USERINTERFACE { - TW_BOOL ShowUI; - TW_BOOL ModalUI; - TW_HANDLE hParent; -}; - -struct TW_EVENT { - TW_MEMREF pEvent; - TW_UINT16 TWMessage; -}; - -struct TW_PENDINGXFERS { - TW_UINT16 Count; - TW_UINT32 EOJ; -}; - -struct TW_CAPABILITY { - TW_UINT16 Cap; - TW_UINT16 ConType; - TW_HANDLE hContainer; -}; - -struct TW_ONEVALUE { - TW_UINT16 ItemType; - TW_UINT32 Item; -}; -#pragma pack(pop) - -using DSMEntry = TW_UINT16(WINAPI*)( - TW_IDENTITY* origin, - TW_IDENTITY* dest, - TW_UINT32 dg, - TW_UINT16 dat, - TW_UINT16 msg, - TW_MEMREF data -); - -static constexpr TW_UINT32 DG_CONTROL = 0x0001; -static constexpr TW_UINT32 DG_IMAGE = 0x0002; - -static constexpr TW_UINT16 DAT_CAPABILITY = 0x0001; -static constexpr TW_UINT16 DAT_EVENT = 0x0002; -static constexpr TW_UINT16 DAT_IDENTITY = 0x0003; -static constexpr TW_UINT16 DAT_PARENT = 0x0004; -static constexpr TW_UINT16 DAT_PENDINGXFERS = 0x0005; -static constexpr TW_UINT16 DAT_USERINTERFACE = 0x0009; -static constexpr TW_UINT16 DAT_IMAGENATIVEXFER = 0x0104; - -static constexpr TW_UINT16 MSG_GETFIRST = 0x0004; -static constexpr TW_UINT16 MSG_GETNEXT = 0x0005; -static constexpr TW_UINT16 MSG_OPENDSM = 0x0301; -static constexpr TW_UINT16 MSG_CLOSEDSM = 0x0302; -static constexpr TW_UINT16 MSG_OPENDS = 0x0401; -static constexpr TW_UINT16 MSG_CLOSEDS = 0x0402; -static constexpr TW_UINT16 MSG_DISABLEDS = 0x0501; -static constexpr TW_UINT16 MSG_ENABLEDS = 0x0502; -static constexpr TW_UINT16 MSG_PROCESSEVENT = 0x0601; -static constexpr TW_UINT16 MSG_ENDXFER = 0x0701; -static constexpr TW_UINT16 MSG_GET = 0x0001; -static constexpr TW_UINT16 MSG_SET = 0x0006; - -static constexpr TW_UINT16 MSG_XFERREADY = 0x0101; -static constexpr TW_UINT16 MSG_CLOSEDSREQ = 0x0102; -static constexpr TW_UINT16 MSG_CLOSEDSOK = 0x0103; - -static constexpr TW_UINT16 TWRC_SUCCESS = 0; -static constexpr TW_UINT16 TWRC_FAILURE = 1; -static constexpr TW_UINT16 TWRC_DSEVENT = 4; -static constexpr TW_UINT16 TWRC_NOTDSEVENT = 5; -static constexpr TW_UINT16 TWRC_XFERDONE = 6; -static constexpr TW_UINT16 TWRC_ENDOFLIST = 7; - -static constexpr TW_UINT16 TWON_PROTOCOLMAJOR = 1; -static constexpr TW_UINT16 TWON_PROTOCOLMINOR = 9; -static constexpr TW_UINT16 TWON_ONEVALUE = 0x0005; - -static constexpr TW_UINT16 TWTY_INT16 = 0x0001; -static constexpr TW_UINT16 TWTY_UINT16 = 0x0004; -static constexpr TW_UINT16 TWTY_FIX32 = 0x0007; - -static constexpr TW_UINT16 CAP_XFERCOUNT = 0x0001; -static constexpr TW_UINT16 ICAP_PIXELTYPE = 0x0101; -static constexpr TW_UINT16 ICAP_XFERMECH = 0x0103; -static constexpr TW_UINT16 ICAP_XRESOLUTION = 0x1118; -static constexpr TW_UINT16 ICAP_YRESOLUTION = 0x1119; -static constexpr TW_UINT16 ICAP_BITDEPTH = 0x112b; - -static constexpr TW_UINT16 TWPT_BW = 0; -static constexpr TW_UINT16 TWPT_GRAY = 1; -static constexpr TW_UINT16 TWPT_RGB = 2; -static constexpr TW_UINT16 TWSX_NATIVE = 0; - -static HWND g_hwnd = nullptr; - -static void copy_twstr(TW_STR32 target, const char* source) { - std::memset(target, 0, sizeof(TW_STR32)); - std::strncpy(target, source, sizeof(TW_STR32) - 1); -} - -static const char* rc_name(TW_UINT16 rc) { - switch (rc) { - case TWRC_SUCCESS: return "SUCCESS"; - case TWRC_FAILURE: return "FAILURE"; - case TWRC_DSEVENT: return "DSEVENT"; - case TWRC_NOTDSEVENT: return "NOTDSEVENT"; - case TWRC_XFERDONE: return "XFERDONE"; - case TWRC_ENDOFLIST: return "ENDOFLIST"; - default: return "OTHER"; - } -} - -static bool write_bmp_from_dib(HGLOBAL h_dib, const char* output_path) { - void* data = GlobalLock(h_dib); - if (data == nullptr) { - std::fprintf(stderr, "GlobalLock failed: %lu\n", GetLastError()); - return false; - } - - auto* bih = static_cast(data); - if (bih->biSize < sizeof(BITMAPINFOHEADER)) { - std::fprintf(stderr, "Unexpected DIB header size: %lu\n", static_cast(bih->biSize)); - GlobalUnlock(h_dib); - return false; - } - - const DWORD color_count = bih->biClrUsed - ? bih->biClrUsed - : (bih->biBitCount <= 8 ? (1u << bih->biBitCount) : 0u); - const DWORD palette_bytes = color_count * sizeof(RGBQUAD); - const DWORD pixel_offset = sizeof(BITMAPFILEHEADER) + bih->biSize + palette_bytes; - - DWORD image_bytes = bih->biSizeImage; - if (image_bytes == 0) { - const DWORD width = static_cast(bih->biWidth < 0 ? -bih->biWidth : bih->biWidth); - const DWORD height = static_cast(bih->biHeight < 0 ? -bih->biHeight : bih->biHeight); - const DWORD row_bytes = ((width * bih->biBitCount + 31u) / 32u) * 4u; - image_bytes = row_bytes * height; - } - - BITMAPFILEHEADER bfh{}; - bfh.bfType = 0x4d42; - bfh.bfOffBits = pixel_offset; - bfh.bfSize = pixel_offset + image_bytes; - - HANDLE file = CreateFileA( - output_path, - GENERIC_WRITE, - 0, - nullptr, - CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, - nullptr - ); - if (file == INVALID_HANDLE_VALUE) { - std::fprintf(stderr, "CreateFile failed for %s: %lu\n", output_path, GetLastError()); - GlobalUnlock(h_dib); - return false; - } - - DWORD written = 0; - const bool ok = - WriteFile(file, &bfh, sizeof(bfh), &written, nullptr) && - WriteFile(file, data, bih->biSize + palette_bytes + image_bytes, &written, nullptr); - CloseHandle(file); - GlobalUnlock(h_dib); - - if (!ok) { - std::fprintf(stderr, "WriteFile failed: %lu\n", GetLastError()); - return false; - } - - std::printf( - "saved %s width=%ld height=%ld bpp=%u bytes=%lu\n", - output_path, - static_cast(bih->biWidth), - static_cast(bih->biHeight), - static_cast(bih->biBitCount), - static_cast(bfh.bfSize) - ); - return true; -} - -static LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { - return DefWindowProcA(hwnd, msg, wparam, lparam); -} - -static TW_UINT32 fix32_item(TW_INT16 whole) { - return static_cast(whole); -} - -static bool set_onevalue_cap( - DSMEntry dsm_entry, - TW_IDENTITY* app, - TW_IDENTITY* source, - TW_UINT16 cap_id, - TW_UINT16 item_type, - TW_UINT32 item -) { - HGLOBAL handle = GlobalAlloc(GHND, sizeof(TW_ONEVALUE)); - if (handle == nullptr) { - std::fprintf(stderr, "GlobalAlloc failed for cap %u\n", cap_id); - return false; - } - auto* value = static_cast(GlobalLock(handle)); - value->ItemType = item_type; - value->Item = item; - GlobalUnlock(handle); - - TW_CAPABILITY cap{}; - cap.Cap = cap_id; - cap.ConType = TWON_ONEVALUE; - cap.hContainer = handle; - TW_UINT16 rc = dsm_entry(app, source, DG_CONTROL, DAT_CAPABILITY, MSG_SET, &cap); - std::printf("SET cap=%u item=%lu rc=%s(%u)\n", cap_id, static_cast(item), rc_name(rc), rc); - GlobalFree(handle); - return rc == TWRC_SUCCESS; -} - -static HWND create_hidden_parent() { - WNDCLASSA wc{}; - wc.lpfnWndProc = wnd_proc; - wc.hInstance = GetModuleHandleA(nullptr); - wc.lpszClassName = "MoleculesTwainHiddenParent"; - RegisterClassA(&wc); - return CreateWindowExA( - 0, - wc.lpszClassName, - "Molecules TWAIN Hidden Parent", - WS_OVERLAPPEDWINDOW, - CW_USEDEFAULT, - CW_USEDEFAULT, - 320, - 200, - nullptr, - nullptr, - wc.hInstance, - nullptr - ); -} - -int main(int argc, char** argv) { - const char* output_path = argc > 1 ? argv[1] : "twain_scan.bmp"; - const char* source_match = argc > 2 ? argv[2] : "AVA6PlusG"; - const DWORD timeout_ms = argc > 3 ? static_cast(std::strtoul(argv[3], nullptr, 10)) : 90000u; - - HMODULE twain = LoadLibraryA("TWAIN_32.DLL"); - if (twain == nullptr) { - twain = LoadLibraryA("TWAINDSM.DLL"); - } - if (twain == nullptr) { - std::fprintf(stderr, "Could not load TWAIN source manager: %lu\n", GetLastError()); - return 2; - } - - auto dsm_entry = reinterpret_cast(GetProcAddress(twain, "DSM_Entry")); - if (dsm_entry == nullptr) { - std::fprintf(stderr, "Could not find DSM_Entry: %lu\n", GetLastError()); - return 2; - } - - g_hwnd = create_hidden_parent(); - if (g_hwnd == nullptr) { - std::fprintf(stderr, "Could not create hidden parent window: %lu\n", GetLastError()); - return 2; - } - - TW_IDENTITY app{}; - app.Id = 0; - app.Version.MajorNum = 1; - app.Version.MinorNum = 0; - copy_twstr(app.Version.Info, "Molecules Alakascan"); - app.ProtocolMajor = TWON_PROTOCOLMAJOR; - app.ProtocolMinor = TWON_PROTOCOLMINOR; - app.SupportedGroups = DG_CONTROL | DG_IMAGE; - copy_twstr(app.Manufacturer, "Molecules"); - copy_twstr(app.ProductFamily, "Alakascan"); - copy_twstr(app.ProductName, "alakascan-twain-scan"); - - TW_UINT16 rc = dsm_entry(&app, nullptr, DG_CONTROL, DAT_PARENT, MSG_OPENDSM, &g_hwnd); - std::printf("OPENDSM rc=%s(%u)\n", rc_name(rc), rc); - if (rc != TWRC_SUCCESS) { - return 3; - } - - TW_IDENTITY source{}; - TW_IDENTITY selected{}; - bool found = false; - rc = dsm_entry(&app, nullptr, DG_CONTROL, DAT_IDENTITY, MSG_GETFIRST, &source); - while (rc == TWRC_SUCCESS) { - std::printf("source: %s / %s / %s\n", source.Manufacturer, source.ProductFamily, source.ProductName); - if (std::strstr(source.ProductName, source_match) != nullptr) { - selected = source; - found = true; - } - rc = dsm_entry(&app, nullptr, DG_CONTROL, DAT_IDENTITY, MSG_GETNEXT, &source); - } - if (!found) { - std::fprintf(stderr, "No TWAIN source matching '%s'\n", source_match); - dsm_entry(&app, nullptr, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, &g_hwnd); - return 4; - } - - rc = dsm_entry(&app, nullptr, DG_CONTROL, DAT_IDENTITY, MSG_OPENDS, &selected); - std::printf("OPENDS rc=%s(%u)\n", rc_name(rc), rc); - if (rc != TWRC_SUCCESS) { - dsm_entry(&app, nullptr, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, &g_hwnd); - return 5; - } - - set_onevalue_cap(dsm_entry, &app, &selected, CAP_XFERCOUNT, TWTY_INT16, 1); - set_onevalue_cap(dsm_entry, &app, &selected, ICAP_XFERMECH, TWTY_UINT16, TWSX_NATIVE); - set_onevalue_cap(dsm_entry, &app, &selected, ICAP_PIXELTYPE, TWTY_UINT16, TWPT_GRAY); - set_onevalue_cap(dsm_entry, &app, &selected, ICAP_BITDEPTH, TWTY_UINT16, 8); - set_onevalue_cap(dsm_entry, &app, &selected, ICAP_XRESOLUTION, TWTY_FIX32, fix32_item(600)); - set_onevalue_cap(dsm_entry, &app, &selected, ICAP_YRESOLUTION, TWTY_FIX32, fix32_item(600)); - - TW_USERINTERFACE ui{}; - ui.ShowUI = 0; - ui.ModalUI = 0; - ui.hParent = g_hwnd; - rc = dsm_entry(&app, &selected, DG_CONTROL, DAT_USERINTERFACE, MSG_ENABLEDS, &ui); - std::printf("ENABLEDS rc=%s(%u)\n", rc_name(rc), rc); - if (rc != TWRC_SUCCESS) { - dsm_entry(&app, &selected, DG_CONTROL, DAT_IDENTITY, MSG_CLOSEDS, &selected); - dsm_entry(&app, nullptr, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, &g_hwnd); - return 6; - } - - bool transferred = false; - bool close_requested = false; - const DWORD start = GetTickCount(); - while (!transferred && !close_requested && GetTickCount() - start < timeout_ms) { - MSG msg; - while (PeekMessageA(&msg, nullptr, 0, 0, PM_REMOVE)) { - TW_EVENT event{}; - event.pEvent = &msg; - event.TWMessage = 0; - rc = dsm_entry(&app, &selected, DG_CONTROL, DAT_EVENT, MSG_PROCESSEVENT, &event); - if (rc == TWRC_DSEVENT) { - if (event.TWMessage == MSG_XFERREADY) { - std::printf("XFERREADY\n"); - HGLOBAL h_dib = nullptr; - rc = dsm_entry(&app, &selected, DG_IMAGE, DAT_IMAGENATIVEXFER, MSG_GET, &h_dib); - std::printf("IMAGENATIVEXFER rc=%s(%u) handle=%p\n", rc_name(rc), rc, h_dib); - if ((rc == TWRC_XFERDONE || rc == TWRC_SUCCESS) && h_dib != nullptr) { - transferred = write_bmp_from_dib(h_dib, output_path); - GlobalFree(h_dib); - } - TW_PENDINGXFERS pending{}; - TW_UINT16 erc = dsm_entry(&app, &selected, DG_CONTROL, DAT_PENDINGXFERS, MSG_ENDXFER, &pending); - std::printf("ENDXFER rc=%s(%u) pending=%u\n", rc_name(erc), erc, pending.Count); - break; - } - if (event.TWMessage == MSG_CLOSEDSREQ || event.TWMessage == MSG_CLOSEDSOK) { - std::printf("close requested by source message=%u\n", event.TWMessage); - close_requested = true; - } - } else { - TranslateMessage(&msg); - DispatchMessageA(&msg); - } - } - Sleep(20); - } - - if (!transferred) { - std::fprintf(stderr, "Timed out or no transfer after %lu ms\n", static_cast(timeout_ms)); - } - - dsm_entry(&app, &selected, DG_CONTROL, DAT_USERINTERFACE, MSG_DISABLEDS, &ui); - dsm_entry(&app, &selected, DG_CONTROL, DAT_IDENTITY, MSG_CLOSEDS, &selected); - dsm_entry(&app, nullptr, DG_CONTROL, DAT_PARENT, MSG_CLOSEDSM, &g_hwnd); - DestroyWindow(g_hwnd); - return transferred ? 0 : 7; -} diff --git a/pylabrobot/micronic/code_reader/native/twain_scan.exe b/pylabrobot/micronic/code_reader/native/twain_scan.exe deleted file mode 100644 index e85d712526331dc70afd5a7838d3c6654dcccc1b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 248275 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 <>ZBKUg(iJ`}O!glmQud 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 diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index 9963b2d9676..24f2780d0f1 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Sequence from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.rack_reading import ( @@ -95,8 +95,13 @@ def __init__( driver: Optional[MicronicDirectDriver] = None, twain_scanner_path: Optional[str] = None, twain_source: str = "AVA6PlusG", + sane_device: Optional[str] = None, + scanner_backend: str = "auto", + scan_command: Optional[Sequence[str]] = None, + image_extension: Optional[str] = None, image_dir: Optional[str] = None, serial_port: str = "COM4", + rack_id_command: Optional[Sequence[str]] = None, scanner_timeout_ms: int = 90000, serial_timeout_ms: int = 2500, min_wells: int = 96, @@ -108,8 +113,13 @@ def __init__( driver = MicronicDirectDriver( twain_scanner_path=twain_scanner_path, twain_source=twain_source, + sane_device=sane_device, + scanner_backend=scanner_backend, + scan_command=scan_command, + image_extension=image_extension, image_dir=image_dir, serial_port=serial_port, + rack_id_command=rack_id_command, scanner_timeout_ms=scanner_timeout_ms, serial_timeout_ms=serial_timeout_ms, min_wells=min_wells, diff --git a/pyproject.toml b/pyproject.toml index 209e85b0d98..06d86e3aef2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ namespaces = false exclude = ["tools*", "docs*"] [tool.setuptools.package-data] -pylabrobot = ["visualizer/*", "visualizer/img/*", "version.txt", "micronic/code_reader/native/*"] +pylabrobot = ["visualizer/*", "visualizer/img/*", "version.txt"] [tool.ruff] line-length = 100 From a6c65e2fb74a58976575464795660fc6a709d2ab Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 6 May 2026 11:43:53 -0700 Subject: [PATCH 16/54] Tighten Micronic direct driver review findings --- docs/user_guide/micronic/index.md | 20 ++- .../micronic/code_reader/direct_driver.py | 82 ++++++++--- .../micronic/code_reader/micronic_tests.py | 135 ++++++++++++------ 3 files changed, 175 insertions(+), 62 deletions(-) diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index 6906438e566..69e9d543941 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -15,7 +15,8 @@ There are two rack-reader drivers: Ubuntu/Linux SANE `scanimage`; reads the side rack barcode through the serial reader; decodes tube DataMatrix codes locally; and returns the same `RackScanResult` shape through the standard `rack_reading` capability. It does - not call Micronic Code Reader or IO Monitor. + not call Micronic Code Reader or IO Monitor, and PyLabRobot does not package + any scanner helper executable. Both drivers plug into `MicronicCodeReader` through the same `rack_reading` capability. `MicronicDirectCodeReader` is a convenience frontend that constructs @@ -67,7 +68,10 @@ finally: Use `MicronicDirectDriver` when the host should own scanner acquisition, rack-ID reads, and tube decoding without the Micronic application. The direct path -exposes `rack_reading`; it does not expose `barcode_scanning`. +exposes `rack_reading`; it does not expose `barcode_scanning`. The operator is +responsible for installing any OS-level scanner bridge (`twain_scan`, +`scanimage`, or a custom command) and the local Python decode dependencies in +the runtime environment. ```python from pylabrobot.micronic import MicronicCodeReader, MicronicDirectDriver @@ -123,9 +127,13 @@ formatted with `{output_path}`, `{timeout_ms}`, `{twain_source}`, and it typically takes tens of seconds. `scan_rack_id` only reads the rack barcode and completes in a few seconds. - TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN - bridge binary; configure `twain_scanner_path`, set `MICRONIC_TWAIN_SCANNER_PATH`, - or put a local helper named `twain_scan`/`twain_scan.exe` on PATH when using - the `twain` backend. + bridge binary and does not install one for you; configure + `twain_scanner_path`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper + named `twain_scan`/`twain_scan.exe` on PATH when using the `twain` backend. - Ubuntu/Linux scanner control should use SANE `scanimage` or a custom - `scan_command`. Rack-ID reads use `pyserial` on non-Windows systems. + `scan_command`. PyLabRobot does not install SANE or vendor scanner drivers. + Rack-ID reads use `pyserial` on non-Windows systems. +- Direct image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and + `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot + when using the direct driver. - Use `image_input` for offline decode checks without touching scanner hardware. diff --git a/pylabrobot/micronic/code_reader/direct_driver.py b/pylabrobot/micronic/code_reader/direct_driver.py index 9bcace3f227..53c3edd2a8e 100644 --- a/pylabrobot/micronic/code_reader/direct_driver.py +++ b/pylabrobot/micronic/code_reader/direct_driver.py @@ -3,8 +3,8 @@ This driver does not call Micronic Code Reader or IO Monitor. It owns the local scanner path directly: -- acquire a rack image through a configured scan command, Windows TWAIN helper, - or SANE ``scanimage`` command, +- acquire a rack image through a user-supplied scan command, Windows TWAIN + helper, or SANE ``scanimage`` command, - read the rack ID through the side serial barcode reader, - decode tube DataMatrix codes locally, and - return the standard PLR rack-reading result. @@ -16,7 +16,7 @@ import os import re import shutil -import subprocess +import subprocess # nosec B404 - local scanner/rack-id helper execution is the interface. import tempfile import time from dataclasses import dataclass @@ -92,6 +92,9 @@ def __init__( self.rack_id_override = rack_id_override self._state = RackReaderState.IDLE self._last_result: Optional[RackScanResult] = None + self._scan_task: Optional[asyncio.Future[RackScanResult]] = None + self._scan_error: Optional[Exception] = None + self._reported_scanning_since_trigger = False self.last_image_path: Optional[Path] = None self.last_scan_metadata: dict[str, object] = {} self.last_decode_metadata: dict[str, object] = {} @@ -101,7 +104,10 @@ async def setup(self, backend_params: Optional[BackendParams] = None): self.image_dir.mkdir(parents=True, exist_ok=True) async def stop(self): - pass + scan_task = self._scan_task + if scan_task is not None and not scan_task.done(): + await scan_task + self._complete_finished_scan_task() def serialize(self) -> dict: return { @@ -124,16 +130,23 @@ def serialize(self) -> dict: } async def get_rack_reader_state(self) -> RackReaderState: + if self._state == RackReaderState.SCANNING and not self._reported_scanning_since_trigger: + self._reported_scanning_since_trigger = True + return self._state + self._complete_finished_scan_task() + if self._scan_error is not None: + raise self._scan_error return self._state async def trigger_rack_scan(self) -> None: + self._complete_finished_scan_task() + if self._scan_task is not None and not self._scan_task.done(): + raise MicronicDirectRackReaderError("Direct Micronic rack scan is already in progress.") self._state = RackReaderState.SCANNING - try: - self._last_result = await asyncio.to_thread(self._scan_rack_blocking) - self._state = RackReaderState.DATAREADY - except Exception: - self._state = RackReaderState.IDLE - raise + self._scan_error = None + self._reported_scanning_since_trigger = False + loop = asyncio.get_running_loop() + self._scan_task = loop.run_in_executor(None, self._scan_rack_blocking) async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: del timeout, poll_interval @@ -142,9 +155,15 @@ async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: serial_port=self.serial_port, timeout_ms=self.serial_timeout_ms, rack_id_override=self.rack_id_override, + rack_id_command=self.rack_id_command, ) async def get_scan_result(self) -> RackScanResult: + self._complete_finished_scan_task() + if self._scan_error is not None: + raise self._scan_error + if self._state == RackReaderState.SCANNING: + raise MicronicDirectRackReaderError("Direct Micronic rack scan is still in progress.") if self._last_result is None: raise MicronicDirectRackReaderError("No direct Micronic rack scan has completed yet.") return self._last_result @@ -154,6 +173,27 @@ async def get_rack_id(self) -> str: return self._last_result.rack_id return await self.scan_rack_id(timeout=0, poll_interval=0) + def _complete_finished_scan_task(self) -> None: + if self._scan_task is not None and self._scan_task.done(): + self._complete_scan_task(self._scan_task) + + def _complete_scan_task(self, task: asyncio.Future[RackScanResult]) -> None: + if task is not self._scan_task: + return + + self._scan_task = None + try: + self._last_result = task.result() + except asyncio.CancelledError: + self._scan_error = MicronicDirectRackReaderError("Direct Micronic rack scan was cancelled.") + self._state = RackReaderState.IDLE + except Exception as exc: + self._scan_error = exc + self._state = RackReaderState.IDLE + else: + self._scan_error = None + self._state = RackReaderState.DATAREADY + async def get_layouts(self) -> list[LayoutInfo]: return [LayoutInfo(name="8x12")] @@ -308,7 +348,7 @@ def run_scan_command( source: str, ) -> dict[str, object]: try: - completed = subprocess.run( + completed = subprocess.run( # nosec B603 - operator-configured command, shell=False. list(command), check=False, capture_output=True, @@ -392,7 +432,7 @@ def read_rack_id_pyserial(serial_port: str, timeout_ms: int) -> str: def read_rack_id_command(command: Sequence[str], timeout_ms: int) -> str: try: - completed = subprocess.run( + completed = subprocess.run( # nosec B603 - operator-configured command, shell=False. list(command), check=False, capture_output=True, @@ -416,9 +456,14 @@ def read_rack_id_powershell(serial_port: str, timeout_ms: int) -> str: "PowerShell rack ID serial read is only supported on Windows." ) + quoted_serial_port = powershell_single_quote(serial_port) + powershell_path = shutil.which("powershell.exe") or shutil.which("powershell") + if powershell_path is None: + raise MicronicDirectRackReaderError("PowerShell executable was not found on PATH.") + ps_script = rf""" $ErrorActionPreference = 'Stop' -$port = New-Object System.IO.Ports.SerialPort '{serial_port}', 9600, ([System.IO.Ports.Parity]::Even), 7, ([System.IO.Ports.StopBits]::One) +$port = New-Object System.IO.Ports.SerialPort {quoted_serial_port}, 9600, ([System.IO.Ports.Parity]::Even), 7, ([System.IO.Ports.StopBits]::One) $port.ReadTimeout = 100 $port.WriteTimeout = 1000 $port.Open() @@ -443,8 +488,8 @@ def read_rack_id_powershell(serial_port: str, timeout_ms: int) -> str: if ($port.IsOpen) {{ $port.Close() }} }} """ - completed = subprocess.run( - ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_script], + completed = subprocess.run( # nosec B603 - fixed PowerShell path with escaped port input. + [powershell_path, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_script], check=False, capture_output=True, text=True, @@ -456,6 +501,10 @@ def read_rack_id_powershell(serial_port: str, timeout_ms: int) -> str: return extract_rack_id(completed.stdout) +def powershell_single_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + def extract_rack_id(text: str) -> str: match = re.search(r"\d{6,}", text) return match.group(0) if match else "NOREAD" @@ -519,7 +568,8 @@ def format_command(command: Sequence[str], **values: object) -> list[str]: def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, object]]: cv2, np, zxingcpp, Image, ImageOps = import_decode_dependencies() - image = Image.open(image_path).convert("L") + with Image.open(image_path) as loaded_image: + image = loaded_image.convert("L") full_results = zxingcpp.read_barcodes( image, formats=zxingcpp.BarcodeFormat.DataMatrix, diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 52bdc2d37bc..bf17782aa3b 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -1,3 +1,4 @@ +import asyncio import json import sys import tempfile @@ -18,6 +19,7 @@ MicronicDirectDriver, MicronicDirectRackReaderError, choose_image_extension, + powershell_single_quote, read_rack_id, run_scan, ) @@ -33,6 +35,14 @@ from pylabrobot.resources.barcode import Barcode +async def wait_for_direct_dataready(driver: MicronicDirectDriver) -> None: + for _ in range(100): + if await driver.get_rack_reader_state() == RackReaderState.DATAREADY: + return + await asyncio.sleep(0.01) + raise AssertionError("Direct Micronic test scan did not reach dataready.") + + class TestMicronicIOMonitorDriver(unittest.IsolatedAsyncioTestCase): async def test_request_sync_retries_connection_reset(self): driver = MicronicIOMonitorDriver() @@ -233,6 +243,7 @@ async def test_direct_driver_scan_populates_standard_rack_result(self): ): await driver.setup() await driver.trigger_rack_scan() + await wait_for_direct_dataready(driver) result = await driver.get_scan_result() self.assertEqual(await driver.get_rack_reader_state(), RackReaderState.DATAREADY) @@ -246,6 +257,38 @@ async def test_direct_driver_scan_populates_standard_rack_result(self): read_rack_id.assert_called_once() decode_image.assert_called_once() + async def test_direct_reader_can_scan_twice_after_dataready(self): + with tempfile.TemporaryDirectory() as image_dir: + reader = MicronicCodeReader( + driver=MicronicDirectDriver( + image_dir=image_dir, + min_wells=1, + keep_images=True, + ) + ) + decoded = {"A01": DecodeResult(tube_id="1111111111", method="test")} + with ( + patch( + "pylabrobot.micronic.code_reader.direct_driver.run_scan", + return_value={"source": "test"}, + ) as run_scan, + patch( + "pylabrobot.micronic.code_reader.direct_driver.read_rack_id", + return_value="9500017722", + ), + patch( + "pylabrobot.micronic.code_reader.direct_driver.decode_image", + return_value=(decoded, {"decodedWells": 1}), + ), + ): + await reader.setup() + first = await reader.rack_reading.scan_rack(timeout=1.0, poll_interval=0.01) + second = await reader.rack_reading.scan_rack(timeout=1.0, poll_interval=0.01) + + self.assertEqual(first.rack_id, "9500017722") + self.assertEqual(second.rack_id, "9500017722") + self.assertEqual(run_scan.call_count, 2) + async def test_run_scan_uses_explicit_command(self): with tempfile.TemporaryDirectory() as image_dir: output_path = Path(image_dir) / "rack.bmp" @@ -263,49 +306,51 @@ async def test_run_scan_uses_explicit_command(self): self.assertTrue(output_path.exists()) async def test_run_scan_uses_sane_scanimage_when_requested(self): - output_path = Path("/tmp/micronic-test.tiff") - with ( - patch( - "pylabrobot.micronic.code_reader.direct_driver.shutil.which", - return_value="/usr/bin/scanimage", - ), - patch( - "pylabrobot.micronic.code_reader.direct_driver.run_scan_command", - return_value={"source": "sane"}, - ) as run_scan_command, - ): - metadata = run_scan( - output_path=output_path, - timeout_ms=1000, - scanner_backend="sane", - sane_device="avision:libusb:001:004", - ) + with tempfile.TemporaryDirectory() as image_dir: + output_path = Path(image_dir) / "micronic-test.tiff" + with ( + patch( + "pylabrobot.micronic.code_reader.direct_driver.shutil.which", + return_value="/usr/bin/scanimage", + ), + patch( + "pylabrobot.micronic.code_reader.direct_driver.run_scan_command", + return_value={"source": "sane"}, + ) as run_scan_command, + ): + metadata = run_scan( + output_path=output_path, + timeout_ms=1000, + scanner_backend="sane", + sane_device="avision:libusb:001:004", + ) - self.assertEqual(metadata["source"], "sane") - run_scan_command.assert_called_once_with( - [ - "/usr/bin/scanimage", - "--device-name", - "avision:libusb:001:004", - "--format=tiff", - "--output-file", - str(output_path), - ], - output_path, - 1000, - source="sane", - ) + self.assertEqual(metadata["source"], "sane") + run_scan_command.assert_called_once_with( + [ + "/usr/bin/scanimage", + "--device-name", + "avision:libusb:001:004", + "--format=tiff", + "--output-file", + str(output_path), + ], + output_path, + 1000, + source="sane", + ) async def test_run_scan_requires_configured_acquisition(self): - with ( - patch("pylabrobot.micronic.code_reader.direct_driver.shutil.which", return_value=None), - self.assertRaises(MicronicDirectRackReaderError), - ): - run_scan( - output_path=Path("/tmp/micronic-test.bmp"), - timeout_ms=1000, - scanner_backend="twain", - ) + with tempfile.TemporaryDirectory() as image_dir: + with ( + patch("pylabrobot.micronic.code_reader.direct_driver.shutil.which", return_value=None), + self.assertRaises(MicronicDirectRackReaderError), + ): + run_scan( + output_path=Path(image_dir) / "micronic-test.bmp", + timeout_ms=1000, + scanner_backend="twain", + ) async def test_choose_image_extension_prefers_sane_tiff_on_non_windows_auto(self): extension = choose_image_extension( @@ -325,6 +370,16 @@ async def test_read_rack_id_uses_configured_command(self): ) self.assertEqual(rack_id, "9500017722") + def test_powershell_single_quote_escapes_serial_port(self): + self.assertEqual(powershell_single_quote("COM4"), "'COM4'") + self.assertEqual(powershell_single_quote("COM'4"), "'COM''4'") + + async def test_direct_driver_scan_rack_id_uses_configured_command(self): + driver = MicronicDirectDriver( + rack_id_command=[sys.executable, "-c", "print('rack 9500017722')"] + ) + self.assertEqual(await driver.scan_rack_id(timeout=1.0, poll_interval=0.1), "9500017722") + async def test_direct_driver_raises_when_scan_result_is_not_ready(self): driver = MicronicDirectDriver() with self.assertRaises(MicronicDirectRackReaderError): From de866268869804003a25ae399d92d09ef6378910 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 6 May 2026 11:48:46 -0700 Subject: [PATCH 17/54] Guard Micronic direct scan state transitions --- .../micronic/code_reader/direct_driver.py | 15 ++++- .../micronic/code_reader/micronic_tests.py | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/pylabrobot/micronic/code_reader/direct_driver.py b/pylabrobot/micronic/code_reader/direct_driver.py index 53c3edd2a8e..9d83723736c 100644 --- a/pylabrobot/micronic/code_reader/direct_driver.py +++ b/pylabrobot/micronic/code_reader/direct_driver.py @@ -106,8 +106,15 @@ async def setup(self, backend_params: Optional[BackendParams] = None): async def stop(self): scan_task = self._scan_task if scan_task is not None and not scan_task.done(): - await scan_task + try: + await scan_task + except asyncio.CancelledError: + pass + except Exception: + pass self._complete_finished_scan_task() + if self._scan_error is not None: + raise self._scan_error def serialize(self) -> dict: return { @@ -142,6 +149,7 @@ async def trigger_rack_scan(self) -> None: self._complete_finished_scan_task() if self._scan_task is not None and not self._scan_task.done(): raise MicronicDirectRackReaderError("Direct Micronic rack scan is already in progress.") + self._last_result = None self._state = RackReaderState.SCANNING self._scan_error = None self._reported_scanning_since_trigger = False @@ -169,6 +177,11 @@ async def get_scan_result(self) -> RackScanResult: return self._last_result async def get_rack_id(self) -> str: + self._complete_finished_scan_task() + if self._scan_error is not None: + raise self._scan_error + if self._state == RackReaderState.SCANNING: + raise MicronicDirectRackReaderError("Direct Micronic rack scan is still in progress.") if self._last_result is not None: return self._last_result.rack_id return await self.scan_rack_id(timeout=0, poll_interval=0) diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index bf17782aa3b..8ee4a05c169 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -289,6 +289,61 @@ async def test_direct_reader_can_scan_twice_after_dataready(self): self.assertEqual(second.rack_id, "9500017722") self.assertEqual(run_scan.call_count, 2) + async def test_direct_driver_get_rack_id_does_not_return_stale_result_while_scanning(self): + with tempfile.TemporaryDirectory() as image_dir: + driver = MicronicDirectDriver( + image_dir=image_dir, + min_wells=1, + keep_images=True, + ) + decoded = {"A01": DecodeResult(tube_id="1111111111", method="test")} + + def slow_scan(*args, **kwargs): + del args, kwargs + import time + + time.sleep(0.05) + return {"source": "test"} + + with ( + patch( + "pylabrobot.micronic.code_reader.direct_driver.run_scan", + return_value={"source": "test"}, + ), + patch( + "pylabrobot.micronic.code_reader.direct_driver.read_rack_id", + return_value="9500017722", + ), + patch( + "pylabrobot.micronic.code_reader.direct_driver.decode_image", + return_value=(decoded, {"decodedWells": 1}), + ), + ): + await driver.setup() + await driver.trigger_rack_scan() + await wait_for_direct_dataready(driver) + self.assertEqual(await driver.get_rack_id(), "9500017722") + + with ( + patch( + "pylabrobot.micronic.code_reader.direct_driver.run_scan", + side_effect=slow_scan, + ), + patch( + "pylabrobot.micronic.code_reader.direct_driver.read_rack_id", + return_value="9500017723", + ), + patch( + "pylabrobot.micronic.code_reader.direct_driver.decode_image", + return_value=(decoded, {"decodedWells": 1}), + ), + ): + await driver.trigger_rack_scan() + with self.assertRaises(MicronicDirectRackReaderError): + await driver.get_rack_id() + await wait_for_direct_dataready(driver) + self.assertEqual(await driver.get_rack_id(), "9500017723") + async def test_run_scan_uses_explicit_command(self): with tempfile.TemporaryDirectory() as image_dir: output_path = Path(image_dir) / "rack.bmp" From 7e0e82acdccde82bc66cf01f957c07787d908b9c Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 6 May 2026 12:18:39 -0700 Subject: [PATCH 18/54] Simplify Micronic direct rack reader --- docs/api/pylabrobot.micronic.rst | 28 +- docs/user_guide/capabilities/rack-reading.md | 11 +- docs/user_guide/micronic/index.md | 86 +--- .../barcode_scanning_tests.py | 49 --- pylabrobot/micronic/__init__.py | 6 - pylabrobot/micronic/code_reader/__init__.py | 8 - .../code_reader/barcode_scanning_backend.py | 56 --- .../micronic/code_reader/code_reader.py | 32 +- .../micronic/code_reader/direct_driver.py | 86 +--- pylabrobot/micronic/code_reader/driver.py | 362 +--------------- .../micronic/code_reader/micronic_tests.py | 390 +----------------- .../code_reader/rack_reading_backend.py | 55 +-- 12 files changed, 80 insertions(+), 1089 deletions(-) delete mode 100644 pylabrobot/capabilities/barcode_scanning/barcode_scanning_tests.py delete mode 100644 pylabrobot/micronic/code_reader/barcode_scanning_backend.py diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index 7df7f4b2599..aa6cdafd5c1 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -3,7 +3,7 @@ pylabrobot.micronic package =========================== -Micronic integrations built on the rack-reading and barcode-scanning capabilities. +Micronic integrations built on the rack-reading capability. Device ------ @@ -22,27 +22,25 @@ Device Driver ------ -.. currentmodule:: pylabrobot.micronic.code_reader.driver +.. currentmodule:: pylabrobot.micronic.code_reader.direct_driver .. autosummary:: :toctree: _autosummary :nosignatures: :recursive: - MicronicIOMonitorDriver - MicronicIOMonitorState - MicronicRackReaderDriver - MicronicError + MicronicDirectDriver + MicronicDirectRackReaderError -.. currentmodule:: pylabrobot.micronic.code_reader.direct_driver +.. currentmodule:: pylabrobot.micronic.code_reader.driver .. autosummary:: :toctree: _autosummary :nosignatures: :recursive: - MicronicDirectDriver - MicronicDirectRackReaderError + MicronicRackReaderDriver + MicronicError Capabilities @@ -55,17 +53,5 @@ Capabilities :nosignatures: :recursive: - MicronicIOMonitorRackReadingBackend MicronicRackReadingBackend - MicronicDirectRackReadingBackend MicronicRackReaderError - -.. currentmodule:: pylabrobot.micronic.code_reader.barcode_scanning_backend - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - MicronicIOMonitorBarcodeScannerBackend - MicronicBarcodeScannerError diff --git a/docs/user_guide/capabilities/rack-reading.md b/docs/user_guide/capabilities/rack-reading.md index 651b2b72327..05752feb6d8 100644 --- a/docs/user_guide/capabilities/rack-reading.md +++ b/docs/user_guide/capabilities/rack-reading.md @@ -3,8 +3,7 @@ The `rack_reading` capability standardizes rack-scale code readers that trigger a rack scan, report normalized state while scanning, and return structured per-position scan results. -Unlike the single-barcode `barcode_scanning` capability, rack reading is job-oriented and returns -the full decoded rack map. +Unlike one-at-a-time code reads, rack reading is job-oriented and returns the full decoded rack map. ## Public API @@ -35,9 +34,13 @@ Lower-level methods are also available: ## Example With Micronic ```python -from pylabrobot.micronic import MicronicCodeReader +from pylabrobot.micronic import MicronicDirectCodeReader -reader = MicronicCodeReader(host="localhost", port=2500) +reader = MicronicDirectCodeReader( + scanner_backend="sane", + sane_device="avision:libusb:001:004", + serial_port="/dev/ttyUSB0", +) await reader.setup() try: diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index 69e9d543941..bf1c9121094 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -1,77 +1,33 @@ # Micronic PyLabRobot includes `v1b1` Micronic integrations built on the generic -`rack_reading` and `barcode_scanning` capabilities. - -There are two rack-reader drivers: - -- `MicronicIOMonitorDriver` - targets the `IO Monitor` HTTP server exposed by the Micronic Code Reader - Windows application. It supports rack reading and single-tube barcode - scanning. -- `MicronicDirectDriver` - controls the local hardware directly. It acquires the rack image through a - configured scanner command, a Windows TWAIN helper available on PATH, or - Ubuntu/Linux SANE `scanimage`; reads the side rack barcode through the serial - reader; decodes tube DataMatrix codes locally; and returns the same - `RackScanResult` shape through the standard `rack_reading` capability. It does - not call Micronic Code Reader or IO Monitor, and PyLabRobot does not package - any scanner helper executable. - -Both drivers plug into `MicronicCodeReader` through the same `rack_reading` -capability. `MicronicDirectCodeReader` is a convenience frontend that constructs +`rack_reading` capability. + +`MicronicDirectDriver` controls the local hardware directly. It acquires the +rack image through a configured scanner command, a Windows TWAIN helper +available on PATH, or Ubuntu/Linux SANE `scanimage`; reads the side rack barcode +through the serial reader; decodes tube DataMatrix codes locally; and returns a +standard `RackScanResult`. It does not call Micronic Code Reader or IO Monitor, +and PyLabRobot does not package any scanner helper executable. + +`MicronicDirectCodeReader` is a convenience frontend that constructs `MicronicCodeReader` with `MicronicDirectDriver`. ## Supported operations Rack reading (large scanner that decodes 96 tubes plus the side rack barcode): -- `GET /state` -- `POST /scanbox` to trigger a full rack scan -- `GET /scanresult` to read the decoded grid -- `GET /rackid` for a rack-barcode-only read on the side reader (one-shot trigger+result) -- `GET /layoutlist` -- `GET /currentlayout` -- `PUT /currentlayout` - -Single-tube barcode scanning (small spot, separate from the rack scanner): - -- `GET /state` -- `POST /scantube` -- `GET /scanresult` -- `GET /rackid` as a compatibility fallback for server variants that expose the decoded - tube value there - -## IO Monitor example - -```python -from pylabrobot.micronic import MicronicCodeReader, MicronicIOMonitorDriver - -reader = MicronicCodeReader(driver=MicronicIOMonitorDriver(host="localhost", port=2500)) -await reader.setup() - -try: - rack_result = await reader.rack_reading.scan_rack(timeout=60.0, poll_interval=1.0) - print(rack_result.rack_id) - print(rack_result.entries[0].position, rack_result.entries[0].tube_id) - - rack_id = await reader.rack_reading.scan_rack_id(timeout=10.0, poll_interval=0.5) - print(rack_id) - - barcode = await reader.barcode_scanning.scan() - print(barcode.data) -finally: - await reader.stop() -``` +- `rack_reading.scan_rack()` to trigger image acquisition, decode all 96 tube + positions, read the side rack barcode, and return a `RackScanResult` +- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader +- `rack_reading.get_layouts()`, `get_current_layout()`, and + `set_current_layout()` for the fixed 8x12 rack layout ## Direct hardware example -Use `MicronicDirectDriver` when the host should own scanner acquisition, rack-ID -reads, and tube decoding without the Micronic application. The direct path -exposes `rack_reading`; it does not expose `barcode_scanning`. The operator is -responsible for installing any OS-level scanner bridge (`twain_scan`, -`scanimage`, or a custom command) and the local Python decode dependencies in -the runtime environment. +The operator is responsible for installing any OS-level scanner bridge +(`twain_scan`, `scanimage`, or a custom command), pyserial, and the local Python +decode dependencies in the runtime environment. ```python from pylabrobot.micronic import MicronicCodeReader, MicronicDirectDriver @@ -119,10 +75,6 @@ formatted with `{output_path}`, `{timeout_ms}`, `{twain_source}`, and ## Notes -- The Micronic server is path-based. Use `POST /scanbox`, not `POST /` with raw text. -- The Micronic application must have the HTTP server enabled in `IO Monitor`. -- The reader only supports one external client at a time. -- `localhost` is typically safer than `127.0.0.1` on the Windows host. - `scan_rack` reads every tube barcode and finishes by reading the rack ID, so it typically takes tens of seconds. `scan_rack_id` only reads the rack barcode and completes in a few seconds. @@ -132,7 +84,7 @@ formatted with `{output_path}`, `{timeout_ms}`, `{twain_source}`, and named `twain_scan`/`twain_scan.exe` on PATH when using the `twain` backend. - Ubuntu/Linux scanner control should use SANE `scanimage` or a custom `scan_command`. PyLabRobot does not install SANE or vendor scanner drivers. - Rack-ID reads use `pyserial` on non-Windows systems. + Rack-ID reads use `pyserial`. - Direct image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot when using the direct driver. diff --git a/pylabrobot/capabilities/barcode_scanning/barcode_scanning_tests.py b/pylabrobot/capabilities/barcode_scanning/barcode_scanning_tests.py deleted file mode 100644 index 87cff10990f..00000000000 --- a/pylabrobot/capabilities/barcode_scanning/barcode_scanning_tests.py +++ /dev/null @@ -1,49 +0,0 @@ -import unittest - -from pylabrobot.capabilities.barcode_scanning.backend import BarcodeScannerBackend -from pylabrobot.capabilities.barcode_scanning.barcode_scanning import BarcodeScanner -from pylabrobot.capabilities.barcode_scanning.chatterbox import BarcodeScannerChatterboxBackend -from pylabrobot.resources.barcode import Barcode - - -class RecordingBarcodeScannerBackend(BarcodeScannerBackend): - def __init__(self, barcode: str = "TEST-123"): - self.barcode = barcode - self.calls = 0 - - async def scan_barcode(self) -> Barcode: - self.calls += 1 - return Barcode(data=self.barcode, symbology="Data Matrix", position_on_resource="bottom") - - -class TestBarcodeScanner(unittest.IsolatedAsyncioTestCase): - async def test_scan_returns_barcode(self): - backend = RecordingBarcodeScannerBackend() - scanner = BarcodeScanner(backend=backend) - await scanner._on_setup() - - barcode = await scanner.scan() - - self.assertEqual(backend.calls, 1) - self.assertEqual(barcode.data, "TEST-123") - self.assertEqual(barcode.symbology, "Data Matrix") - self.assertEqual(barcode.position_on_resource, "bottom") - - async def test_scan_requires_setup(self): - backend = RecordingBarcodeScannerBackend() - scanner = BarcodeScanner(backend=backend) - - with self.assertRaises(RuntimeError): - await scanner.scan() - - async def test_chatterbox_backend(self): - scanner = BarcodeScanner(backend=BarcodeScannerChatterboxBackend(barcode="CHATTERBOX-XYZ")) - await scanner._on_setup() - - barcode = await scanner.scan() - - self.assertEqual(barcode.data, "CHATTERBOX-XYZ") - - -if __name__ == "__main__": - unittest.main() diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index f4656a04708..479d0b05771 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -1,15 +1,9 @@ from pylabrobot.micronic.code_reader import ( - MicronicBarcodeScannerError, MicronicCodeReader, MicronicDirectCodeReader, MicronicDirectDriver, MicronicDirectRackReaderError, - MicronicDirectRackReadingBackend, MicronicError, - MicronicIOMonitorBarcodeScannerBackend, - MicronicIOMonitorDriver, - MicronicIOMonitorRackReadingBackend, - MicronicIOMonitorState, MicronicRackReaderDriver, MicronicRackReadingBackend, MicronicRackReaderError, diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index 4705b575e11..efd53dd8afd 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -1,7 +1,3 @@ -from pylabrobot.micronic.code_reader.barcode_scanning_backend import ( - MicronicBarcodeScannerError, - MicronicIOMonitorBarcodeScannerBackend, -) from pylabrobot.micronic.code_reader.code_reader import MicronicCodeReader from pylabrobot.micronic.code_reader.code_reader import MicronicDirectCodeReader from pylabrobot.micronic.code_reader.direct_driver import ( @@ -10,13 +6,9 @@ ) from pylabrobot.micronic.code_reader.driver import ( MicronicError, - MicronicIOMonitorDriver, - MicronicIOMonitorState, MicronicRackReaderDriver, ) from pylabrobot.micronic.code_reader.rack_reading_backend import ( - MicronicDirectRackReadingBackend, - MicronicIOMonitorRackReadingBackend, MicronicRackReadingBackend, MicronicRackReaderError, ) diff --git a/pylabrobot/micronic/code_reader/barcode_scanning_backend.py b/pylabrobot/micronic/code_reader/barcode_scanning_backend.py deleted file mode 100644 index b0422c4ffe4..00000000000 --- a/pylabrobot/micronic/code_reader/barcode_scanning_backend.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Single-tube barcode-scanning backend for the Micronic Code Reader IO Monitor server.""" - -from __future__ import annotations - -from typing import Optional - -from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend, BarcodeScannerError -from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.resources.barcode import Barcode - -from .driver import MicronicError, MicronicIOMonitorDriver - - -class MicronicBarcodeScannerError(MicronicError, BarcodeScannerError): - """Raised when Micronic single-tube barcode scanning fails.""" - - -class MicronicIOMonitorBarcodeScannerBackend(BarcodeScannerBackend): - """Single-tube barcode-scanning backend for the Micronic Code Reader IO Monitor server.""" - - def __init__( - self, - driver: MicronicIOMonitorDriver, - timeout: float = 60.0, - poll_interval: float = 1.0, - ): - super().__init__() - self.driver = driver - self.timeout = timeout - self.poll_interval = poll_interval - - async def _on_setup(self, backend_params: Optional[BackendParams] = None): - try: - await self.driver.get_iomonitor_state() - except MicronicError as exc: - raise MicronicBarcodeScannerError(str(exc)) from exc - - async def scan_barcode(self) -> Barcode: - try: - initial_state = await self.driver.get_iomonitor_state() - await self.driver.request( - "POST", - "/scantube", - data=b"", - headers=None, - expect_json=False, - ) - await self.driver.wait_for_fresh_data_ready( - initial_state=initial_state, - timeout=self.timeout, - poll_interval=self.poll_interval, - ) - data = await self.driver.get_single_tube_barcode() - except MicronicError as exc: - raise MicronicBarcodeScannerError(str(exc)) from exc - return Barcode(data=data, symbology="Data Matrix", position_on_resource="bottom") diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index f93da0eeb78..fbfa84e4e1b 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -4,49 +4,35 @@ from typing import Optional, Sequence -from pylabrobot.capabilities.barcode_scanning import BarcodeScanner from pylabrobot.capabilities.rack_reading import RackReader from pylabrobot.device import Device -from .barcode_scanning_backend import MicronicIOMonitorBarcodeScannerBackend from .direct_driver import MicronicDirectDriver -from .driver import MicronicIOMonitorDriver, MicronicRackReaderDriver +from .driver import MicronicRackReaderDriver from .rack_reading_backend import MicronicRackReadingBackend class MicronicCodeReader(Device): """Micronic rack reader device. - The rack-reading capability is driven by ``driver``. By default this uses the - Micronic IO Monitor HTTP server, but a ``MicronicDirectDriver`` can be supplied - to control the local scanner hardware directly. + The rack-reading capability is driven by ``driver``. By default this uses + ``MicronicDirectDriver`` to control the scanner hardware directly. """ def __init__( self, - host: str = "localhost", - port: int = 2500, timeout: float = 60.0, poll_interval: float = 1.0, driver: Optional[MicronicRackReaderDriver] = None, ): if driver is None: - driver = MicronicIOMonitorDriver(host=host, port=port, timeout=timeout) + driver = MicronicDirectDriver(scanner_timeout_ms=int(timeout * 1000)) super().__init__(driver=driver) self.driver: MicronicRackReaderDriver = driver self.default_timeout = timeout self.default_poll_interval = poll_interval self.rack_reading = RackReader(backend=MicronicRackReadingBackend(driver)) self._capabilities = [self.rack_reading] - if isinstance(driver, MicronicIOMonitorDriver): - self.barcode_scanning = BarcodeScanner( - backend=MicronicIOMonitorBarcodeScannerBackend( - driver, - timeout=timeout, - poll_interval=poll_interval, - ) - ) - self._capabilities.append(self.barcode_scanning) def serialize(self) -> dict: return { @@ -60,8 +46,7 @@ class MicronicDirectCodeReader(MicronicCodeReader): """Micronic rack reader that controls scanner hardware directly. This frontend follows the same v1b1 rack-reading capability surface as - ``MicronicCodeReader`` but uses the direct hardware driver instead of the - Micronic IO Monitor HTTP server. + ``MicronicCodeReader`` while exposing direct-driver setup options. """ def __init__( @@ -104,10 +89,3 @@ def __init__( ) super().__init__(timeout=timeout, poll_interval=poll_interval, driver=driver) self.driver: MicronicDirectDriver = driver - - def serialize(self) -> dict: - return { - **super().serialize(), - "timeout": self.default_timeout, - "poll_interval": self.default_poll_interval, - } diff --git a/pylabrobot/micronic/code_reader/direct_driver.py b/pylabrobot/micronic/code_reader/direct_driver.py index 9d83723736c..9ef593ef21d 100644 --- a/pylabrobot/micronic/code_reader/direct_driver.py +++ b/pylabrobot/micronic/code_reader/direct_driver.py @@ -105,14 +105,26 @@ async def setup(self, backend_params: Optional[BackendParams] = None): async def stop(self): scan_task = self._scan_task - if scan_task is not None and not scan_task.done(): + if scan_task is None: + if self._scan_error is not None: + raise self._scan_error + return + if scan_task.done(): + self._complete_scan_task(scan_task) + else: try: - await scan_task + self._last_result = await scan_task except asyncio.CancelledError: - pass - except Exception: - pass - self._complete_finished_scan_task() + self._scan_error = MicronicDirectRackReaderError("Direct Micronic rack scan was cancelled.") + self._state = RackReaderState.IDLE + except Exception as exc: + self._scan_error = exc + self._state = RackReaderState.IDLE + else: + self._scan_error = None + self._state = RackReaderState.DATAREADY + finally: + self._scan_task = None if self._scan_error is not None: raise self._scan_error @@ -406,12 +418,7 @@ def read_rack_id( try: return read_rack_id_pyserial(serial_port=serial_port, timeout_ms=timeout_ms) except ImportError as exc: - if os.name != "nt": - raise MicronicDirectRackReaderError( - "Rack ID serial read requires pyserial on non-Windows systems." - ) from exc - - return read_rack_id_powershell(serial_port=serial_port, timeout_ms=timeout_ms) + raise MicronicDirectRackReaderError("Rack ID serial read requires pyserial.") from exc def read_rack_id_pyserial(serial_port: str, timeout_ms: int) -> str: @@ -463,61 +470,6 @@ def read_rack_id_command(command: Sequence[str], timeout_ms: int) -> str: return extract_rack_id(completed.stdout) -def read_rack_id_powershell(serial_port: str, timeout_ms: int) -> str: - if os.name != "nt": - raise MicronicDirectRackReaderError( - "PowerShell rack ID serial read is only supported on Windows." - ) - - quoted_serial_port = powershell_single_quote(serial_port) - powershell_path = shutil.which("powershell.exe") or shutil.which("powershell") - if powershell_path is None: - raise MicronicDirectRackReaderError("PowerShell executable was not found on PATH.") - - ps_script = rf""" -$ErrorActionPreference = 'Stop' -$port = New-Object System.IO.Ports.SerialPort {quoted_serial_port}, 9600, ([System.IO.Ports.Parity]::Even), 7, ([System.IO.Ports.StopBits]::One) -$port.ReadTimeout = 100 -$port.WriteTimeout = 1000 -$port.Open() -try {{ - $port.DiscardInBuffer() - $bytes = [byte[]](60,116,62,13,10) - $port.Write($bytes, 0, $bytes.Length) - $sw = [Diagnostics.Stopwatch]::StartNew() - $chars = New-Object System.Collections.Generic.List[char] - while ($sw.ElapsedMilliseconds -lt {timeout_ms}) {{ - try {{ - $value = $port.ReadByte() - if ($value -ge 0) {{ - $chars.Add([char]$value) - if ($value -eq 10) {{ break }} - }} - }} catch [System.TimeoutException] {{ - }} - }} - -join $chars -}} finally {{ - if ($port.IsOpen) {{ $port.Close() }} -}} -""" - completed = subprocess.run( # nosec B603 - fixed PowerShell path with escaped port input. - [powershell_path, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_script], - check=False, - capture_output=True, - text=True, - timeout=(timeout_ms / 1000) + 5, - ) - if completed.returncode != 0: - raise MicronicDirectRackReaderError(f"Rack ID serial read failed: {completed.stderr.strip()}") - - return extract_rack_id(completed.stdout) - - -def powershell_single_quote(value: str) -> str: - return "'" + value.replace("'", "''") + "'" - - def extract_rack_id(text: str) -> str: match = re.search(r"\d{6,}", text) return match.group(0) if match else "NOREAD" diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 9569d0e3ed0..7d6ddb04350 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -1,21 +1,12 @@ -"""Drivers for Micronic Code Reader rack and barcode integrations.""" +"""Shared contracts for Micronic rack-reader drivers.""" from __future__ import annotations -import asyncio -import enum -import http.client -import json -import time from abc import abstractmethod -from typing import Any, Optional -from urllib import error, parse, request -from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.rack_reading import ( LayoutInfo, RackReaderState, - RackScanEntry, RackScanResult, ) from pylabrobot.device import Driver @@ -25,18 +16,6 @@ class MicronicError(Exception): """Raised when Micronic driver operations fail.""" -class MicronicIOMonitorState(enum.Enum): - """Normalized IO Monitor device state reported by GET /state. - - IO Monitor exposes a single state machine that governs both rack scans and - single-tube scans, so this enum is shared across all IO Monitor backends. - """ - - IDLE = "idle" - SCANNING = "scanning" - DATAREADY = "dataready" - - class MicronicRackReaderDriver(Driver): """Driver contract used by the Micronic rack-reading backend.""" @@ -71,342 +50,3 @@ async def get_current_layout(self) -> str: @abstractmethod async def set_current_layout(self, layout: str) -> None: """Set the active layout.""" - - -_IOMONITOR_TO_RACK_READER_STATE = { - MicronicIOMonitorState.IDLE: RackReaderState.IDLE, - MicronicIOMonitorState.SCANNING: RackReaderState.SCANNING, - MicronicIOMonitorState.DATAREADY: RackReaderState.DATAREADY, -} - - -class MicronicIOMonitorDriver(MicronicRackReaderDriver): - """HTTP transport for the Micronic Code Reader IO Monitor server.""" - - def __init__( - self, - host: str = "localhost", - port: int = 2500, - timeout: float = 60.0, - user_agent: str = "curl/8.0", - ): - super().__init__() - self.host = host - self.port = port - self.timeout = timeout - self.user_agent = user_agent - - @property - def base_url(self) -> str: - return f"http://{self.host}:{self.port}" - - async def setup(self, backend_params: Optional[BackendParams] = None) -> None: - return None - - async def stop(self) -> None: - return None - - def serialize(self) -> dict: - return { - **super().serialize(), - "host": self.host, - "port": self.port, - "timeout": self.timeout, - "user_agent": self.user_agent, - } - - async def get_iomonitor_state(self) -> MicronicIOMonitorState: - payload = await self.request_json("GET", "/state") - state = payload.get("state") if isinstance(payload, dict) else None - if not isinstance(state, str): - raise MicronicError("Micronic server response did not contain a valid state.") - try: - return MicronicIOMonitorState(state) - except ValueError as exc: - raise MicronicError(f"Unknown Micronic state: {state}") from exc - - async def get_rack_reader_state(self) -> RackReaderState: - return _IOMONITOR_TO_RACK_READER_STATE[await self.get_iomonitor_state()] - - async def trigger_rack_scan(self) -> None: - await self.request("POST", "/scanbox", data=b"", headers=None, expect_json=False) - - async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - # IO Monitor's GET /rackid is a one-shot trigger+result on the side barcode reader, - # so timeout/poll_interval are unused here (the HTTP timeout controls the read). - del timeout, poll_interval - return await self.get_rack_id() - - async def get_scan_result(self) -> RackScanResult: - payload = await self.request_json("GET", "/scanresult") - return self._parse_scan_result(payload) - - async def get_rack_id(self) -> str: - payload = await self.request_json("GET", "/rackid", data=None, headers=None) - - if isinstance(payload, dict): - for key in ("RackID", "rackid", "rack_id"): - value = payload.get(key) - if isinstance(value, str): - return value - - raise MicronicError("Micronic rack ID response had an unexpected shape.") - - async def get_layouts(self) -> list[LayoutInfo]: - payload = await self.request_json("GET", "/layoutlist") - - if isinstance(payload, list): - return [LayoutInfo(name=str(item)) for item in payload] - - if isinstance(payload, dict): - for key in ("Layout", "layouts", "layoutlist", "data"): - value = payload.get(key) - if isinstance(value, list): - return [LayoutInfo(name=str(item)) for item in value] - - raise MicronicError("Micronic layout list response had an unexpected shape.") - - async def get_current_layout(self) -> str: - payload = await self.request_json("GET", "/currentlayout") - - if isinstance(payload, str): - return payload - - if isinstance(payload, dict): - for key in ("Layout", "layout", "currentlayout", "name"): - value = payload.get(key) - if isinstance(value, str): - return value - - raise MicronicError("Micronic current layout response had an unexpected shape.") - - async def set_current_layout(self, layout: str) -> None: - await self.request( - "PUT", - "/currentlayout", - data=json.dumps({"Layout": layout}).encode("utf-8"), - headers={"Content-Type": "application/json; charset=utf-8"}, - expect_json=False, - ) - - async def wait_for_fresh_data_ready( - self, - initial_state: MicronicIOMonitorState, - timeout: float, - poll_interval: float, - ) -> None: - # If we started at dataready, require a state change before accepting the next - # dataready, otherwise we'd return the previous scan's stale result. - require_state_change = initial_state == MicronicIOMonitorState.DATAREADY - deadline = time.monotonic() + timeout - while True: - state = await self.get_iomonitor_state() - if state != MicronicIOMonitorState.DATAREADY: - require_state_change = False - elif not require_state_change: - return - if time.monotonic() >= deadline: - raise MicronicError( - f"Timed out waiting for IO Monitor to reach {MicronicIOMonitorState.DATAREADY.value}." - ) - await asyncio.sleep(poll_interval) - - async def get_single_tube_barcode(self) -> str: - # Prefers the decoded tube value from GET /scanresult. Older IO Monitor builds - # expose that value on GET /rackid instead, so fall back to /rackid for - # cross-version compatibility. - scan_result_payload = await self.request_json("GET", "/scanresult") - barcode = _extract_single_tube_barcode(scan_result_payload) - if barcode is not None: - return barcode - - rack_id_payload = await self.request_json("GET", "/rackid") - barcode = _extract_named_barcode( - rack_id_payload, - keys=("RackID", "rackid", "rack_id", "Barcode", "barcode", "Code", "code"), - ) - if barcode is not None: - return barcode - - raise MicronicError("Micronic single-tube scan result had an unexpected shape.") - - def _parse_scan_result(self, payload: dict[str, Any]) -> RackScanResult: - positions = self._get_list(payload, "Position") - tube_ids = self._get_list(payload, "TubeID") - statuses = self._get_list(payload, "Status") - free_texts = self._get_list(payload, "FreeText") - - if not positions: - raise MicronicError("Micronic scan result did not include any positions.") - - entries: list[RackScanEntry] = [] - for idx, position in enumerate(positions): - tube_id = self._get_optional_item(tube_ids, idx) - entries.append( - RackScanEntry( - position=str(position), - tube_id=None if tube_id in (None, "") else str(tube_id), - status=str(self._get_required_item(statuses, idx, "Status")), - free_text=str(self._get_optional_item(free_texts, idx) or ""), - ) - ) - - rack_id = payload.get("RackID") - date = payload.get("Date") - time = payload.get("Time") - if not isinstance(rack_id, str) or not isinstance(date, str) or not isinstance(time, str): - raise MicronicError("Micronic scan result did not include RackID/Date/Time.") - - return RackScanResult(rack_id=rack_id, date=date, time=time, entries=entries) - - def _get_list(self, payload: dict[str, Any], key: str) -> list[Any]: - value = payload.get(key) - if value is None: - return [] - if not isinstance(value, list): - raise MicronicError(f"Micronic field {key} was not a list.") - return value - - def _get_required_item(self, items: list[Any], index: int, field_name: str) -> Any: - try: - return items[index] - except IndexError as exc: - raise MicronicError( - f"Micronic field {field_name} was missing an item for position index {index}." - ) from exc - - def _get_optional_item(self, items: list[Any], index: int) -> Any: - if index >= len(items): - return None - return items[index] - - async def request( - self, - method: str, - path: str, - data: Optional[bytes] = None, - headers: Optional[dict[str, str]] = None, - expect_json: bool = True, - ) -> bytes: - return await asyncio.to_thread( - self._request_sync, - method, - path, - data, - headers, - expect_json, - ) - - async def request_json( - self, - method: str, - path: str, - data: Optional[bytes] = None, - headers: Optional[dict[str, str]] = None, - ) -> Any: - response = await self.request( - method=method, - path=path, - data=data, - headers=headers, - expect_json=True, - ) - try: - return json.loads(response.decode("utf-8")) - except json.JSONDecodeError as exc: - raise MicronicError( - f"Micronic server returned non-JSON payload for {method} {path}." - ) from exc - - def _request_sync( - self, - method: str, - path: str, - data: Optional[bytes] = None, - headers: Optional[dict[str, str]] = None, - expect_json: bool = True, - ) -> bytes: - req_headers = { - "Accept": "application/json" if expect_json else "*/*", - "Connection": "close", - "User-Agent": self.user_agent, - } - if headers is not None: - req_headers.update(headers) - if data is not None: - req_headers["Content-Length"] = str(len(data)) - - req = request.Request( - url=parse.urljoin(self.base_url, path), - data=data, - headers=req_headers, - method=method, - ) - - for attempt in range(3): - try: - with request.urlopen(req, timeout=self.timeout) as response: - body: bytes = response.read() - return body - except error.HTTPError as exc: - body = exc.read() - raise self._as_micronic_error( - body, fallback=f"HTTP {exc.code} for {method} {path}" - ) from exc - except error.URLError as exc: - if self._is_retryable_url_error(exc) and attempt < 2: - time.sleep(0.25) - continue - raise MicronicError( - f"Failed to reach Micronic server at {self.base_url}: {exc.reason}" - ) from exc - except (ConnectionResetError, http.client.RemoteDisconnected, OSError) as exc: - if attempt == 2: - raise MicronicError(f"Micronic connection failed for {method} {path}: {exc}") from exc - time.sleep(0.25) - - raise MicronicError(f"Micronic request failed for {method} {path}.") - - def _as_micronic_error(self, body: bytes, fallback: str) -> MicronicError: - try: - payload = json.loads(body.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - return MicronicError(fallback) - - if isinstance(payload, dict) and "ErrorMsg" in payload: - error_code = payload.get("ErrorCode") - error_msg = payload.get("ErrorMsg") - return MicronicError(f"Micronic error {error_code}: {error_msg}") - - return MicronicError(fallback) - - def _is_retryable_url_error(self, exc: error.URLError) -> bool: - reason = exc.reason - return isinstance(reason, (ConnectionResetError, http.client.RemoteDisconnected, OSError)) - - -def _extract_single_tube_barcode(payload: Any) -> Optional[str]: - if isinstance(payload, str) and payload: - return payload - return _extract_named_barcode( - payload, - keys=("TubeID", "tubeid", "tube_id", "Barcode", "barcode", "Code", "code", "Data", "data"), - ) - - -def _extract_named_barcode(payload: Any, keys: tuple[str, ...]) -> Optional[str]: - if not isinstance(payload, dict): - return None - for key in keys: - barcode = _coerce_single_barcode(payload.get(key)) - if barcode is not None: - return barcode - return None - - -def _coerce_single_barcode(value: Any) -> Optional[str]: - if isinstance(value, str): - return value or None - if isinstance(value, list) and len(value) == 1 and value[0] not in (None, ""): - return str(value[0]) - return None diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 8ee4a05c169..b477bbcac8b 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -1,38 +1,21 @@ import asyncio -import json import sys import tempfile import unittest from pathlib import Path from unittest.mock import MagicMock, patch -from urllib import error -from pylabrobot.capabilities.barcode_scanning.backend import BarcodeScannerError from pylabrobot.capabilities.rack_reading import RackReaderState from pylabrobot.micronic import MicronicCodeReader, MicronicDirectCodeReader -from pylabrobot.micronic.code_reader.barcode_scanning_backend import ( - MicronicBarcodeScannerError, - MicronicIOMonitorBarcodeScannerBackend, -) from pylabrobot.micronic.code_reader.direct_driver import ( DecodeResult, MicronicDirectDriver, MicronicDirectRackReaderError, choose_image_extension, - powershell_single_quote, read_rack_id, run_scan, ) -from pylabrobot.micronic.code_reader.driver import ( - MicronicError, - MicronicIOMonitorDriver, - MicronicIOMonitorState, -) -from pylabrobot.micronic.code_reader.rack_reading_backend import ( - MicronicIOMonitorRackReadingBackend, - MicronicRackReadingBackend, -) -from pylabrobot.resources.barcode import Barcode +from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend async def wait_for_direct_dataready(driver: MicronicDirectDriver) -> None: @@ -43,173 +26,6 @@ async def wait_for_direct_dataready(driver: MicronicDirectDriver) -> None: raise AssertionError("Direct Micronic test scan did not reach dataready.") -class TestMicronicIOMonitorDriver(unittest.IsolatedAsyncioTestCase): - async def test_request_sync_retries_connection_reset(self): - driver = MicronicIOMonitorDriver() - response = MagicMock() - response.read.return_value = b'{"state":"idle"}' - response.__enter__.return_value = response - response.__exit__.return_value = False - - with patch( - "pylabrobot.micronic.code_reader.driver.request.urlopen", - side_effect=[ConnectionResetError(104, "reset"), response], - ): - body = driver._request_sync("GET", "/state") - - self.assertEqual(body, b'{"state":"idle"}') - - async def test_http_error_maps_to_backend_error(self): - driver = MicronicIOMonitorDriver() - err = driver._as_micronic_error( - json.dumps({"ErrorCode": 4, "ErrorMsg": "invalid state"}).encode("utf-8"), - fallback="fallback", - ) - self.assertIsInstance(err, MicronicError) - self.assertIn("invalid state", str(err)) - - async def test_request_sync_retries_retryable_urlerror(self): - driver = MicronicIOMonitorDriver() - response = MagicMock() - response.read.return_value = b'{"state":"idle"}' - response.__enter__.return_value = response - response.__exit__.return_value = False - - with patch( - "pylabrobot.micronic.code_reader.driver.request.urlopen", - side_effect=[error.URLError(ConnectionResetError(104, "reset")), response], - ): - body = driver._request_sync("GET", "/state") - - self.assertEqual(body, b'{"state":"idle"}') - - async def test_get_iomonitor_state_parses_payload(self): - driver = MicronicIOMonitorDriver() - with patch.object(driver, "request_json", return_value={"state": "dataready"}): - state = await driver.get_iomonitor_state() - self.assertEqual(state, MicronicIOMonitorState.DATAREADY) - - async def test_get_iomonitor_state_rejects_unknown(self): - driver = MicronicIOMonitorDriver() - with patch.object(driver, "request_json", return_value={"state": "weird"}): - with self.assertRaises(MicronicError): - await driver.get_iomonitor_state() - - async def test_wait_for_fresh_data_ready_requires_state_change_when_starting_ready(self): - driver = MicronicIOMonitorDriver() - with patch.object( - driver, - "request_json", - side_effect=[ - {"state": "dataready"}, - {"state": "scanning"}, - {"state": "dataready"}, - ], - ) as request_json: - await driver.wait_for_fresh_data_ready( - initial_state=MicronicIOMonitorState.DATAREADY, - timeout=1.0, - poll_interval=0.0, - ) - self.assertEqual(request_json.call_count, 3) - - async def test_get_single_tube_barcode_prefers_scanresult(self): - driver = MicronicIOMonitorDriver() - with patch.object( - driver, - "request_json", - side_effect=[{"TubeID": ["5007377910"]}], - ): - barcode = await driver.get_single_tube_barcode() - self.assertEqual(barcode, "5007377910") - - async def test_get_single_tube_barcode_falls_back_to_rackid(self): - driver = MicronicIOMonitorDriver() - with patch.object( - driver, - "request_json", - side_effect=[{"unexpected": "shape"}, {"RackID": "5007377910"}], - ): - barcode = await driver.get_single_tube_barcode() - self.assertEqual(barcode, "5007377910") - - -class TestMicronicIOMonitorRackReadingBackend(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - super().setUp() - self.driver = MicronicIOMonitorDriver() - self.backend = MicronicIOMonitorRackReadingBackend(driver=self.driver) - - async def test_on_setup_checks_state(self): - with patch.object(self.backend, "get_state", return_value=RackReaderState.IDLE) as get_state: - await self.backend._on_setup() - get_state.assert_called_once_with() - - async def test_get_state(self): - with patch.object( - self.driver, - "request_json", - return_value={"state": "dataready"}, - ): - state = await self.backend.get_state() - self.assertEqual(state, RackReaderState.DATAREADY) - - async def test_trigger_rack_scan(self): - with patch.object(self.driver, "request", return_value=b"") as request_bytes: - await self.backend.trigger_rack_scan() - request_bytes.assert_called_once_with( - "POST", - "/scanbox", - data=b"", - headers=None, - expect_json=False, - ) - - async def test_scan_rack_id_uses_rackid_endpoint(self): - with patch.object( - self.driver, - "request_json", - return_value={"RackID": "5500135415"}, - ) as request_json: - rack_id = await self.backend.scan_rack_id(timeout=10.0, poll_interval=0.5) - - request_json.assert_called_once_with("GET", "/rackid", data=None, headers=None) - self.assertEqual(rack_id, "5500135415") - - async def test_get_scan_result(self): - payload = { - "RackID": "3000756455", - "Date": "20260315", - "Time": "114804", - "Position": ["A01", "A02"], - "TubeID": ["5007377910", "5007377911"], - "Status": ["Code OK", "Code OK"], - "FreeText": ["", ""], - } - with patch.object(self.driver, "request_json", return_value=payload): - result = await self.backend.get_scan_result() - - self.assertEqual(result.rack_id, "3000756455") - self.assertEqual(result.entries[0].position, "A01") - self.assertEqual(result.entries[1].tube_id, "5007377911") - - async def test_get_layouts_dict_payload(self): - with patch.object(self.driver, "request_json", return_value={"Layout": ["8x12", "6x8"]}): - layouts = await self.backend.get_layouts() - self.assertEqual([layout.name for layout in layouts], ["8x12", "6x8"]) - - async def test_set_current_layout(self): - with patch.object(self.driver, "request", return_value=b"") as request_bytes: - await self.backend.set_current_layout("96") - request_bytes.assert_called_once_with( - "PUT", - "/currentlayout", - data=b'{"Layout": "96"}', - headers={"Content-Type": "application/json; charset=utf-8"}, - expect_json=False, - ) - - class TestMicronicDirectDriver(unittest.IsolatedAsyncioTestCase): def test_direct_driver_does_not_default_to_packaged_twain_helper(self): driver = MicronicDirectDriver() @@ -231,15 +47,15 @@ async def test_direct_driver_scan_populates_standard_rack_result(self): patch( "pylabrobot.micronic.code_reader.direct_driver.run_scan", return_value={"source": "test"}, - ) as run_scan, + ) as run_scan_mock, patch( "pylabrobot.micronic.code_reader.direct_driver.read_rack_id", return_value="9500017722", - ) as read_rack_id, + ) as read_rack_id_mock, patch( "pylabrobot.micronic.code_reader.direct_driver.decode_image", return_value=(decoded, {"decodedWells": 2}), - ) as decode_image, + ) as decode_image_mock, ): await driver.setup() await driver.trigger_rack_scan() @@ -253,9 +69,9 @@ async def test_direct_driver_scan_populates_standard_rack_result(self): self.assertEqual(result.entries[1].tube_id, "2222222222") self.assertEqual(driver.last_scan_metadata, {"source": "test"}) self.assertEqual(driver.last_decode_metadata, {"decodedWells": 2}) - run_scan.assert_called_once() - read_rack_id.assert_called_once() - decode_image.assert_called_once() + run_scan_mock.assert_called_once() + read_rack_id_mock.assert_called_once() + decode_image_mock.assert_called_once() async def test_direct_reader_can_scan_twice_after_dataready(self): with tempfile.TemporaryDirectory() as image_dir: @@ -271,7 +87,7 @@ async def test_direct_reader_can_scan_twice_after_dataready(self): patch( "pylabrobot.micronic.code_reader.direct_driver.run_scan", return_value={"source": "test"}, - ) as run_scan, + ) as run_scan_mock, patch( "pylabrobot.micronic.code_reader.direct_driver.read_rack_id", return_value="9500017722", @@ -287,7 +103,7 @@ async def test_direct_reader_can_scan_twice_after_dataready(self): self.assertEqual(first.rack_id, "9500017722") self.assertEqual(second.rack_id, "9500017722") - self.assertEqual(run_scan.call_count, 2) + self.assertEqual(run_scan_mock.call_count, 2) async def test_direct_driver_get_rack_id_does_not_return_stale_result_while_scanning(self): with tempfile.TemporaryDirectory() as image_dir: @@ -425,10 +241,6 @@ async def test_read_rack_id_uses_configured_command(self): ) self.assertEqual(rack_id, "9500017722") - def test_powershell_single_quote_escapes_serial_port(self): - self.assertEqual(powershell_single_quote("COM4"), "'COM4'") - self.assertEqual(powershell_single_quote("COM'4"), "'COM''4'") - async def test_direct_driver_scan_rack_id_uses_configured_command(self): driver = MicronicDirectDriver( rack_id_command=[sys.executable, "-c", "print('rack 9500017722')"] @@ -454,198 +266,36 @@ async def test_generic_backend_delegates_to_direct_driver(self): scan_rack_id.assert_called_once_with(timeout=5.0, poll_interval=0.5) -class TestMicronicIOMonitorBarcodeScannerBackend(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - super().setUp() - self.driver = MicronicIOMonitorDriver() - self.backend = MicronicIOMonitorBarcodeScannerBackend( - driver=self.driver, timeout=1.0, poll_interval=0.0 - ) - - async def test_scan_barcode_reads_single_tube_code(self): - with ( - patch.object(self.driver, "request", return_value=b"") as request_bytes, - patch.object( - self.driver, - "request_json", - side_effect=[ - {"state": "idle"}, - {"state": "scanning"}, - {"state": "dataready"}, - {"TubeID": ["5007377910"]}, - ], - ), - ): - barcode = await self.backend.scan_barcode() - - request_bytes.assert_called_once_with( - "POST", - "/scantube", - data=b"", - headers=None, - expect_json=False, - ) - self.assertEqual(barcode, Barcode("5007377910", "Data Matrix", "bottom")) - - async def test_scan_barcode_falls_back_to_rackid_payload(self): - with ( - patch.object(self.driver, "request", return_value=b"") as request_bytes, - patch.object( - self.driver, - "request_json", - side_effect=[ - {"state": "idle"}, - {"state": "dataready"}, - {"unexpected": "shape"}, - {"RackID": "5007377910"}, - ], - ), - ): - barcode = await self.backend.scan_barcode() - - request_bytes.assert_called_once_with( - "POST", - "/scantube", - data=b"", - headers=None, - expect_json=False, - ) - self.assertEqual(barcode.data, "5007377910") - - async def test_scan_barcode_waits_for_new_dataready_cycle(self): - with ( - patch.object(self.driver, "request", return_value=b"") as request_bytes, - patch.object( - self.driver, - "request_json", - side_effect=[ - {"state": "dataready"}, - {"state": "dataready"}, - {"state": "scanning"}, - {"state": "dataready"}, - {"TubeID": ["5007377910"]}, - ], - ) as request_json, - ): - barcode = await self.backend.scan_barcode() - - request_bytes.assert_called_once_with( - "POST", - "/scantube", - data=b"", - headers=None, - expect_json=False, - ) - self.assertEqual(barcode.data, "5007377910") - self.assertEqual(request_json.call_count, 5) - - async def test_scan_barcode_raises_on_unknown_payload(self): - with ( - patch.object(self.driver, "request", return_value=b""), - patch.object( - self.driver, - "request_json", - side_effect=[ - {"state": "idle"}, - {"state": "dataready"}, - {"unexpected": "shape"}, - {"still": "bad"}, - ], - ), - ): - with self.assertRaises(MicronicBarcodeScannerError): - await self.backend.scan_barcode() - - async def test_backend_error_is_a_barcode_scanner_error(self): - with patch.object( - self.driver, - "request_json", - side_effect=MicronicError("network failure"), - ): - with self.assertRaises(BarcodeScannerError): - await self.backend.scan_barcode() - - class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): - async def test_device_exposes_rack_and_barcode_capabilities(self): - reader = MicronicCodeReader(timeout=12.0, poll_interval=0.25) + async def test_device_exposes_rack_reading_only(self): + reader = MicronicCodeReader( + timeout=12.0, + poll_interval=0.25, + driver=MicronicDirectDriver(rack_id_override="9500017722"), + ) with patch.object( reader.driver, - "get_iomonitor_state", - return_value=MicronicIOMonitorState.IDLE, + "get_rack_reader_state", + return_value=RackReaderState.IDLE, ): await reader.setup() try: self.assertIn(reader.rack_reading, reader._capabilities) - self.assertIn(reader.barcode_scanning, reader._capabilities) - self.assertFalse(hasattr(reader, "rack_reader")) + self.assertFalse(hasattr(reader, "barcode_scanning")) with patch.object( reader.rack_reading, "scan_rack", - return_value=MagicMock(rack_id="5500135415"), + return_value=MagicMock(rack_id="9500017722"), ) as scan_rack: result = await reader.rack_reading.scan_rack( timeout=reader.default_timeout, poll_interval=reader.default_poll_interval, ) - with patch.object( - reader.barcode_scanning, - "scan", - return_value=Barcode( - data="5007377910", symbology="Data Matrix", position_on_resource="bottom" - ), - ) as scan_barcode: - barcode = await reader.barcode_scanning.scan() finally: await reader.stop() - self.assertEqual(result.rack_id, "5500135415") - self.assertEqual(barcode.data, "5007377910") + self.assertEqual(result.rack_id, "9500017722") scan_rack.assert_called_once_with(timeout=12.0, poll_interval=0.25) - scan_barcode.assert_called_once_with() - - async def test_device_exposes_rack_id_only_scan_on_rack_reading(self): - reader = MicronicCodeReader(timeout=12.0, poll_interval=0.25) - with patch.object( - reader.driver, - "get_iomonitor_state", - return_value=MicronicIOMonitorState.IDLE, - ): - await reader.setup() - try: - with patch.object( - reader.rack_reading, - "scan_rack_id", - return_value="5500135415", - ) as scan_rack_id: - rack_id = await reader.rack_reading.scan_rack_id( - timeout=reader.default_timeout, - poll_interval=reader.default_poll_interval, - ) - finally: - await reader.stop() - - self.assertEqual(rack_id, "5500135415") - scan_rack_id.assert_called_once_with(timeout=12.0, poll_interval=0.25) - - async def test_device_accepts_direct_driver_without_barcode_capability(self): - with tempfile.TemporaryDirectory() as image_dir: - reader = MicronicCodeReader( - timeout=12.0, - poll_interval=0.25, - driver=MicronicDirectDriver(image_dir=image_dir), - ) - with patch.object( - reader.driver, - "get_rack_reader_state", - return_value=RackReaderState.IDLE, - ): - await reader.setup() - try: - self.assertIn(reader.rack_reading, reader._capabilities) - self.assertFalse(hasattr(reader, "barcode_scanning")) - finally: - await reader.stop() async def test_direct_frontend_uses_direct_driver(self): reader = MicronicDirectCodeReader(rack_id_override="9500017722") diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index 24f2780d0f1..fa25cd5ed77 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional, Sequence +from typing import Optional from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.rack_reading import ( @@ -13,8 +13,7 @@ RackScanResult, ) -from .direct_driver import MicronicDirectDriver -from .driver import MicronicError, MicronicIOMonitorDriver, MicronicRackReaderDriver +from .driver import MicronicError, MicronicRackReaderDriver class MicronicRackReaderError(MicronicError, RackReaderError): @@ -78,53 +77,3 @@ async def set_current_layout(self, layout: str) -> None: await self.driver.set_current_layout(layout) except MicronicError as exc: raise MicronicRackReaderError(str(exc)) from exc - - -class MicronicIOMonitorRackReadingBackend(MicronicRackReadingBackend): - """Rack-reading backend for the Micronic Code Reader IO Monitor server.""" - - def __init__(self, driver: MicronicIOMonitorDriver): - super().__init__(driver=driver) - - -class MicronicDirectRackReadingBackend(MicronicRackReadingBackend): - """Rack-reading backend for direct Micronic hardware control.""" - - def __init__( - self, - driver: Optional[MicronicDirectDriver] = None, - twain_scanner_path: Optional[str] = None, - twain_source: str = "AVA6PlusG", - sane_device: Optional[str] = None, - scanner_backend: str = "auto", - scan_command: Optional[Sequence[str]] = None, - image_extension: Optional[str] = None, - image_dir: Optional[str] = None, - serial_port: str = "COM4", - rack_id_command: Optional[Sequence[str]] = None, - scanner_timeout_ms: int = 90000, - serial_timeout_ms: int = 2500, - min_wells: int = 96, - keep_images: bool = False, - image_input: Optional[str] = None, - rack_id_override: Optional[str] = None, - ): - if driver is None: - driver = MicronicDirectDriver( - twain_scanner_path=twain_scanner_path, - twain_source=twain_source, - sane_device=sane_device, - scanner_backend=scanner_backend, - scan_command=scan_command, - image_extension=image_extension, - image_dir=image_dir, - serial_port=serial_port, - rack_id_command=rack_id_command, - scanner_timeout_ms=scanner_timeout_ms, - serial_timeout_ms=serial_timeout_ms, - min_wells=min_wells, - keep_images=keep_images, - image_input=image_input, - rack_id_override=rack_id_override, - ) - super().__init__(driver=driver) From e5cbad52eee01cb51728ecdd539fb98778f4abce Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 7 May 2026 15:22:00 -0700 Subject: [PATCH 19/54] Use PLR Serial for Micronic rack IDs --- docs/user_guide/micronic/index.md | 8 ++- .../micronic/code_reader/direct_driver.py | 71 +++++++++++-------- .../micronic/code_reader/micronic_tests.py | 41 +++++++++++ 3 files changed, 88 insertions(+), 32 deletions(-) diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index bf1c9121094..48ecf0549df 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -26,8 +26,9 @@ Rack reading (large scanner that decodes 96 tubes plus the side rack barcode): ## Direct hardware example The operator is responsible for installing any OS-level scanner bridge -(`twain_scan`, `scanimage`, or a custom command), pyserial, and the local Python -decode dependencies in the runtime environment. +(`twain_scan`, `scanimage`, or a custom command), the PLR serial extra +(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime +environment. ```python from pylabrobot.micronic import MicronicCodeReader, MicronicDirectDriver @@ -84,7 +85,8 @@ formatted with `{output_path}`, `{timeout_ms}`, `{twain_source}`, and named `twain_scan`/`twain_scan.exe` on PATH when using the `twain` backend. - Ubuntu/Linux scanner control should use SANE `scanimage` or a custom `scan_command`. PyLabRobot does not install SANE or vendor scanner drivers. - Rack-ID reads use `pyserial`. + Rack-ID reads use `pylabrobot.io.Serial`, which is installed through the + `pylabrobot[serial]` extra. - Direct image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot when using the direct driver. diff --git a/pylabrobot/micronic/code_reader/direct_driver.py b/pylabrobot/micronic/code_reader/direct_driver.py index 9ef593ef21d..6f2b9a72497 100644 --- a/pylabrobot/micronic/code_reader/direct_driver.py +++ b/pylabrobot/micronic/code_reader/direct_driver.py @@ -31,6 +31,7 @@ RackScanEntry, RackScanResult, ) +from pylabrobot.io.serial import Serial from .driver import MicronicError, MicronicRackReaderDriver @@ -170,12 +171,21 @@ async def trigger_rack_scan(self) -> None: async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: del timeout, poll_interval - return await asyncio.to_thread( - read_rack_id, + if self.rack_id_override: + return self.rack_id_override + if self.rack_id_command is not None: + return await asyncio.to_thread( + read_rack_id_command, + format_command( + self.rack_id_command, + serial_port=self.serial_port, + timeout_ms=self.serial_timeout_ms, + ), + self.serial_timeout_ms, + ) + return await read_rack_id_plr_serial( serial_port=self.serial_port, timeout_ms=self.serial_timeout_ms, - rack_id_override=self.rack_id_override, - rack_id_command=self.rack_id_command, ) async def get_scan_result(self) -> RackScanResult: @@ -415,37 +425,40 @@ def read_rack_id( ) return read_rack_id_command(command, timeout_ms) - try: - return read_rack_id_pyserial(serial_port=serial_port, timeout_ms=timeout_ms) - except ImportError as exc: - raise MicronicDirectRackReaderError("Rack ID serial read requires pyserial.") from exc + return asyncio.run(read_rack_id_plr_serial(serial_port=serial_port, timeout_ms=timeout_ms)) -def read_rack_id_pyserial(serial_port: str, timeout_ms: int) -> str: - import serial # type: ignore - +async def read_rack_id_plr_serial(serial_port: str, timeout_ms: int) -> str: deadline = time.monotonic() + timeout_ms / 1000 chunks: list[bytes] = [] + io = Serial( + human_readable_device_name="Micronic rack ID reader", + port=serial_port, + baudrate=9600, + bytesize=7, + parity="E", + stopbits=1, + timeout=0.1, + write_timeout=1.0, + ) try: - with serial.Serial( - port=serial_port, - baudrate=9600, - bytesize=serial.SEVENBITS, - parity=serial.PARITY_EVEN, - stopbits=serial.STOPBITS_ONE, - timeout=0.1, - write_timeout=1.0, - ) as port: - port.reset_input_buffer() - port.write(b"\r\n") - while time.monotonic() < deadline: - value = port.read(1) - if value: - chunks.append(value) - if value in {b"\r", b"\n"}: - break + await io.setup() + await io.reset_input_buffer() + await io.write(b"\r\n") + while time.monotonic() < deadline: + value = await io.read(1) + if value: + chunks.append(value) + if value in {b"\r", b"\n"}: + break except Exception as exc: - raise MicronicDirectRackReaderError(f"Rack ID serial read failed: {exc}") from exc + raise MicronicDirectRackReaderError( + "Rack ID serial read failed. Install the PLR serial extra with " + "`pip install pylabrobot[serial]` and verify the serial port: " + f"{exc}" + ) from exc + finally: + await io.stop() return extract_rack_id(b"".join(chunks).decode("utf-8", errors="ignore")) diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index b477bbcac8b..152a009aee0 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -3,6 +3,7 @@ import tempfile import unittest from pathlib import Path +from typing import cast from unittest.mock import MagicMock, patch from pylabrobot.capabilities.rack_reading import RackReaderState @@ -13,6 +14,7 @@ MicronicDirectRackReaderError, choose_image_extension, read_rack_id, + read_rack_id_plr_serial, run_scan, ) from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend @@ -241,6 +243,45 @@ async def test_read_rack_id_uses_configured_command(self): ) self.assertEqual(rack_id, "9500017722") + async def test_read_rack_id_uses_plr_serial(self): + instances: list[object] = [] + + class FakeSerial: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.reads = iter([b"9", b"5", b"0", b"0", b"0", b"1", b"7", b"7", b"2", b"2", b"\r"]) + self.calls: list[str] = [] + instances.append(self) + + async def setup(self): + self.calls.append("setup") + + async def reset_input_buffer(self): + self.calls.append("reset_input_buffer") + + async def write(self, data: bytes): + self.calls.append(f"write:{data!r}") + + async def read(self, num_bytes: int = 1) -> bytes: + self.calls.append(f"read:{num_bytes}") + return next(self.reads) + + async def stop(self): + self.calls.append("stop") + + with patch("pylabrobot.micronic.code_reader.direct_driver.Serial", FakeSerial): + rack_id = await read_rack_id_plr_serial(serial_port="COM4", timeout_ms=1000) + + self.assertEqual(len(instances), 1) + fake_serial = cast(FakeSerial, instances[0]) + self.assertEqual(rack_id, "9500017722") + self.assertEqual(fake_serial.kwargs["port"], "COM4") + self.assertEqual(fake_serial.kwargs["bytesize"], 7) + self.assertEqual(fake_serial.kwargs["parity"], "E") + self.assertIn("reset_input_buffer", fake_serial.calls) + self.assertIn("write:b'\\r\\n'", fake_serial.calls) + self.assertEqual(fake_serial.calls[-1], "stop") + async def test_direct_driver_scan_rack_id_uses_configured_command(self): driver = MicronicDirectDriver( rack_id_command=[sys.executable, "-c", "print('rack 9500017722')"] From 0297703bc059fdf33c0b3f6f30f8109a8dec3a51 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 7 May 2026 15:25:12 -0700 Subject: [PATCH 20/54] Collapse Micronic driver abstraction --- docs/api/pylabrobot.micronic.rst | 9 ---- pylabrobot/micronic/__init__.py | 1 - pylabrobot/micronic/code_reader/__init__.py | 3 -- .../micronic/code_reader/code_reader.py | 5 +- .../micronic/code_reader/direct_driver.py | 9 ++-- pylabrobot/micronic/code_reader/driver.py | 52 ------------------- .../code_reader/rack_reading_backend.py | 8 +-- 7 files changed, 12 insertions(+), 75 deletions(-) delete mode 100644 pylabrobot/micronic/code_reader/driver.py diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index aa6cdafd5c1..30f64a30696 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -31,15 +31,6 @@ Driver MicronicDirectDriver MicronicDirectRackReaderError - -.. currentmodule:: pylabrobot.micronic.code_reader.driver - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - MicronicRackReaderDriver MicronicError diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index 479d0b05771..01939c19b24 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -4,7 +4,6 @@ MicronicDirectDriver, MicronicDirectRackReaderError, MicronicError, - MicronicRackReaderDriver, MicronicRackReadingBackend, MicronicRackReaderError, ) diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index efd53dd8afd..bdc7e38584a 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -3,10 +3,7 @@ from pylabrobot.micronic.code_reader.direct_driver import ( MicronicDirectDriver, MicronicDirectRackReaderError, -) -from pylabrobot.micronic.code_reader.driver import ( MicronicError, - MicronicRackReaderDriver, ) from pylabrobot.micronic.code_reader.rack_reading_backend import ( MicronicRackReadingBackend, diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index fbfa84e4e1b..b18ceab6d00 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -8,7 +8,6 @@ from pylabrobot.device import Device from .direct_driver import MicronicDirectDriver -from .driver import MicronicRackReaderDriver from .rack_reading_backend import MicronicRackReadingBackend @@ -23,12 +22,12 @@ def __init__( self, timeout: float = 60.0, poll_interval: float = 1.0, - driver: Optional[MicronicRackReaderDriver] = None, + driver: Optional[MicronicDirectDriver] = None, ): if driver is None: driver = MicronicDirectDriver(scanner_timeout_ms=int(timeout * 1000)) super().__init__(driver=driver) - self.driver: MicronicRackReaderDriver = driver + self.driver: MicronicDirectDriver = driver self.default_timeout = timeout self.default_poll_interval = poll_interval self.rack_reading = RackReader(backend=MicronicRackReadingBackend(driver)) diff --git a/pylabrobot/micronic/code_reader/direct_driver.py b/pylabrobot/micronic/code_reader/direct_driver.py index 6f2b9a72497..862e1a4cf31 100644 --- a/pylabrobot/micronic/code_reader/direct_driver.py +++ b/pylabrobot/micronic/code_reader/direct_driver.py @@ -31,10 +31,9 @@ RackScanEntry, RackScanResult, ) +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial -from .driver import MicronicError, MicronicRackReaderDriver - ROWS = "ABCDEFGH" COLS = 12 @@ -42,6 +41,10 @@ RACK_COLS = 12 +class MicronicError(Exception): + """Raised when Micronic driver operations fail.""" + + class MicronicDirectRackReaderError(MicronicError): """Raised when direct Micronic hardware control fails.""" @@ -52,7 +55,7 @@ class DecodeResult: method: str -class MicronicDirectDriver(MicronicRackReaderDriver): +class MicronicDirectDriver(Driver): """Driver that controls the Micronic scanner without the OEM app.""" def __init__( diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py deleted file mode 100644 index 7d6ddb04350..00000000000 --- a/pylabrobot/micronic/code_reader/driver.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Shared contracts for Micronic rack-reader drivers.""" - -from __future__ import annotations - -from abc import abstractmethod - -from pylabrobot.capabilities.rack_reading import ( - LayoutInfo, - RackReaderState, - RackScanResult, -) -from pylabrobot.device import Driver - - -class MicronicError(Exception): - """Raised when Micronic driver operations fail.""" - - -class MicronicRackReaderDriver(Driver): - """Driver contract used by the Micronic rack-reading backend.""" - - @abstractmethod - async def get_rack_reader_state(self) -> RackReaderState: - """Return the current rack-reader state.""" - - @abstractmethod - async def trigger_rack_scan(self) -> None: - """Initiate a rack-wide scan.""" - - @abstractmethod - async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - """Perform a rack-barcode-only scan and return the rack identifier.""" - - @abstractmethod - async def get_scan_result(self) -> RackScanResult: - """Return the most recent rack scan result.""" - - @abstractmethod - async def get_rack_id(self) -> str: - """Return the rack identifier reported by the scanner.""" - - @abstractmethod - async def get_layouts(self) -> list[LayoutInfo]: - """Return supported layouts.""" - - @abstractmethod - async def get_current_layout(self) -> str: - """Return the active layout.""" - - @abstractmethod - async def set_current_layout(self, layout: str) -> None: - """Set the active layout.""" diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index fa25cd5ed77..2a16917e423 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -1,4 +1,4 @@ -"""Rack-reading backend for Micronic rack-reader drivers.""" +"""Rack-reading backend for the Micronic direct driver.""" from __future__ import annotations @@ -13,7 +13,7 @@ RackScanResult, ) -from .driver import MicronicError, MicronicRackReaderDriver +from .direct_driver import MicronicDirectDriver, MicronicError class MicronicRackReaderError(MicronicError, RackReaderError): @@ -21,9 +21,9 @@ class MicronicRackReaderError(MicronicError, RackReaderError): class MicronicRackReadingBackend(RackReaderBackend): - """Rack-reading backend that delegates to a Micronic rack-reader driver.""" + """Rack-reading backend that delegates to the Micronic direct driver.""" - def __init__(self, driver: MicronicRackReaderDriver): + def __init__(self, driver: MicronicDirectDriver): super().__init__() self.driver = driver From 30416f869c31f61ebad73af750c9d39e57d4a053 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 7 May 2026 15:36:45 -0700 Subject: [PATCH 21/54] Simplify Micronic public names --- docs/api/pylabrobot.micronic.rst | 6 +- docs/user_guide/micronic/index.md | 48 ++++----- pylabrobot/micronic/__init__.py | 4 +- pylabrobot/micronic/code_reader/__init__.py | 6 +- .../micronic/code_reader/code_reader.py | 54 +++------- .../{direct_driver.py => driver.py} | 79 ++++++-------- .../micronic/code_reader/micronic_tests.py | 102 +++++++++--------- .../code_reader/rack_reading_backend.py | 8 +- 8 files changed, 129 insertions(+), 178 deletions(-) rename pylabrobot/micronic/code_reader/{direct_driver.py => driver.py} (90%) diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index 30f64a30696..744042b4ce1 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -16,21 +16,19 @@ Device :recursive: MicronicCodeReader - MicronicDirectCodeReader Driver ------ -.. currentmodule:: pylabrobot.micronic.code_reader.direct_driver +.. currentmodule:: pylabrobot.micronic.code_reader.driver .. autosummary:: :toctree: _autosummary :nosignatures: :recursive: - MicronicDirectDriver - MicronicDirectRackReaderError + MicronicDriver MicronicError diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index 48ecf0549df..f5f6d3eee4b 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -3,15 +3,12 @@ PyLabRobot includes `v1b1` Micronic integrations built on the generic `rack_reading` capability. -`MicronicDirectDriver` controls the local hardware directly. It acquires the -rack image through a configured scanner command, a Windows TWAIN helper -available on PATH, or Ubuntu/Linux SANE `scanimage`; reads the side rack barcode -through the serial reader; decodes tube DataMatrix codes locally; and returns a -standard `RackScanResult`. It does not call Micronic Code Reader or IO Monitor, -and PyLabRobot does not package any scanner helper executable. - -`MicronicDirectCodeReader` is a convenience frontend that constructs -`MicronicCodeReader` with `MicronicDirectDriver`. +`MicronicCodeReader` controls the local hardware directly. It acquires the rack +image through a configured scanner command, a Windows TWAIN helper available on +PATH, or Ubuntu/Linux SANE `scanimage`; reads the side rack barcode through the +serial reader; decodes tube DataMatrix codes locally; and returns a standard +`RackScanResult`. It does not call Micronic Code Reader or IO Monitor, and +PyLabRobot does not package any scanner helper executable. ## Supported operations @@ -23,7 +20,7 @@ Rack reading (large scanner that decodes 96 tubes plus the side rack barcode): - `rack_reading.get_layouts()`, `get_current_layout()`, and `set_current_layout()` for the fixed 8x12 rack layout -## Direct hardware example +## Hardware example The operator is responsible for installing any OS-level scanner bridge (`twain_scan`, `scanimage`, or a custom command), the PLR serial extra @@ -31,17 +28,15 @@ The operator is responsible for installing any OS-level scanner bridge environment. ```python -from pylabrobot.micronic import MicronicCodeReader, MicronicDirectDriver +from pylabrobot.micronic import MicronicCodeReader reader = MicronicCodeReader( - driver=MicronicDirectDriver( - scanner_backend="twain", - twain_scanner_path=r"C:\Tools\twain_scan.exe", - twain_source="AVA6PlusG", - image_dir=r"C:\ProgramData\Alakascan\data\direct-images", - serial_port="COM4", - keep_images=True, - ) + scanner_backend="twain", + twain_scanner_path=r"C:\Tools\twain_scan.exe", + twain_source="AVA6PlusG", + image_dir=r"C:\ProgramData\Alakascan\data\micronic-images", + serial_port="COM4", + keep_images=True, ) await reader.setup() @@ -60,12 +55,10 @@ On Ubuntu/Linux, use SANE if the scanner is exposed by a SANE backend: ```python reader = MicronicCodeReader( - driver=MicronicDirectDriver( - scanner_backend="sane", - sane_device="avision:libusb:001:004", - serial_port="/dev/ttyUSB0", - image_extension="tiff", - ) + scanner_backend="sane", + sane_device="avision:libusb:001:004", + serial_port="/dev/ttyUSB0", + image_extension="tiff", ) ``` @@ -87,7 +80,6 @@ formatted with `{output_path}`, `{timeout_ms}`, `{twain_source}`, and `scan_command`. PyLabRobot does not install SANE or vendor scanner drivers. Rack-ID reads use `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]` extra. -- Direct image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and - `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot - when using the direct driver. +- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and + `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot. - Use `image_input` for offline decode checks without touching scanner hardware. diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index 01939c19b24..f55676153d0 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -1,9 +1,7 @@ from pylabrobot.micronic.code_reader import ( MicronicCodeReader, - MicronicDirectCodeReader, - MicronicDirectDriver, - MicronicDirectRackReaderError, MicronicError, + MicronicDriver, MicronicRackReadingBackend, MicronicRackReaderError, ) diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index bdc7e38584a..2498dcd12a3 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -1,9 +1,7 @@ from pylabrobot.micronic.code_reader.code_reader import MicronicCodeReader -from pylabrobot.micronic.code_reader.code_reader import MicronicDirectCodeReader -from pylabrobot.micronic.code_reader.direct_driver import ( - MicronicDirectDriver, - MicronicDirectRackReaderError, +from pylabrobot.micronic.code_reader.driver import ( MicronicError, + MicronicDriver, ) from pylabrobot.micronic.code_reader.rack_reading_backend import ( MicronicRackReadingBackend, diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index b18ceab6d00..c768f530a54 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -7,45 +7,14 @@ from pylabrobot.capabilities.rack_reading import RackReader from pylabrobot.device import Device -from .direct_driver import MicronicDirectDriver +from .driver import MicronicDriver from .rack_reading_backend import MicronicRackReadingBackend class MicronicCodeReader(Device): """Micronic rack reader device. - The rack-reading capability is driven by ``driver``. By default this uses - ``MicronicDirectDriver`` to control the scanner hardware directly. - """ - - def __init__( - self, - timeout: float = 60.0, - poll_interval: float = 1.0, - driver: Optional[MicronicDirectDriver] = None, - ): - if driver is None: - driver = MicronicDirectDriver(scanner_timeout_ms=int(timeout * 1000)) - super().__init__(driver=driver) - self.driver: MicronicDirectDriver = driver - self.default_timeout = timeout - self.default_poll_interval = poll_interval - self.rack_reading = RackReader(backend=MicronicRackReadingBackend(driver)) - self._capabilities = [self.rack_reading] - - def serialize(self) -> dict: - return { - **super().serialize(), - "timeout": self.default_timeout, - "poll_interval": self.default_poll_interval, - } - - -class MicronicDirectCodeReader(MicronicCodeReader): - """Micronic rack reader that controls scanner hardware directly. - - This frontend follows the same v1b1 rack-reading capability surface as - ``MicronicCodeReader`` while exposing direct-driver setup options. + The rack-reading capability is driven by ``MicronicDriver``. """ def __init__( @@ -66,10 +35,10 @@ def __init__( keep_images: bool = False, image_input: Optional[str] = None, rack_id_override: Optional[str] = None, - driver: Optional[MicronicDirectDriver] = None, + driver: Optional[MicronicDriver] = None, ): if driver is None: - driver = MicronicDirectDriver( + driver = MicronicDriver( twain_scanner_path=twain_scanner_path, twain_source=twain_source, sane_device=sane_device, @@ -86,5 +55,16 @@ def __init__( image_input=image_input, rack_id_override=rack_id_override, ) - super().__init__(timeout=timeout, poll_interval=poll_interval, driver=driver) - self.driver: MicronicDirectDriver = driver + super().__init__(driver=driver) + self.driver: MicronicDriver = driver + self.default_timeout = timeout + self.default_poll_interval = poll_interval + self.rack_reading = RackReader(backend=MicronicRackReadingBackend(driver)) + self._capabilities = [self.rack_reading] + + def serialize(self) -> dict: + return { + **super().serialize(), + "timeout": self.default_timeout, + "poll_interval": self.default_poll_interval, + } diff --git a/pylabrobot/micronic/code_reader/direct_driver.py b/pylabrobot/micronic/code_reader/driver.py similarity index 90% rename from pylabrobot/micronic/code_reader/direct_driver.py rename to pylabrobot/micronic/code_reader/driver.py index 862e1a4cf31..253ec14191e 100644 --- a/pylabrobot/micronic/code_reader/direct_driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -1,4 +1,4 @@ -"""Direct hardware driver for the Micronic rack scanner. +"""Hardware driver for the Micronic rack scanner. This driver does not call Micronic Code Reader or IO Monitor. It owns the local scanner path directly: @@ -45,17 +45,13 @@ class MicronicError(Exception): """Raised when Micronic driver operations fail.""" -class MicronicDirectRackReaderError(MicronicError): - """Raised when direct Micronic hardware control fails.""" - - @dataclass(frozen=True) class DecodeResult: tube_id: str method: str -class MicronicDirectDriver(Driver): +class MicronicDriver(Driver): """Driver that controls the Micronic scanner without the OEM app.""" def __init__( @@ -84,7 +80,7 @@ def __init__( self.scan_command = list(scan_command) if scan_command is not None else None self.image_extension = normalize_image_extension(image_extension) if image_extension else None self.image_dir = ( - Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "alakascan-direct" + Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "alakascan-micronic" ) self.serial_port = serial_port self.rack_id_command = list(rack_id_command) if rack_id_command is not None else None @@ -119,7 +115,7 @@ async def stop(self): try: self._last_result = await scan_task except asyncio.CancelledError: - self._scan_error = MicronicDirectRackReaderError("Direct Micronic rack scan was cancelled.") + self._scan_error = MicronicError("Micronic rack scan was cancelled.") self._state = RackReaderState.IDLE except Exception as exc: self._scan_error = exc @@ -164,7 +160,7 @@ async def get_rack_reader_state(self) -> RackReaderState: async def trigger_rack_scan(self) -> None: self._complete_finished_scan_task() if self._scan_task is not None and not self._scan_task.done(): - raise MicronicDirectRackReaderError("Direct Micronic rack scan is already in progress.") + raise MicronicError("Micronic rack scan is already in progress.") self._last_result = None self._state = RackReaderState.SCANNING self._scan_error = None @@ -196,9 +192,9 @@ async def get_scan_result(self) -> RackScanResult: if self._scan_error is not None: raise self._scan_error if self._state == RackReaderState.SCANNING: - raise MicronicDirectRackReaderError("Direct Micronic rack scan is still in progress.") + raise MicronicError("Micronic rack scan is still in progress.") if self._last_result is None: - raise MicronicDirectRackReaderError("No direct Micronic rack scan has completed yet.") + raise MicronicError("No Micronic rack scan has completed yet.") return self._last_result async def get_rack_id(self) -> str: @@ -206,7 +202,7 @@ async def get_rack_id(self) -> str: if self._scan_error is not None: raise self._scan_error if self._state == RackReaderState.SCANNING: - raise MicronicDirectRackReaderError("Direct Micronic rack scan is still in progress.") + raise MicronicError("Micronic rack scan is still in progress.") if self._last_result is not None: return self._last_result.rack_id return await self.scan_rack_id(timeout=0, poll_interval=0) @@ -223,7 +219,7 @@ def _complete_scan_task(self, task: asyncio.Future[RackScanResult]) -> None: try: self._last_result = task.result() except asyncio.CancelledError: - self._scan_error = MicronicDirectRackReaderError("Direct Micronic rack scan was cancelled.") + self._scan_error = MicronicError("Micronic rack scan was cancelled.") self._state = RackReaderState.IDLE except Exception as exc: self._scan_error = exc @@ -241,7 +237,7 @@ async def get_current_layout(self) -> str: async def set_current_layout(self, layout: str) -> None: normalized = layout.strip().lower().replace(" ", "") if normalized not in {"8x12", "96(8x12)", "96"}: - raise MicronicDirectRackReaderError(f"Unsupported direct Micronic rack layout: {layout}") + raise MicronicError(f"Unsupported Micronic rack layout: {layout}") def _scan_rack_blocking(self) -> RackScanResult: self.image_dir.mkdir(parents=True, exist_ok=True) @@ -254,8 +250,7 @@ def _scan_rack_blocking(self) -> RackScanResult: sane_device=self.sane_device, ) image_path = ( - self.image_dir - / f"micronic_direct_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.{image_extension}" + self.image_dir / f"micronic_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.{image_extension}" ) self.last_scan_metadata = run_scan( @@ -279,8 +274,8 @@ def _scan_rack_blocking(self) -> RackScanResult: decoded, self.last_decode_metadata = decode_image(image_path) if len(decoded) < self.min_wells: missing = ", ".join(position for position in iter_positions() if position not in decoded) - raise MicronicDirectRackReaderError( - f"Direct Micronic decode found {len(decoded)} wells; expected at least {self.min_wells}. " + raise MicronicError( + f"Micronic decode found {len(decoded)} wells; expected at least {self.min_wells}. " f"Missing: {missing}" ) @@ -325,7 +320,7 @@ def run_scan( if image_input: source_path = Path(image_input) if not source_path.exists(): - raise MicronicDirectRackReaderError(f"Image input does not exist: {source_path}") + raise MicronicError(f"Image input does not exist: {source_path}") output_path.write_bytes(source_path.read_bytes()) return {"stdout": "", "stderr": "", "source": str(source_path)} @@ -341,9 +336,7 @@ def run_scan( backend = normalize_scanner_backend(scanner_backend) if backend == "command": - raise MicronicDirectRackReaderError( - "Command scan requested, but scan_command was not configured." - ) + raise MicronicError("Command scan requested, but scan_command was not configured.") if backend in {"auto", "twain"}: resolved_twain_path = resolve_twain_scanner_path(twain_scanner_path) @@ -355,7 +348,7 @@ def run_scan( source="twain", ) if backend == "twain": - raise MicronicDirectRackReaderError( + raise MicronicError( "TWAIN scan requested, but no TWAIN helper was configured. Set " "twain_scanner_path, MICRONIC_TWAIN_SCANNER_PATH, or put twain_scan on PATH." ) @@ -369,12 +362,10 @@ def run_scan( command.extend(["--format=tiff", "--output-file", str(output_path)]) return run_scan_command(command, output_path, timeout_ms, source="sane") if backend == "sane": - raise MicronicDirectRackReaderError( - "SANE scan requested, but scanimage was not found on PATH." - ) + raise MicronicError("SANE scan requested, but scanimage was not found on PATH.") - raise MicronicDirectRackReaderError( - "No direct scan acquisition method is available. Configure scan_command, " + raise MicronicError( + "No scan acquisition method is available. Configure scan_command, " "twain_scanner_path/MICRONIC_TWAIN_SCANNER_PATH, or install SANE scanimage." ) @@ -394,15 +385,15 @@ def run_scan_command( timeout=(timeout_ms / 1000) + 15, ) except FileNotFoundError as exc: - raise MicronicDirectRackReaderError(f"Scan command was not found: {command[0]}") from exc + raise MicronicError(f"Scan command was not found: {command[0]}") from exc if completed.returncode != 0: - raise MicronicDirectRackReaderError( + raise MicronicError( "Scan command failed with exit code " f"{completed.returncode}: {completed.stderr.strip() or completed.stdout.strip()}" ) if not output_path.exists(): - raise MicronicDirectRackReaderError(f"Scan command did not create image: {output_path}") + raise MicronicError(f"Scan command did not create image: {output_path}") return { "stdout": completed.stdout.strip(), "stderr": completed.stderr.strip(), @@ -455,7 +446,7 @@ async def read_rack_id_plr_serial(serial_port: str, timeout_ms: int) -> str: if value in {b"\r", b"\n"}: break except Exception as exc: - raise MicronicDirectRackReaderError( + raise MicronicError( "Rack ID serial read failed. Install the PLR serial extra with " "`pip install pylabrobot[serial]` and verify the serial port: " f"{exc}" @@ -476,10 +467,10 @@ def read_rack_id_command(command: Sequence[str], timeout_ms: int) -> str: timeout=(timeout_ms / 1000) + 5, ) except FileNotFoundError as exc: - raise MicronicDirectRackReaderError(f"Rack ID command was not found: {command[0]}") from exc + raise MicronicError(f"Rack ID command was not found: {command[0]}") from exc if completed.returncode != 0: - raise MicronicDirectRackReaderError( + raise MicronicError( "Rack ID command failed with exit code " f"{completed.returncode}: {completed.stderr.strip() or completed.stdout.strip()}" ) @@ -494,7 +485,7 @@ def extract_rack_id(text: str) -> str: def normalize_scanner_backend(scanner_backend: str) -> str: backend = scanner_backend.strip().lower() if backend not in {"auto", "twain", "sane", "command"}: - raise MicronicDirectRackReaderError( + raise MicronicError( "Unsupported scanner backend " f"{scanner_backend!r}; expected 'auto', 'twain', 'sane', or 'command'." ) @@ -504,7 +495,7 @@ def normalize_scanner_backend(scanner_backend: str) -> str: def normalize_image_extension(image_extension: str) -> str: normalized = image_extension.strip().lstrip(".") if not normalized: - raise MicronicDirectRackReaderError("image_extension must not be empty.") + raise MicronicError("image_extension must not be empty.") return normalized @@ -578,9 +569,7 @@ def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, o ) if len(detected) < 24: - raise MicronicDirectRackReaderError( - f"Only {len(detected)} DataMatrix codes were found in the full image." - ) + raise MicronicError(f"Only {len(detected)} DataMatrix codes were found in the full image.") xs = fitted_axis(cluster_axis([item[0] for item in detected], RACK_ROWS, 90), RACK_ROWS) ys = fitted_axis(cluster_axis([item[1] for item in detected], RACK_COLS, 90), RACK_COLS) @@ -615,7 +604,7 @@ def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, o duplicate_ids = find_duplicate_ids(decoded) if duplicate_ids: - raise MicronicDirectRackReaderError( + raise MicronicError( f"Duplicate tube IDs decoded from more than one well: {', '.join(duplicate_ids)}" ) @@ -637,8 +626,8 @@ def import_decode_dependencies(): import zxingcpp # type: ignore from PIL import Image, ImageOps # type: ignore except ImportError as exc: - raise MicronicDirectRackReaderError( - "Direct Micronic decode dependencies are missing. Install pillow, " + raise MicronicError( + "Micronic decode dependencies are missing. Install pillow, " "opencv-python-headless, numpy, and zxing-cpp." ) from exc return cv2, np, zxingcpp, Image, ImageOps @@ -646,9 +635,7 @@ def import_decode_dependencies(): def cluster_axis(values: list[float], expected_count: int, tolerance: float) -> list[float]: if not values: - raise MicronicDirectRackReaderError( - "No decoded barcode positions are available for grid calibration." - ) + raise MicronicError("No decoded barcode positions are available for grid calibration.") clusters: list[list[float]] = [] for value in sorted(values): @@ -669,7 +656,7 @@ def cluster_axis(values: list[float], expected_count: int, tolerance: float) -> means[0] + index * (means[-1] - means[0]) / (expected_count - 1) for index in range(expected_count) ] - raise MicronicDirectRackReaderError( + raise MicronicError( f"Could not fit {expected_count} grid clusters from {len(values)} decoded positions." ) diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 152a009aee0..2b63a3f3eb4 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -7,11 +7,11 @@ from unittest.mock import MagicMock, patch from pylabrobot.capabilities.rack_reading import RackReaderState -from pylabrobot.micronic import MicronicCodeReader, MicronicDirectCodeReader -from pylabrobot.micronic.code_reader.direct_driver import ( +from pylabrobot.micronic import MicronicCodeReader +from pylabrobot.micronic.code_reader.driver import ( DecodeResult, - MicronicDirectDriver, - MicronicDirectRackReaderError, + MicronicDriver, + MicronicError, choose_image_extension, read_rack_id, read_rack_id_plr_serial, @@ -20,23 +20,23 @@ from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend -async def wait_for_direct_dataready(driver: MicronicDirectDriver) -> None: +async def wait_for_dataready(driver: MicronicDriver) -> None: for _ in range(100): if await driver.get_rack_reader_state() == RackReaderState.DATAREADY: return await asyncio.sleep(0.01) - raise AssertionError("Direct Micronic test scan did not reach dataready.") + raise AssertionError("Micronic test scan did not reach dataready.") -class TestMicronicDirectDriver(unittest.IsolatedAsyncioTestCase): - def test_direct_driver_does_not_default_to_packaged_twain_helper(self): - driver = MicronicDirectDriver() +class TestMicronicDriver(unittest.IsolatedAsyncioTestCase): + def test_driver_does_not_default_to_packaged_twain_helper(self): + driver = MicronicDriver() self.assertIsNone(driver.twain_scanner_path) self.assertIsNone(driver.scan_command) - async def test_direct_driver_scan_populates_standard_rack_result(self): + async def test_driver_scan_populates_standard_rack_result(self): with tempfile.TemporaryDirectory() as image_dir: - driver = MicronicDirectDriver( + driver = MicronicDriver( image_dir=image_dir, min_wells=2, keep_images=True, @@ -47,21 +47,21 @@ async def test_direct_driver_scan_populates_standard_rack_result(self): } with ( patch( - "pylabrobot.micronic.code_reader.direct_driver.run_scan", + "pylabrobot.micronic.code_reader.driver.run_scan", return_value={"source": "test"}, ) as run_scan_mock, patch( - "pylabrobot.micronic.code_reader.direct_driver.read_rack_id", + "pylabrobot.micronic.code_reader.driver.read_rack_id", return_value="9500017722", ) as read_rack_id_mock, patch( - "pylabrobot.micronic.code_reader.direct_driver.decode_image", + "pylabrobot.micronic.code_reader.driver.decode_image", return_value=(decoded, {"decodedWells": 2}), ) as decode_image_mock, ): await driver.setup() await driver.trigger_rack_scan() - await wait_for_direct_dataready(driver) + await wait_for_dataready(driver) result = await driver.get_scan_result() self.assertEqual(await driver.get_rack_reader_state(), RackReaderState.DATAREADY) @@ -75,10 +75,10 @@ async def test_direct_driver_scan_populates_standard_rack_result(self): read_rack_id_mock.assert_called_once() decode_image_mock.assert_called_once() - async def test_direct_reader_can_scan_twice_after_dataready(self): + async def test_reader_can_scan_twice_after_dataready(self): with tempfile.TemporaryDirectory() as image_dir: reader = MicronicCodeReader( - driver=MicronicDirectDriver( + driver=MicronicDriver( image_dir=image_dir, min_wells=1, keep_images=True, @@ -87,15 +87,15 @@ async def test_direct_reader_can_scan_twice_after_dataready(self): decoded = {"A01": DecodeResult(tube_id="1111111111", method="test")} with ( patch( - "pylabrobot.micronic.code_reader.direct_driver.run_scan", + "pylabrobot.micronic.code_reader.driver.run_scan", return_value={"source": "test"}, ) as run_scan_mock, patch( - "pylabrobot.micronic.code_reader.direct_driver.read_rack_id", + "pylabrobot.micronic.code_reader.driver.read_rack_id", return_value="9500017722", ), patch( - "pylabrobot.micronic.code_reader.direct_driver.decode_image", + "pylabrobot.micronic.code_reader.driver.decode_image", return_value=(decoded, {"decodedWells": 1}), ), ): @@ -107,9 +107,9 @@ async def test_direct_reader_can_scan_twice_after_dataready(self): self.assertEqual(second.rack_id, "9500017722") self.assertEqual(run_scan_mock.call_count, 2) - async def test_direct_driver_get_rack_id_does_not_return_stale_result_while_scanning(self): + async def test_driver_get_rack_id_does_not_return_stale_result_while_scanning(self): with tempfile.TemporaryDirectory() as image_dir: - driver = MicronicDirectDriver( + driver = MicronicDriver( image_dir=image_dir, min_wells=1, keep_images=True, @@ -125,41 +125,41 @@ def slow_scan(*args, **kwargs): with ( patch( - "pylabrobot.micronic.code_reader.direct_driver.run_scan", + "pylabrobot.micronic.code_reader.driver.run_scan", return_value={"source": "test"}, ), patch( - "pylabrobot.micronic.code_reader.direct_driver.read_rack_id", + "pylabrobot.micronic.code_reader.driver.read_rack_id", return_value="9500017722", ), patch( - "pylabrobot.micronic.code_reader.direct_driver.decode_image", + "pylabrobot.micronic.code_reader.driver.decode_image", return_value=(decoded, {"decodedWells": 1}), ), ): await driver.setup() await driver.trigger_rack_scan() - await wait_for_direct_dataready(driver) + await wait_for_dataready(driver) self.assertEqual(await driver.get_rack_id(), "9500017722") with ( patch( - "pylabrobot.micronic.code_reader.direct_driver.run_scan", + "pylabrobot.micronic.code_reader.driver.run_scan", side_effect=slow_scan, ), patch( - "pylabrobot.micronic.code_reader.direct_driver.read_rack_id", + "pylabrobot.micronic.code_reader.driver.read_rack_id", return_value="9500017723", ), patch( - "pylabrobot.micronic.code_reader.direct_driver.decode_image", + "pylabrobot.micronic.code_reader.driver.decode_image", return_value=(decoded, {"decodedWells": 1}), ), ): await driver.trigger_rack_scan() - with self.assertRaises(MicronicDirectRackReaderError): + with self.assertRaises(MicronicError): await driver.get_rack_id() - await wait_for_direct_dataready(driver) + await wait_for_dataready(driver) self.assertEqual(await driver.get_rack_id(), "9500017723") async def test_run_scan_uses_explicit_command(self): @@ -183,11 +183,11 @@ async def test_run_scan_uses_sane_scanimage_when_requested(self): output_path = Path(image_dir) / "micronic-test.tiff" with ( patch( - "pylabrobot.micronic.code_reader.direct_driver.shutil.which", + "pylabrobot.micronic.code_reader.driver.shutil.which", return_value="/usr/bin/scanimage", ), patch( - "pylabrobot.micronic.code_reader.direct_driver.run_scan_command", + "pylabrobot.micronic.code_reader.driver.run_scan_command", return_value={"source": "sane"}, ) as run_scan_command, ): @@ -216,8 +216,8 @@ async def test_run_scan_uses_sane_scanimage_when_requested(self): async def test_run_scan_requires_configured_acquisition(self): with tempfile.TemporaryDirectory() as image_dir: with ( - patch("pylabrobot.micronic.code_reader.direct_driver.shutil.which", return_value=None), - self.assertRaises(MicronicDirectRackReaderError), + patch("pylabrobot.micronic.code_reader.driver.shutil.which", return_value=None), + self.assertRaises(MicronicError), ): run_scan( output_path=Path(image_dir) / "micronic-test.bmp", @@ -269,7 +269,7 @@ async def read(self, num_bytes: int = 1) -> bytes: async def stop(self): self.calls.append("stop") - with patch("pylabrobot.micronic.code_reader.direct_driver.Serial", FakeSerial): + with patch("pylabrobot.micronic.code_reader.driver.Serial", FakeSerial): rack_id = await read_rack_id_plr_serial(serial_port="COM4", timeout_ms=1000) self.assertEqual(len(instances), 1) @@ -282,24 +282,22 @@ async def stop(self): self.assertIn("write:b'\\r\\n'", fake_serial.calls) self.assertEqual(fake_serial.calls[-1], "stop") - async def test_direct_driver_scan_rack_id_uses_configured_command(self): - driver = MicronicDirectDriver( - rack_id_command=[sys.executable, "-c", "print('rack 9500017722')"] - ) + async def test_driver_scan_rack_id_uses_configured_command(self): + driver = MicronicDriver(rack_id_command=[sys.executable, "-c", "print('rack 9500017722')"]) self.assertEqual(await driver.scan_rack_id(timeout=1.0, poll_interval=0.1), "9500017722") - async def test_direct_driver_raises_when_scan_result_is_not_ready(self): - driver = MicronicDirectDriver() - with self.assertRaises(MicronicDirectRackReaderError): + async def test_driver_raises_when_scan_result_is_not_ready(self): + driver = MicronicDriver() + with self.assertRaises(MicronicError): await driver.get_scan_result() - async def test_direct_driver_rejects_unknown_layout(self): - driver = MicronicDirectDriver() - with self.assertRaises(MicronicDirectRackReaderError): + async def test_driver_rejects_unknown_layout(self): + driver = MicronicDriver() + with self.assertRaises(MicronicError): await driver.set_current_layout("384") - async def test_generic_backend_delegates_to_direct_driver(self): - driver = MicronicDirectDriver(rack_id_override="9500017722") + async def test_backend_delegates_to_driver(self): + driver = MicronicDriver(rack_id_override="9500017722") backend = MicronicRackReadingBackend(driver=driver) with patch.object(driver, "scan_rack_id", return_value="9500017722") as scan_rack_id: rack_id = await backend.scan_rack_id(timeout=5.0, poll_interval=0.5) @@ -312,7 +310,7 @@ async def test_device_exposes_rack_reading_only(self): reader = MicronicCodeReader( timeout=12.0, poll_interval=0.25, - driver=MicronicDirectDriver(rack_id_override="9500017722"), + driver=MicronicDriver(rack_id_override="9500017722"), ) with patch.object( reader.driver, @@ -338,9 +336,9 @@ async def test_device_exposes_rack_reading_only(self): self.assertEqual(result.rack_id, "9500017722") scan_rack.assert_called_once_with(timeout=12.0, poll_interval=0.25) - async def test_direct_frontend_uses_direct_driver(self): - reader = MicronicDirectCodeReader(rack_id_override="9500017722") - self.assertIsInstance(reader.driver, MicronicDirectDriver) + async def test_frontend_uses_driver(self): + reader = MicronicCodeReader(rack_id_override="9500017722") + self.assertIsInstance(reader.driver, MicronicDriver) self.assertFalse(hasattr(reader, "barcode_scanning")) diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index 2a16917e423..b376fe83fb6 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -1,4 +1,4 @@ -"""Rack-reading backend for the Micronic direct driver.""" +"""Rack-reading backend for the Micronic driver.""" from __future__ import annotations @@ -13,7 +13,7 @@ RackScanResult, ) -from .direct_driver import MicronicDirectDriver, MicronicError +from .driver import MicronicDriver, MicronicError class MicronicRackReaderError(MicronicError, RackReaderError): @@ -21,9 +21,9 @@ class MicronicRackReaderError(MicronicError, RackReaderError): class MicronicRackReadingBackend(RackReaderBackend): - """Rack-reading backend that delegates to the Micronic direct driver.""" + """Rack-reading backend that delegates to the Micronic driver.""" - def __init__(self, driver: MicronicDirectDriver): + def __init__(self, driver: MicronicDriver): super().__init__() self.driver = driver From 4aa0e219c6d1c0fdb7da8a77b59c225c5b2e3146 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 7 May 2026 15:38:39 -0700 Subject: [PATCH 22/54] Update rack-reading docs for Micronic names --- docs/user_guide/capabilities/rack-reading.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user_guide/capabilities/rack-reading.md b/docs/user_guide/capabilities/rack-reading.md index 05752feb6d8..a8e163a7ade 100644 --- a/docs/user_guide/capabilities/rack-reading.md +++ b/docs/user_guide/capabilities/rack-reading.md @@ -34,9 +34,9 @@ Lower-level methods are also available: ## Example With Micronic ```python -from pylabrobot.micronic import MicronicDirectCodeReader +from pylabrobot.micronic import MicronicCodeReader -reader = MicronicDirectCodeReader( +reader = MicronicCodeReader( scanner_backend="sane", sane_device="avision:libusb:001:004", serial_port="/dev/ttyUSB0", From cd78625b48d51afcca359e53e5a204993f7e1be4 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 7 May 2026 16:29:54 -0700 Subject: [PATCH 23/54] Remove Alakascan defaults from Micronic docs --- docs/user_guide/micronic/index.md | 2 +- pylabrobot/micronic/code_reader/driver.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index f5f6d3eee4b..d717de7348b 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -34,7 +34,7 @@ reader = MicronicCodeReader( scanner_backend="twain", twain_scanner_path=r"C:\Tools\twain_scan.exe", twain_source="AVA6PlusG", - image_dir=r"C:\ProgramData\Alakascan\data\micronic-images", + image_dir=r"C:\ProgramData\PyLabRobot\micronic-images", serial_port="COM4", keep_images=True, ) diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 253ec14191e..666aeeff9f9 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -80,7 +80,7 @@ def __init__( self.scan_command = list(scan_command) if scan_command is not None else None self.image_extension = normalize_image_extension(image_extension) if image_extension else None self.image_dir = ( - Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "alakascan-micronic" + Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "pylabrobot-micronic" ) self.serial_port = serial_port self.rack_id_command = list(rack_id_command) if rack_id_command is not None else None From 69cb0808cff224e571e4d47d25b4e239146f9dcf Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 19:43:58 -0700 Subject: [PATCH 24/54] Mock rack-reader backend in tests instead of hand-rolled fakes Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rack_reading/rack_reader_tests.py | 131 +++++++----------- 1 file changed, 47 insertions(+), 84 deletions(-) diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index 9b2dc1167ce..66457c2fdd7 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -1,9 +1,9 @@ import unittest +from unittest.mock import AsyncMock, Mock, call from pylabrobot.capabilities.rack_reading.backend import RackReaderBackend from pylabrobot.capabilities.rack_reading.rack_reader import RackReader from pylabrobot.capabilities.rack_reading.standard import ( - LayoutInfo, RackReaderState, RackReaderTimeoutError, RackScanEntry, @@ -11,119 +11,82 @@ ) -class RecordingRackReaderBackend(RackReaderBackend): - def __init__(self): - self.state = RackReaderState.IDLE - self.calls: list[str] = [] - self.result = RackScanResult( - rack_id="5500135415", - date="20260316", - time="160626", - entries=[ - RackScanEntry(position="A01", tube_id="7518613629", status="Code OK", free_text=""), - ], - ) - - async def get_state(self) -> RackReaderState: - self.calls.append("get_state") - if self.state == RackReaderState.SCANNING: - self.state = RackReaderState.DATAREADY - return self.state - - async def trigger_rack_scan(self) -> None: - self.calls.append("trigger_rack_scan") - self.state = RackReaderState.SCANNING - - async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - self.calls.append(f"scan_rack_id:{timeout}:{poll_interval}") - return self.result.rack_id - - async def get_scan_result(self) -> RackScanResult: - self.calls.append("get_scan_result") - return self.result - - async def get_rack_id(self) -> str: - self.calls.append("get_rack_id") - return self.result.rack_id - - async def get_layouts(self) -> list[LayoutInfo]: - self.calls.append("get_layouts") - return [LayoutInfo(name="96")] - - async def get_current_layout(self) -> str: - self.calls.append("get_current_layout") - return "96" - - async def set_current_layout(self, layout: str) -> None: - self.calls.append(f"set_current_layout:{layout}") - - -class StuckRackReaderBackend(RecordingRackReaderBackend): - async def get_state(self) -> RackReaderState: - self.calls.append("get_state") - return RackReaderState.SCANNING - - -class StaleDataReadyRackReaderBackend(RecordingRackReaderBackend): - def __init__(self): - super().__init__() - self.state = RackReaderState.DATAREADY - self._states_after_trigger = [ - RackReaderState.DATAREADY, - RackReaderState.SCANNING, - RackReaderState.DATAREADY, - ] +SCAN_RESULT = RackScanResult( + rack_id="5500135415", + date="20260316", + time="160626", + entries=[ + RackScanEntry(position="A01", tube_id="7518613629", status="Code OK", free_text=""), + ], +) - async def trigger_rack_scan(self) -> None: - self.calls.append("trigger_rack_scan") - async def get_state(self) -> RackReaderState: - self.calls.append("get_state") - if self._states_after_trigger: - return self._states_after_trigger.pop(0) - return RackReaderState.DATAREADY +def _make_backend(get_state_side_effect=None) -> Mock: + backend = Mock(spec=RackReaderBackend) + backend.get_state = AsyncMock(side_effect=get_state_side_effect) + backend.trigger_rack_scan = AsyncMock() + backend.scan_rack_id = AsyncMock(return_value=SCAN_RESULT.rack_id) + backend.get_scan_result = AsyncMock(return_value=SCAN_RESULT) + backend.get_rack_id = AsyncMock(return_value=SCAN_RESULT.rack_id) + return backend class TestRackReader(unittest.IsolatedAsyncioTestCase): async def test_scan_rack_triggers_and_returns_result(self): - backend = RecordingRackReaderBackend() + backend = _make_backend( + get_state_side_effect=[RackReaderState.IDLE, RackReaderState.DATAREADY], + ) reader = RackReader(backend=backend) await reader._on_setup() + backend.reset_mock() result = await reader.scan_rack(timeout=1.0, poll_interval=0.01) - self.assertEqual(result.rack_id, "5500135415") - self.assertEqual(result.entries[0].position, "A01") + self.assertEqual(result, SCAN_RESULT) self.assertEqual( - backend.calls[:3], - ["get_state", "trigger_rack_scan", "get_state"], + backend.mock_calls[:3], + [call.get_state(), call.trigger_rack_scan(), call.get_state()], ) async def test_scan_rack_waits_for_new_dataready_cycle(self): - backend = StaleDataReadyRackReaderBackend() + backend = _make_backend( + get_state_side_effect=[ + RackReaderState.DATAREADY, + RackReaderState.SCANNING, + RackReaderState.DATAREADY, + ], + ) reader = RackReader(backend=backend) await reader._on_setup() + backend.reset_mock() result = await reader.scan_rack(timeout=1.0, poll_interval=0.0) - self.assertEqual(result.rack_id, "5500135415") + self.assertEqual(result, SCAN_RESULT) self.assertEqual( - backend.calls, - ["get_state", "trigger_rack_scan", "get_state", "get_state", "get_scan_result"], + backend.mock_calls, + [ + call.get_state(), + call.trigger_rack_scan(), + call.get_state(), + call.get_state(), + call.get_scan_result(), + ], ) async def test_scan_rack_id_delegates_to_backend(self): - backend = RecordingRackReaderBackend() + backend = _make_backend() reader = RackReader(backend=backend) await reader._on_setup() rack_id = await reader.scan_rack_id(timeout=1.0, poll_interval=0.01) - self.assertEqual(rack_id, "5500135415") - self.assertEqual(backend.calls, ["scan_rack_id:1.0:0.01"]) + self.assertEqual(rack_id, SCAN_RESULT.rack_id) + backend.scan_rack_id.assert_awaited_once_with(timeout=1.0, poll_interval=0.01) async def test_scan_rack_times_out(self): - backend = StuckRackReaderBackend() + backend = _make_backend() + backend.get_state.return_value = RackReaderState.SCANNING reader = RackReader(backend=backend) await reader._on_setup() @@ -131,7 +94,7 @@ async def test_scan_rack_times_out(self): await reader.scan_rack(timeout=0.01, poll_interval=0.0) async def test_requires_setup(self): - backend = RecordingRackReaderBackend() + backend = _make_backend() reader = RackReader(backend=backend) with self.assertRaises(RuntimeError): From c8f90f6aca2a0706062a204db4677d6d8e220765 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 20:07:16 -0700 Subject: [PATCH 25/54] Make rack-reader state Micronic-specific The generic RackReader capability had a state machine (IDLE/SCANNING/DATAREADY) plus trigger+poll+result orchestration that only made sense for Micronic's hardware. Move that into the Micronic backend and narrow the generic capability to scan_rack(rack, ...) + scan_rack_id(...), mirroring how the plate-reader capability takes a Plate. The driver validates rack shape (8x12) and raises MicronicError on mismatch. - RackReaderState -> MicronicRackReaderState in pylabrobot/micronic - MicronicRackReadingBackend.scan_rack owns trigger+poll+stale-DATAREADY guard - Backend raises plain TimeoutError on poll timeout (no custom timeout class) - Drop LayoutInfo, get_layouts/get_current_layout/set_current_layout - Drop _on_setup probe (it only read a Python attribute) Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/pylabrobot.capabilities.rst | 3 - docs/api/pylabrobot.micronic.rst | 1 + docs/user_guide/capabilities/rack-reading.md | 29 ++--- docs/user_guide/micronic/index.md | 11 +- pylabrobot/capabilities/__init__.py | 3 - .../capabilities/rack_reading/__init__.py | 3 - .../capabilities/rack_reading/backend.py | 38 +----- .../capabilities/rack_reading/chatterbox.py | 33 ++--- .../capabilities/rack_reading/rack_reader.py | 103 +-------------- .../rack_reading/rack_reader_tests.py | 78 +++--------- .../capabilities/rack_reading/standard.py | 25 ---- pylabrobot/micronic/__init__.py | 5 +- pylabrobot/micronic/code_reader/__init__.py | 5 +- pylabrobot/micronic/code_reader/driver.py | 66 +++++----- .../micronic/code_reader/micronic_tests.py | 118 ++++++++++++++---- .../code_reader/rack_reading_backend.py | 73 +++++------ 16 files changed, 209 insertions(+), 385 deletions(-) diff --git a/docs/api/pylabrobot.capabilities.rst b/docs/api/pylabrobot.capabilities.rst index 3fc66738bac..c6db2ef1336 100644 --- a/docs/api/pylabrobot.capabilities.rst +++ b/docs/api/pylabrobot.capabilities.rst @@ -220,12 +220,9 @@ Rack Reading :nosignatures: :recursive: - RackReaderState RackReaderError - RackReaderTimeoutError RackScanEntry RackScanResult - LayoutInfo Microscopy diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index 744042b4ce1..a3f1b086a70 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -30,6 +30,7 @@ Driver MicronicDriver MicronicError + MicronicRackReaderState Capabilities diff --git a/docs/user_guide/capabilities/rack-reading.md b/docs/user_guide/capabilities/rack-reading.md index a8e163a7ade..7cf522712be 100644 --- a/docs/user_guide/capabilities/rack-reading.md +++ b/docs/user_guide/capabilities/rack-reading.md @@ -1,7 +1,7 @@ # Rack Reading -The `rack_reading` capability standardizes rack-scale code readers that trigger a rack scan, -report normalized state while scanning, and return structured per-position scan results. +The `rack_reading` capability standardizes rack-scale code readers that decode a tube rack and +return structured per-position scan results. Unlike one-at-a-time code reads, rack reading is job-oriented and returns the full decoded rack map. @@ -10,26 +10,13 @@ Unlike one-at-a-time code reads, rack reading is job-oriented and returns the fu ```python from pylabrobot.capabilities.rack_reading import RackReader -result = await reader.scan_rack(timeout=60.0, poll_interval=1.0) +result = await reader.rack_reading.scan_rack(rack, timeout=60.0, poll_interval=1.0) ``` -`scan_rack()` is the main public operation. It triggers the scan, waits internally until the -reader reaches `dataready`, and then returns a `RackScanResult`. - -If the hardware supports reading just the rack barcode without decoding all tube positions, -`scan_rack_id()` exposes that as a rack-reading operation and returns the rack identifier only. - -Lower-level methods are also available: - -- `get_state()` -- `wait_for_data_ready()` -- `trigger_rack_scan()` -- `scan_rack_id()` -- `get_scan_result()` -- `get_rack_id()` -- `get_layouts()` -- `get_current_layout()` -- `set_current_layout(layout)` +- `scan_rack(rack, timeout, poll_interval)` — scan a `TubeRack` and return a `RackScanResult`. The + backend validates that the rack shape matches what the hardware supports. +- `scan_rack_id(timeout, poll_interval)` — read just the rack barcode (no per-position decoding) + and return the rack identifier. ## Example With Micronic @@ -44,7 +31,7 @@ reader = MicronicCodeReader( await reader.setup() try: - result = await reader.rack_reading.scan_rack(timeout=90.0, poll_interval=1.0) + result = await reader.rack_reading.scan_rack(rack=my_rack, timeout=90.0, poll_interval=1.0) print(result.rack_id) print(result.entries[0].position, result.entries[0].tube_id) diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index d717de7348b..fcad5a59cf8 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -14,11 +14,10 @@ PyLabRobot does not package any scanner helper executable. Rack reading (large scanner that decodes 96 tubes plus the side rack barcode): -- `rack_reading.scan_rack()` to trigger image acquisition, decode all 96 tube - positions, read the side rack barcode, and return a `RackScanResult` +- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube + positions, read the side rack barcode, and return a `RackScanResult`. The driver + only supports 8x12 racks; passing a different shape raises `MicronicRackReaderError`. - `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader -- `rack_reading.get_layouts()`, `get_current_layout()`, and - `set_current_layout()` for the fixed 8x12 rack layout ## Hardware example @@ -41,7 +40,9 @@ reader = MicronicCodeReader( await reader.setup() try: - rack_result = await reader.rack_reading.scan_rack(timeout=90.0, poll_interval=1.0) + rack_result = await reader.rack_reading.scan_rack( + rack=my_rack, timeout=90.0, poll_interval=1.0 + ) print(rack_result.rack_id) print(len([entry for entry in rack_result.entries if entry.tube_id])) diff --git a/pylabrobot/capabilities/__init__.py b/pylabrobot/capabilities/__init__.py index d39489932c5..0ad1fbcd65e 100644 --- a/pylabrobot/capabilities/__init__.py +++ b/pylabrobot/capabilities/__init__.py @@ -1,11 +1,8 @@ from .capability import Capability, CapabilityBackend, need_capability_ready from .rack_reading import ( - LayoutInfo, RackReader, RackReaderBackend, RackReaderError, - RackReaderState, - RackReaderTimeoutError, RackScanEntry, RackScanResult, ) diff --git a/pylabrobot/capabilities/rack_reading/__init__.py b/pylabrobot/capabilities/rack_reading/__init__.py index f0d7a8d1eb3..dca80bd105f 100644 --- a/pylabrobot/capabilities/rack_reading/__init__.py +++ b/pylabrobot/capabilities/rack_reading/__init__.py @@ -2,10 +2,7 @@ from .chatterbox import RackReaderChatterboxBackend from .rack_reader import RackReader from .standard import ( - LayoutInfo, RackReaderError, - RackReaderState, - RackReaderTimeoutError, RackScanEntry, RackScanResult, ) diff --git a/pylabrobot/capabilities/rack_reading/backend.py b/pylabrobot/capabilities/rack_reading/backend.py index e65c2725409..d8657504443 100644 --- a/pylabrobot/capabilities/rack_reading/backend.py +++ b/pylabrobot/capabilities/rack_reading/backend.py @@ -3,46 +3,18 @@ from abc import ABCMeta, abstractmethod from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.resources.tube_rack import TubeRack -from .standard import LayoutInfo, RackReaderState, RackScanResult +from .standard import RackScanResult class RackReaderBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for rack readers that decode position-indexed rack contents.""" @abstractmethod - async def get_state(self) -> RackReaderState: - """Return the current rack reader state.""" - - @abstractmethod - async def trigger_rack_scan(self) -> None: - """Initiate a rack-wide scan.""" + async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) -> RackScanResult: + """Scan ``rack`` and return its decoded contents.""" @abstractmethod async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - """Perform a rack-barcode-only scan and return the rack identifier. - - Backends whose hardware exposes a one-shot rack-id read may ignore - ``timeout`` and ``poll_interval``; backends that need a trigger/poll cycle - should respect them. - """ - - @abstractmethod - async def get_scan_result(self) -> RackScanResult: - """Return the most recent rack scan result.""" - - @abstractmethod - async def get_rack_id(self) -> str: - """Return the rack identifier reported by the scanner.""" - - @abstractmethod - async def get_layouts(self) -> list[LayoutInfo]: - """Return supported layouts.""" - - @abstractmethod - async def get_current_layout(self) -> str: - """Return the active layout.""" - - @abstractmethod - async def set_current_layout(self, layout: str) -> None: - """Set the active layout.""" + """Read the rack barcode only and return the rack identifier.""" diff --git a/pylabrobot/capabilities/rack_reading/chatterbox.py b/pylabrobot/capabilities/rack_reading/chatterbox.py index ac3276ca6ea..703b41acfa0 100644 --- a/pylabrobot/capabilities/rack_reading/chatterbox.py +++ b/pylabrobot/capabilities/rack_reading/chatterbox.py @@ -1,27 +1,16 @@ from __future__ import annotations +from pylabrobot.resources.tube_rack import TubeRack + from .backend import RackReaderBackend -from .standard import LayoutInfo, RackReaderState, RackScanEntry, RackScanResult +from .standard import RackScanEntry, RackScanResult class RackReaderChatterboxBackend(RackReaderBackend): """Device-free rack-reading backend for tests and examples.""" - def __init__(self): - self._state = RackReaderState.IDLE - self._layout = "96" - - async def get_state(self) -> RackReaderState: - return self._state - - async def trigger_rack_scan(self) -> None: - self._state = RackReaderState.DATAREADY - - async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - self._state = RackReaderState.DATAREADY - return "CHATTERBOX" - - async def get_scan_result(self) -> RackScanResult: + async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) -> RackScanResult: + del rack, timeout, poll_interval return RackScanResult( rack_id="CHATTERBOX", date="19700101", @@ -31,14 +20,6 @@ async def get_scan_result(self) -> RackScanResult: ], ) - async def get_rack_id(self) -> str: + async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: + del timeout, poll_interval return "CHATTERBOX" - - async def get_layouts(self) -> list[LayoutInfo]: - return [LayoutInfo(name="96"), LayoutInfo(name="48")] - - async def get_current_layout(self) -> str: - return self._layout - - async def set_current_layout(self, layout: str) -> None: - self._layout = layout diff --git a/pylabrobot/capabilities/rack_reading/rack_reader.py b/pylabrobot/capabilities/rack_reading/rack_reader.py index 48a87e55574..0241e9aec56 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader.py @@ -1,12 +1,10 @@ from __future__ import annotations -import asyncio -import time - from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.resources.tube_rack import TubeRack from .backend import RackReaderBackend -from .standard import LayoutInfo, RackReaderState, RackReaderTimeoutError, RackScanResult +from .standard import RackScanResult class RackReader(Capability): @@ -16,100 +14,14 @@ def __init__(self, backend: RackReaderBackend): super().__init__(backend=backend) self.backend: RackReaderBackend = backend - @need_capability_ready - async def get_state(self) -> RackReaderState: - return await self.backend.get_state() - - @need_capability_ready - async def trigger_rack_scan(self) -> None: - await self.backend.trigger_rack_scan() - - @need_capability_ready - async def get_scan_result(self) -> RackScanResult: - return await self.backend.get_scan_result() - - @need_capability_ready - async def get_rack_id(self) -> str: - return await self.backend.get_rack_id() - - @need_capability_ready - async def get_layouts(self) -> list[LayoutInfo]: - return await self.backend.get_layouts() - - @need_capability_ready - async def get_current_layout(self) -> str: - return await self.backend.get_current_layout() - - @need_capability_ready - async def set_current_layout(self, layout: str) -> None: - await self.backend.set_current_layout(layout) - - async def _wait_for_state( - self, - target: RackReaderState, - timeout: float, - poll_interval: float, - ) -> RackReaderState: - deadline = time.monotonic() + timeout - while True: - state = await self.backend.get_state() - if state == target: - return state - if time.monotonic() >= deadline: - raise RackReaderTimeoutError(f"Timed out waiting for rack reader to reach {target.value}.") - await asyncio.sleep(poll_interval) - - async def _wait_for_fresh_data_ready( - self, - initial_state: RackReaderState, - timeout: float, - poll_interval: float, - ) -> None: - require_state_change = initial_state == RackReaderState.DATAREADY - deadline = time.monotonic() + timeout - while True: - state = await self.backend.get_state() - if state != RackReaderState.DATAREADY: - require_state_change = False - elif not require_state_change: - return - - if time.monotonic() >= deadline: - raise RackReaderTimeoutError( - f"Timed out waiting for rack reader to reach {RackReaderState.DATAREADY.value}." - ) - await asyncio.sleep(poll_interval) - - @need_capability_ready - async def wait_for_data_ready( - self, - timeout: float = 60.0, - poll_interval: float = 1.0, - ) -> RackReaderState: - """Wait until the reader reports that scan data is ready.""" - - return await self._wait_for_state( - target=RackReaderState.DATAREADY, - timeout=timeout, - poll_interval=poll_interval, - ) - @need_capability_ready async def scan_rack( self, + rack: TubeRack, timeout: float = 60.0, poll_interval: float = 1.0, ) -> RackScanResult: - """Trigger a rack scan and return the completed result.""" - - initial_state = await self.backend.get_state() - await self.backend.trigger_rack_scan() - await self._wait_for_fresh_data_ready( - initial_state=initial_state, - timeout=timeout, - poll_interval=poll_interval, - ) - return await self.backend.get_scan_result() + return await self.backend.scan_rack(rack=rack, timeout=timeout, poll_interval=poll_interval) @need_capability_ready async def scan_rack_id( @@ -117,11 +29,4 @@ async def scan_rack_id( timeout: float = 60.0, poll_interval: float = 1.0, ) -> str: - """Perform a rack-barcode-only scan and return the rack identifier. - - The backend decides whether this is a one-shot read or a trigger/poll cycle; - ``timeout`` and ``poll_interval`` are forwarded so backends that poll can - honor them. - """ - return await self.backend.scan_rack_id(timeout=timeout, poll_interval=poll_interval) diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index 66457c2fdd7..63e17051f63 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -1,78 +1,40 @@ import unittest -from unittest.mock import AsyncMock, Mock, call +from unittest.mock import AsyncMock, MagicMock -from pylabrobot.capabilities.rack_reading.backend import RackReaderBackend from pylabrobot.capabilities.rack_reading.rack_reader import RackReader -from pylabrobot.capabilities.rack_reading.standard import ( - RackReaderState, - RackReaderTimeoutError, - RackScanEntry, - RackScanResult, -) - +from pylabrobot.capabilities.rack_reading.standard import RackScanEntry, RackScanResult +from pylabrobot.resources.tube_rack import TubeRack SCAN_RESULT = RackScanResult( rack_id="5500135415", date="20260316", time="160626", entries=[ - RackScanEntry(position="A01", tube_id="7518613629", status="Code OK", free_text=""), + RackScanEntry(position="A01", tube_id="7518613629", status="Code OK"), ], ) -def _make_backend(get_state_side_effect=None) -> Mock: - backend = Mock(spec=RackReaderBackend) - backend.get_state = AsyncMock(side_effect=get_state_side_effect) - backend.trigger_rack_scan = AsyncMock() +def _make_backend() -> MagicMock: + backend = MagicMock() + backend.scan_rack = AsyncMock(return_value=SCAN_RESULT) backend.scan_rack_id = AsyncMock(return_value=SCAN_RESULT.rack_id) - backend.get_scan_result = AsyncMock(return_value=SCAN_RESULT) - backend.get_rack_id = AsyncMock(return_value=SCAN_RESULT.rack_id) + backend._on_setup = AsyncMock() + backend._on_stop = AsyncMock() return backend class TestRackReader(unittest.IsolatedAsyncioTestCase): - async def test_scan_rack_triggers_and_returns_result(self): - backend = _make_backend( - get_state_side_effect=[RackReaderState.IDLE, RackReaderState.DATAREADY], - ) - reader = RackReader(backend=backend) - await reader._on_setup() - - backend.reset_mock() - result = await reader.scan_rack(timeout=1.0, poll_interval=0.01) - - self.assertEqual(result, SCAN_RESULT) - self.assertEqual( - backend.mock_calls[:3], - [call.get_state(), call.trigger_rack_scan(), call.get_state()], - ) - - async def test_scan_rack_waits_for_new_dataready_cycle(self): - backend = _make_backend( - get_state_side_effect=[ - RackReaderState.DATAREADY, - RackReaderState.SCANNING, - RackReaderState.DATAREADY, - ], - ) + async def test_scan_rack_delegates_to_backend(self): + backend = _make_backend() + rack = MagicMock(spec=TubeRack) reader = RackReader(backend=backend) await reader._on_setup() - backend.reset_mock() - result = await reader.scan_rack(timeout=1.0, poll_interval=0.0) + result = await reader.scan_rack(rack=rack, timeout=1.0, poll_interval=0.01) self.assertEqual(result, SCAN_RESULT) - self.assertEqual( - backend.mock_calls, - [ - call.get_state(), - call.trigger_rack_scan(), - call.get_state(), - call.get_state(), - call.get_scan_result(), - ], - ) + backend.scan_rack.assert_awaited_once_with(rack=rack, timeout=1.0, poll_interval=0.01) async def test_scan_rack_id_delegates_to_backend(self): backend = _make_backend() @@ -84,21 +46,13 @@ async def test_scan_rack_id_delegates_to_backend(self): self.assertEqual(rack_id, SCAN_RESULT.rack_id) backend.scan_rack_id.assert_awaited_once_with(timeout=1.0, poll_interval=0.01) - async def test_scan_rack_times_out(self): - backend = _make_backend() - backend.get_state.return_value = RackReaderState.SCANNING - reader = RackReader(backend=backend) - await reader._on_setup() - - with self.assertRaises(RackReaderTimeoutError): - await reader.scan_rack(timeout=0.01, poll_interval=0.0) - async def test_requires_setup(self): backend = _make_backend() + rack = MagicMock(spec=TubeRack) reader = RackReader(backend=backend) with self.assertRaises(RuntimeError): - await reader.get_state() + await reader.scan_rack(rack=rack) if __name__ == "__main__": diff --git a/pylabrobot/capabilities/rack_reading/standard.py b/pylabrobot/capabilities/rack_reading/standard.py index fc46cf0d3b8..d1665539cc9 100644 --- a/pylabrobot/capabilities/rack_reading/standard.py +++ b/pylabrobot/capabilities/rack_reading/standard.py @@ -1,6 +1,5 @@ from __future__ import annotations -import enum from dataclasses import dataclass from typing import Optional @@ -9,18 +8,6 @@ class RackReaderError(Exception): """Base exception for rack reader operations.""" -class RackReaderTimeoutError(RackReaderError): - """Raised when a rack reader operation times out.""" - - -class RackReaderState(enum.Enum): - """Normalized rack reader states.""" - - IDLE = "idle" - SCANNING = "scanning" - DATAREADY = "dataready" - - @dataclass class RackScanEntry: """One decoded rack position.""" @@ -39,15 +26,3 @@ class RackScanResult: date: str time: str entries: list[RackScanEntry] - - -@dataclass -class LayoutInfo: - """One rack layout supported by the reader. - - Wraps a single ``name`` field today so backends can grow the metadata they - attach to a layout (rows, columns, tube type, vendor-specific attributes) - without changing the capability surface. - """ - - name: str diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index f55676153d0..f05c9da2381 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -1,7 +1,8 @@ from pylabrobot.micronic.code_reader import ( MicronicCodeReader, - MicronicError, MicronicDriver, - MicronicRackReadingBackend, + MicronicError, MicronicRackReaderError, + MicronicRackReaderState, + MicronicRackReadingBackend, ) diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index 2498dcd12a3..56e457da80d 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -1,9 +1,10 @@ from pylabrobot.micronic.code_reader.code_reader import MicronicCodeReader from pylabrobot.micronic.code_reader.driver import ( - MicronicError, MicronicDriver, + MicronicError, + MicronicRackReaderState, ) from pylabrobot.micronic.code_reader.rack_reading_backend import ( - MicronicRackReadingBackend, MicronicRackReaderError, + MicronicRackReadingBackend, ) diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 666aeeff9f9..29279930471 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio +import enum import os import re import shutil @@ -25,15 +26,10 @@ from typing import Iterable, Optional, Sequence from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.capabilities.rack_reading import ( - LayoutInfo, - RackReaderState, - RackScanEntry, - RackScanResult, -) +from pylabrobot.capabilities.rack_reading import RackScanEntry, RackScanResult from pylabrobot.device import Driver from pylabrobot.io.serial import Serial - +from pylabrobot.resources.tube_rack import TubeRack ROWS = "ABCDEFGH" COLS = 12 @@ -45,6 +41,14 @@ class MicronicError(Exception): """Raised when Micronic driver operations fail.""" +class MicronicRackReaderState(enum.Enum): + """Micronic rack reader state machine.""" + + IDLE = "idle" + SCANNING = "scanning" + DATAREADY = "dataready" + + @dataclass(frozen=True) class DecodeResult: tube_id: str @@ -90,7 +94,7 @@ def __init__( self.keep_images = keep_images self.image_input = image_input self.rack_id_override = rack_id_override - self._state = RackReaderState.IDLE + self._state = MicronicRackReaderState.IDLE self._last_result: Optional[RackScanResult] = None self._scan_task: Optional[asyncio.Future[RackScanResult]] = None self._scan_error: Optional[Exception] = None @@ -116,13 +120,13 @@ async def stop(self): self._last_result = await scan_task except asyncio.CancelledError: self._scan_error = MicronicError("Micronic rack scan was cancelled.") - self._state = RackReaderState.IDLE + self._state = MicronicRackReaderState.IDLE except Exception as exc: self._scan_error = exc - self._state = RackReaderState.IDLE + self._state = MicronicRackReaderState.IDLE else: self._scan_error = None - self._state = RackReaderState.DATAREADY + self._state = MicronicRackReaderState.DATAREADY finally: self._scan_task = None if self._scan_error is not None: @@ -148,8 +152,10 @@ def serialize(self) -> dict: "rack_id_override": self.rack_id_override, } - async def get_rack_reader_state(self) -> RackReaderState: - if self._state == RackReaderState.SCANNING and not self._reported_scanning_since_trigger: + async def get_rack_reader_state(self) -> MicronicRackReaderState: + if ( + self._state == MicronicRackReaderState.SCANNING and not self._reported_scanning_since_trigger + ): self._reported_scanning_since_trigger = True return self._state self._complete_finished_scan_task() @@ -157,17 +163,26 @@ async def get_rack_reader_state(self) -> RackReaderState: raise self._scan_error return self._state - async def trigger_rack_scan(self) -> None: + async def trigger_rack_scan(self, rack: TubeRack) -> None: + self._validate_rack(rack) self._complete_finished_scan_task() if self._scan_task is not None and not self._scan_task.done(): raise MicronicError("Micronic rack scan is already in progress.") self._last_result = None - self._state = RackReaderState.SCANNING + self._state = MicronicRackReaderState.SCANNING self._scan_error = None self._reported_scanning_since_trigger = False loop = asyncio.get_running_loop() self._scan_task = loop.run_in_executor(None, self._scan_rack_blocking) + @staticmethod + def _validate_rack(rack: TubeRack) -> None: + if rack.num_items_x != RACK_COLS or rack.num_items_y != RACK_ROWS: + raise MicronicError( + f"Micronic driver only supports {RACK_ROWS}x{RACK_COLS} racks; " + f"got {rack.num_items_y}x{rack.num_items_x}." + ) + async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: del timeout, poll_interval if self.rack_id_override: @@ -191,7 +206,7 @@ async def get_scan_result(self) -> RackScanResult: self._complete_finished_scan_task() if self._scan_error is not None: raise self._scan_error - if self._state == RackReaderState.SCANNING: + if self._state == MicronicRackReaderState.SCANNING: raise MicronicError("Micronic rack scan is still in progress.") if self._last_result is None: raise MicronicError("No Micronic rack scan has completed yet.") @@ -201,7 +216,7 @@ async def get_rack_id(self) -> str: self._complete_finished_scan_task() if self._scan_error is not None: raise self._scan_error - if self._state == RackReaderState.SCANNING: + if self._state == MicronicRackReaderState.SCANNING: raise MicronicError("Micronic rack scan is still in progress.") if self._last_result is not None: return self._last_result.rack_id @@ -220,24 +235,13 @@ def _complete_scan_task(self, task: asyncio.Future[RackScanResult]) -> None: self._last_result = task.result() except asyncio.CancelledError: self._scan_error = MicronicError("Micronic rack scan was cancelled.") - self._state = RackReaderState.IDLE + self._state = MicronicRackReaderState.IDLE except Exception as exc: self._scan_error = exc - self._state = RackReaderState.IDLE + self._state = MicronicRackReaderState.IDLE else: self._scan_error = None - self._state = RackReaderState.DATAREADY - - async def get_layouts(self) -> list[LayoutInfo]: - return [LayoutInfo(name="8x12")] - - async def get_current_layout(self) -> str: - return "8x12" - - async def set_current_layout(self, layout: str) -> None: - normalized = layout.strip().lower().replace(" ", "") - if normalized not in {"8x12", "96(8x12)", "96"}: - raise MicronicError(f"Unsupported Micronic rack layout: {layout}") + self._state = MicronicRackReaderState.DATAREADY def _scan_rack_blocking(self) -> RackScanResult: self.image_dir.mkdir(parents=True, exist_ok=True) diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 2b63a3f3eb4..e8b6f8c9a5c 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -4,25 +4,36 @@ import unittest from pathlib import Path from typing import cast -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from pylabrobot.capabilities.rack_reading import RackReaderState from pylabrobot.micronic import MicronicCodeReader from pylabrobot.micronic.code_reader.driver import ( DecodeResult, MicronicDriver, MicronicError, + MicronicRackReaderState, choose_image_extension, read_rack_id, read_rack_id_plr_serial, run_scan, ) -from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend +from pylabrobot.micronic.code_reader.rack_reading_backend import ( + MicronicRackReaderError, + MicronicRackReadingBackend, +) +from pylabrobot.resources.tube_rack import TubeRack + + +def _rack(num_items_x: int = 12, num_items_y: int = 8) -> TubeRack: + rack = MagicMock(spec=TubeRack) + rack.num_items_x = num_items_x + rack.num_items_y = num_items_y + return rack async def wait_for_dataready(driver: MicronicDriver) -> None: for _ in range(100): - if await driver.get_rack_reader_state() == RackReaderState.DATAREADY: + if await driver.get_rack_reader_state() == MicronicRackReaderState.DATAREADY: return await asyncio.sleep(0.01) raise AssertionError("Micronic test scan did not reach dataready.") @@ -60,11 +71,11 @@ async def test_driver_scan_populates_standard_rack_result(self): ) as decode_image_mock, ): await driver.setup() - await driver.trigger_rack_scan() + await driver.trigger_rack_scan(_rack()) await wait_for_dataready(driver) result = await driver.get_scan_result() - self.assertEqual(await driver.get_rack_reader_state(), RackReaderState.DATAREADY) + self.assertEqual(await driver.get_rack_reader_state(), MicronicRackReaderState.DATAREADY) self.assertEqual(result.rack_id, "9500017722") self.assertEqual(result.entries[0].position, "A01") self.assertEqual(result.entries[0].tube_id, "1111111111") @@ -100,8 +111,8 @@ async def test_reader_can_scan_twice_after_dataready(self): ), ): await reader.setup() - first = await reader.rack_reading.scan_rack(timeout=1.0, poll_interval=0.01) - second = await reader.rack_reading.scan_rack(timeout=1.0, poll_interval=0.01) + first = await reader.rack_reading.scan_rack(rack=_rack(), timeout=1.0, poll_interval=0.01) + second = await reader.rack_reading.scan_rack(rack=_rack(), timeout=1.0, poll_interval=0.01) self.assertEqual(first.rack_id, "9500017722") self.assertEqual(second.rack_id, "9500017722") @@ -138,7 +149,7 @@ def slow_scan(*args, **kwargs): ), ): await driver.setup() - await driver.trigger_rack_scan() + await driver.trigger_rack_scan(_rack()) await wait_for_dataready(driver) self.assertEqual(await driver.get_rack_id(), "9500017722") @@ -156,7 +167,7 @@ def slow_scan(*args, **kwargs): return_value=(decoded, {"decodedWells": 1}), ), ): - await driver.trigger_rack_scan() + await driver.trigger_rack_scan(_rack()) with self.assertRaises(MicronicError): await driver.get_rack_id() await wait_for_dataready(driver) @@ -291,18 +302,79 @@ async def test_driver_raises_when_scan_result_is_not_ready(self): with self.assertRaises(MicronicError): await driver.get_scan_result() - async def test_driver_rejects_unknown_layout(self): + async def test_driver_rejects_mismatched_rack_shape(self): driver = MicronicDriver() with self.assertRaises(MicronicError): - await driver.set_current_layout("384") + await driver.trigger_rack_scan(_rack(num_items_x=6, num_items_y=4)) + + +class TestMicronicRackReadingBackend(unittest.IsolatedAsyncioTestCase): + def _backend_with_mocked_driver(self) -> tuple[MicronicRackReadingBackend, MagicMock]: + driver = MagicMock(spec=MicronicDriver) + driver.trigger_rack_scan = AsyncMock() + driver.get_rack_reader_state = AsyncMock() + driver.get_scan_result = AsyncMock() + driver.scan_rack_id = AsyncMock() + return MicronicRackReadingBackend(driver=driver), driver + + async def test_scan_rack_triggers_polls_and_returns_result(self): + backend, driver = self._backend_with_mocked_driver() + driver.get_rack_reader_state.side_effect = [ + MicronicRackReaderState.IDLE, + MicronicRackReaderState.DATAREADY, + ] + expected = MagicMock() + driver.get_scan_result.return_value = expected + rack = _rack() + + result = await backend.scan_rack(rack=rack, timeout=1.0, poll_interval=0.0) + + self.assertIs(result, expected) + driver.trigger_rack_scan.assert_awaited_once_with(rack) + self.assertEqual(driver.get_rack_reader_state.await_count, 2) + driver.get_scan_result.assert_awaited_once() + + async def test_scan_rack_waits_for_fresh_dataready(self): + backend, driver = self._backend_with_mocked_driver() + driver.get_rack_reader_state.side_effect = [ + MicronicRackReaderState.DATAREADY, + MicronicRackReaderState.SCANNING, + MicronicRackReaderState.DATAREADY, + ] + driver.get_scan_result.return_value = MagicMock() + + await backend.scan_rack(rack=_rack(), timeout=1.0, poll_interval=0.0) + + self.assertEqual(driver.get_rack_reader_state.await_count, 3) + driver.get_scan_result.assert_awaited_once() + + async def test_scan_rack_times_out_with_standard_timeout_error(self): + backend, driver = self._backend_with_mocked_driver() + driver.get_rack_reader_state.return_value = MicronicRackReaderState.SCANNING + + with self.assertRaises(TimeoutError) as ctx: + await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) + + self.assertNotIsInstance(ctx.exception, MicronicRackReaderError) + + async def test_scan_rack_wraps_driver_micronic_error(self): + backend, driver = self._backend_with_mocked_driver() + driver.get_rack_reader_state.return_value = MicronicRackReaderState.IDLE + driver.trigger_rack_scan.side_effect = MicronicError("rack shape mismatch") + + with self.assertRaises(MicronicRackReaderError): + await backend.scan_rack( + rack=_rack(num_items_x=6, num_items_y=4), timeout=1.0, poll_interval=0.0 + ) + + async def test_scan_rack_id_delegates_to_driver(self): + backend, driver = self._backend_with_mocked_driver() + driver.scan_rack_id.return_value = "9500017722" + + rack_id = await backend.scan_rack_id(timeout=5.0, poll_interval=0.5) - async def test_backend_delegates_to_driver(self): - driver = MicronicDriver(rack_id_override="9500017722") - backend = MicronicRackReadingBackend(driver=driver) - with patch.object(driver, "scan_rack_id", return_value="9500017722") as scan_rack_id: - rack_id = await backend.scan_rack_id(timeout=5.0, poll_interval=0.5) self.assertEqual(rack_id, "9500017722") - scan_rack_id.assert_called_once_with(timeout=5.0, poll_interval=0.5) + driver.scan_rack_id.assert_awaited_once_with(timeout=5.0, poll_interval=0.5) class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): @@ -312,12 +384,7 @@ async def test_device_exposes_rack_reading_only(self): poll_interval=0.25, driver=MicronicDriver(rack_id_override="9500017722"), ) - with patch.object( - reader.driver, - "get_rack_reader_state", - return_value=RackReaderState.IDLE, - ): - await reader.setup() + await reader.setup() try: self.assertIn(reader.rack_reading, reader._capabilities) self.assertFalse(hasattr(reader, "barcode_scanning")) @@ -327,6 +394,7 @@ async def test_device_exposes_rack_reading_only(self): return_value=MagicMock(rack_id="9500017722"), ) as scan_rack: result = await reader.rack_reading.scan_rack( + rack=_rack(), timeout=reader.default_timeout, poll_interval=reader.default_poll_interval, ) @@ -334,7 +402,7 @@ async def test_device_exposes_rack_reading_only(self): await reader.stop() self.assertEqual(result.rack_id, "9500017722") - scan_rack.assert_called_once_with(timeout=12.0, poll_interval=0.25) + scan_rack.assert_called_once() async def test_frontend_uses_driver(self): reader = MicronicCodeReader(rack_id_override="9500017722") diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index b376fe83fb6..9d291a6f892 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -2,18 +2,17 @@ from __future__ import annotations -from typing import Optional +import asyncio +import time -from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.rack_reading import ( - LayoutInfo, RackReaderBackend, RackReaderError, - RackReaderState, RackScanResult, ) +from pylabrobot.resources.tube_rack import TubeRack -from .driver import MicronicDriver, MicronicError +from .driver import MicronicDriver, MicronicError, MicronicRackReaderState class MicronicRackReaderError(MicronicError, RackReaderError): @@ -27,18 +26,14 @@ def __init__(self, driver: MicronicDriver): super().__init__() self.driver = driver - async def _on_setup(self, backend_params: Optional[BackendParams] = None): - await self.get_state() - - async def get_state(self) -> RackReaderState: + async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) -> RackScanResult: try: - return await self.driver.get_rack_reader_state() - except MicronicError as exc: - raise MicronicRackReaderError(str(exc)) from exc - - async def trigger_rack_scan(self) -> None: - try: - await self.driver.trigger_rack_scan() + initial_state = await self.driver.get_rack_reader_state() + await self.driver.trigger_rack_scan(rack) + await self._wait_for_fresh_dataready( + initial_state=initial_state, timeout=timeout, poll_interval=poll_interval + ) + return await self.driver.get_scan_result() except MicronicError as exc: raise MicronicRackReaderError(str(exc)) from exc @@ -48,32 +43,20 @@ async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: except MicronicError as exc: raise MicronicRackReaderError(str(exc)) from exc - async def get_scan_result(self) -> RackScanResult: - try: - return await self.driver.get_scan_result() - except MicronicError as exc: - raise MicronicRackReaderError(str(exc)) from exc - - async def get_rack_id(self) -> str: - try: - return await self.driver.get_rack_id() - except MicronicError as exc: - raise MicronicRackReaderError(str(exc)) from exc - - async def get_layouts(self) -> list[LayoutInfo]: - try: - return await self.driver.get_layouts() - except MicronicError as exc: - raise MicronicRackReaderError(str(exc)) from exc - - async def get_current_layout(self) -> str: - try: - return await self.driver.get_current_layout() - except MicronicError as exc: - raise MicronicRackReaderError(str(exc)) from exc - - async def set_current_layout(self, layout: str) -> None: - try: - await self.driver.set_current_layout(layout) - except MicronicError as exc: - raise MicronicRackReaderError(str(exc)) from exc + async def _wait_for_fresh_dataready( + self, + initial_state: MicronicRackReaderState, + timeout: float, + poll_interval: float, + ) -> None: + require_state_change = initial_state == MicronicRackReaderState.DATAREADY + deadline = time.monotonic() + timeout + while True: + state = await self.driver.get_rack_reader_state() + if state != MicronicRackReaderState.DATAREADY: + require_state_change = False + elif not require_state_change: + return + if time.monotonic() >= deadline: + raise TimeoutError("Timed out waiting for Micronic rack reader to reach dataready.") + await asyncio.sleep(poll_interval) From acc7d9e3d7163dd1aacd37a38d2cd61d45b379c1 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 20:10:42 -0700 Subject: [PATCH 26/54] Drop min_wells from Micronic driver in favor of rack.num_items Now that trigger_rack_scan takes a TubeRack, the expected well count comes from the rack itself instead of a separate constructor parameter. Stores rack.num_items on the driver when a scan is triggered and uses it as the decode threshold. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../micronic/code_reader/code_reader.py | 2 -- pylabrobot/micronic/code_reader/driver.py | 11 +++++----- .../micronic/code_reader/micronic_tests.py | 20 ++++++++++--------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index c768f530a54..20ebfc4a7ab 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -31,7 +31,6 @@ def __init__( timeout: float = 90.0, poll_interval: float = 1.0, serial_timeout_ms: int = 2500, - min_wells: int = 96, keep_images: bool = False, image_input: Optional[str] = None, rack_id_override: Optional[str] = None, @@ -50,7 +49,6 @@ def __init__( rack_id_command=rack_id_command, scanner_timeout_ms=int(timeout * 1000), serial_timeout_ms=serial_timeout_ms, - min_wells=min_wells, keep_images=keep_images, image_input=image_input, rack_id_override=rack_id_override, diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 29279930471..8510ecefbc1 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -71,7 +71,6 @@ def __init__( rack_id_command: Optional[Sequence[str]] = None, scanner_timeout_ms: int = 90000, serial_timeout_ms: int = 2500, - min_wells: int = 96, keep_images: bool = False, image_input: Optional[str] = None, rack_id_override: Optional[str] = None, @@ -90,11 +89,11 @@ def __init__( self.rack_id_command = list(rack_id_command) if rack_id_command is not None else None self.scanner_timeout_ms = scanner_timeout_ms self.serial_timeout_ms = serial_timeout_ms - self.min_wells = min_wells self.keep_images = keep_images self.image_input = image_input self.rack_id_override = rack_id_override self._state = MicronicRackReaderState.IDLE + self._expected_well_count = 0 self._last_result: Optional[RackScanResult] = None self._scan_task: Optional[asyncio.Future[RackScanResult]] = None self._scan_error: Optional[Exception] = None @@ -146,7 +145,6 @@ def serialize(self) -> dict: "rack_id_command": self.rack_id_command, "scanner_timeout_ms": self.scanner_timeout_ms, "serial_timeout_ms": self.serial_timeout_ms, - "min_wells": self.min_wells, "keep_images": self.keep_images, "image_input": self.image_input, "rack_id_override": self.rack_id_override, @@ -169,6 +167,7 @@ async def trigger_rack_scan(self, rack: TubeRack) -> None: if self._scan_task is not None and not self._scan_task.done(): raise MicronicError("Micronic rack scan is already in progress.") self._last_result = None + self._expected_well_count = rack.num_items self._state = MicronicRackReaderState.SCANNING self._scan_error = None self._reported_scanning_since_trigger = False @@ -276,11 +275,11 @@ def _scan_rack_blocking(self) -> RackScanResult: rack_id_command=self.rack_id_command, ) decoded, self.last_decode_metadata = decode_image(image_path) - if len(decoded) < self.min_wells: + if len(decoded) < self._expected_well_count: missing = ", ".join(position for position in iter_positions() if position not in decoded) raise MicronicError( - f"Micronic decode found {len(decoded)} wells; expected at least {self.min_wells}. " - f"Missing: {missing}" + f"Micronic decode found {len(decoded)} wells; expected at least " + f"{self._expected_well_count}. Missing: {missing}" ) now = datetime.now() diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index e8b6f8c9a5c..7f70fa0b8c7 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -24,10 +24,11 @@ from pylabrobot.resources.tube_rack import TubeRack -def _rack(num_items_x: int = 12, num_items_y: int = 8) -> TubeRack: +def _rack(num_items_x: int = 12, num_items_y: int = 8, num_items: int = 96) -> TubeRack: rack = MagicMock(spec=TubeRack) rack.num_items_x = num_items_x rack.num_items_y = num_items_y + rack.num_items = num_items return rack @@ -49,7 +50,6 @@ async def test_driver_scan_populates_standard_rack_result(self): with tempfile.TemporaryDirectory() as image_dir: driver = MicronicDriver( image_dir=image_dir, - min_wells=2, keep_images=True, ) decoded = { @@ -71,7 +71,7 @@ async def test_driver_scan_populates_standard_rack_result(self): ) as decode_image_mock, ): await driver.setup() - await driver.trigger_rack_scan(_rack()) + await driver.trigger_rack_scan(_rack(num_items=2)) await wait_for_dataready(driver) result = await driver.get_scan_result() @@ -91,7 +91,6 @@ async def test_reader_can_scan_twice_after_dataready(self): reader = MicronicCodeReader( driver=MicronicDriver( image_dir=image_dir, - min_wells=1, keep_images=True, ) ) @@ -111,8 +110,12 @@ async def test_reader_can_scan_twice_after_dataready(self): ), ): await reader.setup() - first = await reader.rack_reading.scan_rack(rack=_rack(), timeout=1.0, poll_interval=0.01) - second = await reader.rack_reading.scan_rack(rack=_rack(), timeout=1.0, poll_interval=0.01) + first = await reader.rack_reading.scan_rack( + rack=_rack(num_items=1), timeout=1.0, poll_interval=0.01 + ) + second = await reader.rack_reading.scan_rack( + rack=_rack(num_items=1), timeout=1.0, poll_interval=0.01 + ) self.assertEqual(first.rack_id, "9500017722") self.assertEqual(second.rack_id, "9500017722") @@ -122,7 +125,6 @@ async def test_driver_get_rack_id_does_not_return_stale_result_while_scanning(se with tempfile.TemporaryDirectory() as image_dir: driver = MicronicDriver( image_dir=image_dir, - min_wells=1, keep_images=True, ) decoded = {"A01": DecodeResult(tube_id="1111111111", method="test")} @@ -149,7 +151,7 @@ def slow_scan(*args, **kwargs): ), ): await driver.setup() - await driver.trigger_rack_scan(_rack()) + await driver.trigger_rack_scan(_rack(num_items=1)) await wait_for_dataready(driver) self.assertEqual(await driver.get_rack_id(), "9500017722") @@ -167,7 +169,7 @@ def slow_scan(*args, **kwargs): return_value=(decoded, {"decodedWells": 1}), ), ): - await driver.trigger_rack_scan(_rack()) + await driver.trigger_rack_scan(_rack(num_items=1)) with self.assertRaises(MicronicError): await driver.get_rack_id() await wait_for_dataready(driver) From b4b5fd3cb73be5040e9e2f4384752dd56a2f27e0 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 20:14:34 -0700 Subject: [PATCH 27/54] Collapse rack-reading error hierarchy to MicronicError Drop RackReaderError (generic base) and MicronicRackReaderError (multi-inherited wrapper). The Micronic backend no longer catch/re-raises driver errors at every call site; MicronicError propagates as-is. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/pylabrobot.capabilities.rst | 1 - docs/api/pylabrobot.micronic.rst | 1 - docs/user_guide/micronic/index.md | 2 +- pylabrobot/capabilities/__init__.py | 1 - .../capabilities/rack_reading/__init__.py | 6 +--- .../capabilities/rack_reading/standard.py | 4 --- pylabrobot/micronic/__init__.py | 1 - pylabrobot/micronic/code_reader/__init__.py | 5 +-- .../micronic/code_reader/micronic_tests.py | 13 +++----- .../code_reader/rack_reading_backend.py | 32 ++++++------------- 10 files changed, 16 insertions(+), 50 deletions(-) diff --git a/docs/api/pylabrobot.capabilities.rst b/docs/api/pylabrobot.capabilities.rst index c6db2ef1336..99be84cc2da 100644 --- a/docs/api/pylabrobot.capabilities.rst +++ b/docs/api/pylabrobot.capabilities.rst @@ -220,7 +220,6 @@ Rack Reading :nosignatures: :recursive: - RackReaderError RackScanEntry RackScanResult diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index a3f1b086a70..c2de3cda9d0 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -44,4 +44,3 @@ Capabilities :recursive: MicronicRackReadingBackend - MicronicRackReaderError diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md index fcad5a59cf8..e0c52017fa6 100644 --- a/docs/user_guide/micronic/index.md +++ b/docs/user_guide/micronic/index.md @@ -16,7 +16,7 @@ Rack reading (large scanner that decodes 96 tubes plus the side rack barcode): - `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube positions, read the side rack barcode, and return a `RackScanResult`. The driver - only supports 8x12 racks; passing a different shape raises `MicronicRackReaderError`. + only supports 8x12 racks; passing a different shape raises `MicronicError`. - `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader ## Hardware example diff --git a/pylabrobot/capabilities/__init__.py b/pylabrobot/capabilities/__init__.py index 0ad1fbcd65e..75c3c674762 100644 --- a/pylabrobot/capabilities/__init__.py +++ b/pylabrobot/capabilities/__init__.py @@ -2,7 +2,6 @@ from .rack_reading import ( RackReader, RackReaderBackend, - RackReaderError, RackScanEntry, RackScanResult, ) diff --git a/pylabrobot/capabilities/rack_reading/__init__.py b/pylabrobot/capabilities/rack_reading/__init__.py index dca80bd105f..9ef4c9936fa 100644 --- a/pylabrobot/capabilities/rack_reading/__init__.py +++ b/pylabrobot/capabilities/rack_reading/__init__.py @@ -1,8 +1,4 @@ from .backend import RackReaderBackend from .chatterbox import RackReaderChatterboxBackend from .rack_reader import RackReader -from .standard import ( - RackReaderError, - RackScanEntry, - RackScanResult, -) +from .standard import RackScanEntry, RackScanResult diff --git a/pylabrobot/capabilities/rack_reading/standard.py b/pylabrobot/capabilities/rack_reading/standard.py index d1665539cc9..ba944c8744d 100644 --- a/pylabrobot/capabilities/rack_reading/standard.py +++ b/pylabrobot/capabilities/rack_reading/standard.py @@ -4,10 +4,6 @@ from typing import Optional -class RackReaderError(Exception): - """Base exception for rack reader operations.""" - - @dataclass class RackScanEntry: """One decoded rack position.""" diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index f05c9da2381..e7a5a587759 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -2,7 +2,6 @@ MicronicCodeReader, MicronicDriver, MicronicError, - MicronicRackReaderError, MicronicRackReaderState, MicronicRackReadingBackend, ) diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index 56e457da80d..1dc5a2ab11d 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -4,7 +4,4 @@ MicronicError, MicronicRackReaderState, ) -from pylabrobot.micronic.code_reader.rack_reading_backend import ( - MicronicRackReaderError, - MicronicRackReadingBackend, -) +from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 7f70fa0b8c7..f4f32a32f5c 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -17,10 +17,7 @@ read_rack_id_plr_serial, run_scan, ) -from pylabrobot.micronic.code_reader.rack_reading_backend import ( - MicronicRackReaderError, - MicronicRackReadingBackend, -) +from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend from pylabrobot.resources.tube_rack import TubeRack @@ -354,17 +351,15 @@ async def test_scan_rack_times_out_with_standard_timeout_error(self): backend, driver = self._backend_with_mocked_driver() driver.get_rack_reader_state.return_value = MicronicRackReaderState.SCANNING - with self.assertRaises(TimeoutError) as ctx: + with self.assertRaises(TimeoutError): await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) - self.assertNotIsInstance(ctx.exception, MicronicRackReaderError) - - async def test_scan_rack_wraps_driver_micronic_error(self): + async def test_scan_rack_propagates_driver_micronic_error(self): backend, driver = self._backend_with_mocked_driver() driver.get_rack_reader_state.return_value = MicronicRackReaderState.IDLE driver.trigger_rack_scan.side_effect = MicronicError("rack shape mismatch") - with self.assertRaises(MicronicRackReaderError): + with self.assertRaises(MicronicError): await backend.scan_rack( rack=_rack(num_items_x=6, num_items_y=4), timeout=1.0, poll_interval=0.0 ) diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index 9d291a6f892..cdc0bf6d825 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -5,18 +5,10 @@ import asyncio import time -from pylabrobot.capabilities.rack_reading import ( - RackReaderBackend, - RackReaderError, - RackScanResult, -) +from pylabrobot.capabilities.rack_reading import RackReaderBackend, RackScanResult from pylabrobot.resources.tube_rack import TubeRack -from .driver import MicronicDriver, MicronicError, MicronicRackReaderState - - -class MicronicRackReaderError(MicronicError, RackReaderError): - """Raised when Micronic rack-reading operations fail.""" +from .driver import MicronicDriver, MicronicRackReaderState class MicronicRackReadingBackend(RackReaderBackend): @@ -27,21 +19,15 @@ def __init__(self, driver: MicronicDriver): self.driver = driver async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) -> RackScanResult: - try: - initial_state = await self.driver.get_rack_reader_state() - await self.driver.trigger_rack_scan(rack) - await self._wait_for_fresh_dataready( - initial_state=initial_state, timeout=timeout, poll_interval=poll_interval - ) - return await self.driver.get_scan_result() - except MicronicError as exc: - raise MicronicRackReaderError(str(exc)) from exc + initial_state = await self.driver.get_rack_reader_state() + await self.driver.trigger_rack_scan(rack) + await self._wait_for_fresh_dataready( + initial_state=initial_state, timeout=timeout, poll_interval=poll_interval + ) + return await self.driver.get_scan_result() async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - try: - return await self.driver.scan_rack_id(timeout=timeout, poll_interval=poll_interval) - except MicronicError as exc: - raise MicronicRackReaderError(str(exc)) from exc + return await self.driver.scan_rack_id(timeout=timeout, poll_interval=poll_interval) async def _wait_for_fresh_dataready( self, From 4ceb0a8a22ea336dcb6b4cf44f439901803d218a Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 20:16:52 -0700 Subject: [PATCH 28/54] Drop date and time fields from RackScanResult Callers can timestamp scans themselves; carrying these as result fields adds no information beyond what wall-clock at scan time would give. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/capabilities/rack_reading/chatterbox.py | 2 -- .../capabilities/rack_reading/rack_reader_tests.py | 2 -- pylabrobot/capabilities/rack_reading/standard.py | 2 -- pylabrobot/micronic/code_reader/driver.py | 10 +--------- 4 files changed, 1 insertion(+), 15 deletions(-) diff --git a/pylabrobot/capabilities/rack_reading/chatterbox.py b/pylabrobot/capabilities/rack_reading/chatterbox.py index 703b41acfa0..040a5a24fbf 100644 --- a/pylabrobot/capabilities/rack_reading/chatterbox.py +++ b/pylabrobot/capabilities/rack_reading/chatterbox.py @@ -13,8 +13,6 @@ async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) del rack, timeout, poll_interval return RackScanResult( rack_id="CHATTERBOX", - date="19700101", - time="000000", entries=[ RackScanEntry(position="A01", tube_id="SIMULATED", status="Code OK"), ], diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index 63e17051f63..89c002e2154 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -7,8 +7,6 @@ SCAN_RESULT = RackScanResult( rack_id="5500135415", - date="20260316", - time="160626", entries=[ RackScanEntry(position="A01", tube_id="7518613629", status="Code OK"), ], diff --git a/pylabrobot/capabilities/rack_reading/standard.py b/pylabrobot/capabilities/rack_reading/standard.py index ba944c8744d..e5e22a5ea2c 100644 --- a/pylabrobot/capabilities/rack_reading/standard.py +++ b/pylabrobot/capabilities/rack_reading/standard.py @@ -19,6 +19,4 @@ class RackScanResult: """A decoded rack scan.""" rack_id: str - date: str - time: str entries: list[RackScanEntry] diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 8510ecefbc1..9b1611de83b 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -282,9 +282,6 @@ def _scan_rack_blocking(self) -> RackScanResult: f"{self._expected_well_count}. Missing: {missing}" ) - now = datetime.now() - date_text = now.strftime("%Y%m%d") - time_text = now.strftime("%H%M%S") entries = [ RackScanEntry( position=position, @@ -302,12 +299,7 @@ def _scan_rack_blocking(self) -> RackScanResult: except OSError: pass - return RackScanResult( - rack_id=rack_id, - date=date_text, - time=time_text, - entries=entries, - ) + return RackScanResult(rack_id=rack_id, entries=entries) def run_scan( From 73ed69e198c4e91b110aba7ab942bc99d70e0c25 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 20:22:41 -0700 Subject: [PATCH 29/54] Type RackScanEntry.status as Literal["OK", "NOREAD"] Normalize chatterbox/test fixture "Code OK" to "OK" so the type closes on the two values the Micronic driver actually emits. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/capabilities/rack_reading/chatterbox.py | 2 +- pylabrobot/capabilities/rack_reading/rack_reader_tests.py | 2 +- pylabrobot/capabilities/rack_reading/standard.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pylabrobot/capabilities/rack_reading/chatterbox.py b/pylabrobot/capabilities/rack_reading/chatterbox.py index 040a5a24fbf..1f624b7b52c 100644 --- a/pylabrobot/capabilities/rack_reading/chatterbox.py +++ b/pylabrobot/capabilities/rack_reading/chatterbox.py @@ -14,7 +14,7 @@ async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) return RackScanResult( rack_id="CHATTERBOX", entries=[ - RackScanEntry(position="A01", tube_id="SIMULATED", status="Code OK"), + RackScanEntry(position="A01", tube_id="SIMULATED", status="OK"), ], ) diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index 89c002e2154..d91f96e7fff 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -8,7 +8,7 @@ SCAN_RESULT = RackScanResult( rack_id="5500135415", entries=[ - RackScanEntry(position="A01", tube_id="7518613629", status="Code OK"), + RackScanEntry(position="A01", tube_id="7518613629", status="OK"), ], ) diff --git a/pylabrobot/capabilities/rack_reading/standard.py b/pylabrobot/capabilities/rack_reading/standard.py index e5e22a5ea2c..ed849ebdef1 100644 --- a/pylabrobot/capabilities/rack_reading/standard.py +++ b/pylabrobot/capabilities/rack_reading/standard.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional +from typing import Literal, Optional @dataclass @@ -10,7 +10,7 @@ class RackScanEntry: position: str tube_id: Optional[str] - status: str + status: Literal["OK", "NOREAD"] free_text: str = "" From b11bf2fbebaa4f467b27f76d8210619841a5fc98 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 20:23:43 -0700 Subject: [PATCH 30/54] Log decode method instead of carrying it on RackScanEntry The Micronic decoder cascade (full-image -> crop-N -> perspective-N) is a debugging detail, not part of the rack scan result. Emit it at DEBUG and drop free_text from the dataclass. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/capabilities/rack_reading/standard.py | 1 - pylabrobot/micronic/code_reader/driver.py | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pylabrobot/capabilities/rack_reading/standard.py b/pylabrobot/capabilities/rack_reading/standard.py index ed849ebdef1..355ac90098f 100644 --- a/pylabrobot/capabilities/rack_reading/standard.py +++ b/pylabrobot/capabilities/rack_reading/standard.py @@ -11,7 +11,6 @@ class RackScanEntry: position: str tube_id: Optional[str] status: Literal["OK", "NOREAD"] - free_text: str = "" @dataclass diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 9b1611de83b..a31baa18b68 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -14,6 +14,7 @@ import asyncio import enum +import logging import os import re import shutil @@ -31,6 +32,8 @@ from pylabrobot.io.serial import Serial from pylabrobot.resources.tube_rack import TubeRack +logger = logging.getLogger(__name__) + ROWS = "ABCDEFGH" COLS = 12 RACK_ROWS = 8 @@ -282,12 +285,14 @@ def _scan_rack_blocking(self) -> RackScanResult: f"{self._expected_well_count}. Missing: {missing}" ) + for position, result in decoded.items(): + logger.debug("Micronic decoded %s via %s", position, result.method) + entries = [ RackScanEntry( position=position, tube_id=decoded[position].tube_id if position in decoded else None, status="OK" if position in decoded else "NOREAD", - free_text=decoded[position].method if position in decoded else "", ) for position in iter_positions() ] From a9cdcae43a57f01ae10d50e21a4b5cd720b4cbe8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 20:26:49 -0700 Subject: [PATCH 31/54] Use PLR position convention (A1) instead of zero-padded A01 Matches ItemizedResource identifiers and the split_identifier contract so RackScanEntry.position can be passed directly to rack.children-by-identifier lookups. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/capabilities/rack_reading/chatterbox.py | 2 +- .../capabilities/rack_reading/rack_reader_tests.py | 2 +- pylabrobot/micronic/code_reader/driver.py | 4 ++-- pylabrobot/micronic/code_reader/micronic_tests.py | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pylabrobot/capabilities/rack_reading/chatterbox.py b/pylabrobot/capabilities/rack_reading/chatterbox.py index 1f624b7b52c..a3f560755db 100644 --- a/pylabrobot/capabilities/rack_reading/chatterbox.py +++ b/pylabrobot/capabilities/rack_reading/chatterbox.py @@ -14,7 +14,7 @@ async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) return RackScanResult( rack_id="CHATTERBOX", entries=[ - RackScanEntry(position="A01", tube_id="SIMULATED", status="OK"), + RackScanEntry(position="A1", tube_id="SIMULATED", status="OK"), ], ) diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index d91f96e7fff..11edd883f1b 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -8,7 +8,7 @@ SCAN_RESULT = RackScanResult( rack_id="5500135415", entries=[ - RackScanEntry(position="A01", tube_id="7518613629", status="OK"), + RackScanEntry(position="A1", tube_id="7518613629", status="OK"), ], ) diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index a31baa18b68..c53dcb76c24 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -669,13 +669,13 @@ def fitted_axis(means: list[float], expected_count: int) -> list[float]: def rack_position(scan_row: int, scan_col: int) -> str: - return f"{ROWS[RACK_ROWS - 1 - scan_col]}{RACK_COLS - scan_row:02d}" + return f"{ROWS[RACK_ROWS - 1 - scan_col]}{RACK_COLS - scan_row}" def iter_positions() -> Iterable[str]: for row in ROWS: for column in range(1, COLS + 1): - yield f"{row}{column:02d}" + yield f"{row}{column}" def is_tube_id(value: object) -> bool: diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index f4f32a32f5c..540bd4df9e6 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -50,8 +50,8 @@ async def test_driver_scan_populates_standard_rack_result(self): keep_images=True, ) decoded = { - "A01": DecodeResult(tube_id="1111111111", method="test"), - "A02": DecodeResult(tube_id="2222222222", method="test"), + "A1": DecodeResult(tube_id="1111111111", method="test"), + "A2": DecodeResult(tube_id="2222222222", method="test"), } with ( patch( @@ -74,7 +74,7 @@ async def test_driver_scan_populates_standard_rack_result(self): self.assertEqual(await driver.get_rack_reader_state(), MicronicRackReaderState.DATAREADY) self.assertEqual(result.rack_id, "9500017722") - self.assertEqual(result.entries[0].position, "A01") + self.assertEqual(result.entries[0].position, "A1") self.assertEqual(result.entries[0].tube_id, "1111111111") self.assertEqual(result.entries[1].tube_id, "2222222222") self.assertEqual(driver.last_scan_metadata, {"source": "test"}) @@ -91,7 +91,7 @@ async def test_reader_can_scan_twice_after_dataready(self): keep_images=True, ) ) - decoded = {"A01": DecodeResult(tube_id="1111111111", method="test")} + decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} with ( patch( "pylabrobot.micronic.code_reader.driver.run_scan", @@ -124,7 +124,7 @@ async def test_driver_get_rack_id_does_not_return_stale_result_while_scanning(se image_dir=image_dir, keep_images=True, ) - decoded = {"A01": DecodeResult(tube_id="1111111111", method="test")} + decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} def slow_scan(*args, **kwargs): del args, kwargs From cdaa7602a3105b52b1c6497099c9255c9e96f1ea Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 20:30:35 -0700 Subject: [PATCH 32/54] Drop MicronicDriver.get_rack_id The cached-or-fresh accessor only existed to back the dropped capability-level get_rack_id and has no remaining callers. Remove the method and its test. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/micronic/code_reader/driver.py | 10 ---- .../micronic/code_reader/micronic_tests.py | 54 ------------------- 2 files changed, 64 deletions(-) diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index c53dcb76c24..a5f15dfc473 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -214,16 +214,6 @@ async def get_scan_result(self) -> RackScanResult: raise MicronicError("No Micronic rack scan has completed yet.") return self._last_result - async def get_rack_id(self) -> str: - self._complete_finished_scan_task() - if self._scan_error is not None: - raise self._scan_error - if self._state == MicronicRackReaderState.SCANNING: - raise MicronicError("Micronic rack scan is still in progress.") - if self._last_result is not None: - return self._last_result.rack_id - return await self.scan_rack_id(timeout=0, poll_interval=0) - def _complete_finished_scan_task(self) -> None: if self._scan_task is not None and self._scan_task.done(): self._complete_scan_task(self._scan_task) diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 540bd4df9e6..1b2a3d32a00 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -118,60 +118,6 @@ async def test_reader_can_scan_twice_after_dataready(self): self.assertEqual(second.rack_id, "9500017722") self.assertEqual(run_scan_mock.call_count, 2) - async def test_driver_get_rack_id_does_not_return_stale_result_while_scanning(self): - with tempfile.TemporaryDirectory() as image_dir: - driver = MicronicDriver( - image_dir=image_dir, - keep_images=True, - ) - decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} - - def slow_scan(*args, **kwargs): - del args, kwargs - import time - - time.sleep(0.05) - return {"source": "test"} - - with ( - patch( - "pylabrobot.micronic.code_reader.driver.run_scan", - return_value={"source": "test"}, - ), - patch( - "pylabrobot.micronic.code_reader.driver.read_rack_id", - return_value="9500017722", - ), - patch( - "pylabrobot.micronic.code_reader.driver.decode_image", - return_value=(decoded, {"decodedWells": 1}), - ), - ): - await driver.setup() - await driver.trigger_rack_scan(_rack(num_items=1)) - await wait_for_dataready(driver) - self.assertEqual(await driver.get_rack_id(), "9500017722") - - with ( - patch( - "pylabrobot.micronic.code_reader.driver.run_scan", - side_effect=slow_scan, - ), - patch( - "pylabrobot.micronic.code_reader.driver.read_rack_id", - return_value="9500017723", - ), - patch( - "pylabrobot.micronic.code_reader.driver.decode_image", - return_value=(decoded, {"decodedWells": 1}), - ), - ): - await driver.trigger_rack_scan(_rack(num_items=1)) - with self.assertRaises(MicronicError): - await driver.get_rack_id() - await wait_for_dataready(driver) - self.assertEqual(await driver.get_rack_id(), "9500017723") - async def test_run_scan_uses_explicit_command(self): with tempfile.TemporaryDirectory() as image_dir: output_path = Path(image_dir) / "rack.bmp" From 48ff245a468a12ad3e931307766d62f3c6552c00 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 20:44:08 -0700 Subject: [PATCH 33/54] Convert rack-reading docs from md to notebook Co-Authored-By: Claude Opus 4.7 (1M context) --- .../capabilities/rack-reading.ipynb | 80 +++++++++++++++++++ docs/user_guide/capabilities/rack-reading.md | 42 ---------- 2 files changed, 80 insertions(+), 42 deletions(-) create mode 100644 docs/user_guide/capabilities/rack-reading.ipynb delete mode 100644 docs/user_guide/capabilities/rack-reading.md diff --git a/docs/user_guide/capabilities/rack-reading.ipynb b/docs/user_guide/capabilities/rack-reading.ipynb new file mode 100644 index 00000000000..44717b5c1ea --- /dev/null +++ b/docs/user_guide/capabilities/rack-reading.ipynb @@ -0,0 +1,80 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Rack Reading\n", + "\n", + "The `rack_reading` capability standardizes rack-scale code readers that decode a tube rack and\n", + "return structured per-position scan results.\n", + "\n", + "Unlike one-at-a-time code reads, rack reading is job-oriented and returns the full decoded rack map.\n", + "\n", + "## Public API" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.rack_reading import RackReader\n", + "\n", + "result = await reader.rack_reading.scan_rack(rack, timeout=60.0, poll_interval=1.0)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- `scan_rack(rack, timeout, poll_interval)` — scan a `TubeRack` and return a `RackScanResult`. The\n", + " backend validates that the rack shape matches what the hardware supports.\n", + "- `scan_rack_id(timeout, poll_interval)` — read just the rack barcode (no per-position decoding)\n", + " and return the rack identifier.\n", + "\n", + "## Example With Micronic" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.micronic import MicronicCodeReader\n", + "\n", + "reader = MicronicCodeReader(\n", + " scanner_backend=\"sane\",\n", + " sane_device=\"avision:libusb:001:004\",\n", + " serial_port=\"/dev/ttyUSB0\",\n", + ")\n", + "await reader.setup()\n", + "\n", + "try:\n", + " result = await reader.rack_reading.scan_rack(rack=my_rack, timeout=90.0, poll_interval=1.0)\n", + " print(result.rack_id)\n", + " print(result.entries[0].position, result.entries[0].tube_id)\n", + "\n", + " rack_id = await reader.rack_reading.scan_rack_id(timeout=60.0, poll_interval=1.0)\n", + " print(rack_id)\n", + "finally:\n", + " await reader.stop()" + ], + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/capabilities/rack-reading.md b/docs/user_guide/capabilities/rack-reading.md deleted file mode 100644 index 7cf522712be..00000000000 --- a/docs/user_guide/capabilities/rack-reading.md +++ /dev/null @@ -1,42 +0,0 @@ -# Rack Reading - -The `rack_reading` capability standardizes rack-scale code readers that decode a tube rack and -return structured per-position scan results. - -Unlike one-at-a-time code reads, rack reading is job-oriented and returns the full decoded rack map. - -## Public API - -```python -from pylabrobot.capabilities.rack_reading import RackReader - -result = await reader.rack_reading.scan_rack(rack, timeout=60.0, poll_interval=1.0) -``` - -- `scan_rack(rack, timeout, poll_interval)` — scan a `TubeRack` and return a `RackScanResult`. The - backend validates that the rack shape matches what the hardware supports. -- `scan_rack_id(timeout, poll_interval)` — read just the rack barcode (no per-position decoding) - and return the rack identifier. - -## Example With Micronic - -```python -from pylabrobot.micronic import MicronicCodeReader - -reader = MicronicCodeReader( - scanner_backend="sane", - sane_device="avision:libusb:001:004", - serial_port="/dev/ttyUSB0", -) -await reader.setup() - -try: - result = await reader.rack_reading.scan_rack(rack=my_rack, timeout=90.0, poll_interval=1.0) - print(result.rack_id) - print(result.entries[0].position, result.entries[0].tube_id) - - rack_id = await reader.rack_reading.scan_rack_id(timeout=60.0, poll_interval=1.0) - print(rack_id) -finally: - await reader.stop() -``` From f8f3b42a985fcd035281344bc21b1016a787d144 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 20:49:00 -0700 Subject: [PATCH 34/54] Use chatterbox in rack-reading docs and convert Micronic index to notebook Co-Authored-By: Claude Opus 4.7 (1M context) --- .../capabilities/rack-reading.ipynb | 63 +++++---- docs/user_guide/micronic/index.ipynb | 129 ++++++++++++++++++ docs/user_guide/micronic/index.md | 86 ------------ 3 files changed, 165 insertions(+), 113 deletions(-) create mode 100644 docs/user_guide/micronic/index.ipynb delete mode 100644 docs/user_guide/micronic/index.md diff --git a/docs/user_guide/capabilities/rack-reading.ipynb b/docs/user_guide/capabilities/rack-reading.ipynb index 44717b5c1ea..885218fe71c 100644 --- a/docs/user_guide/capabilities/rack-reading.ipynb +++ b/docs/user_guide/capabilities/rack-reading.ipynb @@ -11,7 +11,7 @@ "\n", "Unlike one-at-a-time code reads, rack reading is job-oriented and returns the full decoded rack map.\n", "\n", - "## Public API" + "## Walkthrough" ] }, { @@ -19,49 +19,58 @@ "metadata": {}, "source": [ "from pylabrobot.capabilities.rack_reading import RackReader\n", + "from pylabrobot.capabilities.rack_reading.chatterbox import RackReaderChatterboxBackend\n", + "from pylabrobot.resources.tube_rack import TubeRack\n", "\n", - "result = await reader.rack_reading.scan_rack(rack, timeout=60.0, poll_interval=1.0)" + "reader = RackReader(backend=RackReaderChatterboxBackend())\n", + "await reader._on_setup()" ], "execution_count": null, "outputs": [] }, { - "cell_type": "markdown", + "cell_type": "code", "metadata": {}, "source": [ - "- `scan_rack(rack, timeout, poll_interval)` — scan a `TubeRack` and return a `RackScanResult`. The\n", - " backend validates that the rack shape matches what the hardware supports.\n", - "- `scan_rack_id(timeout, poll_interval)` — read just the rack barcode (no per-position decoding)\n", - " and return the rack identifier.\n", + "rack = TubeRack(name=\"rack\", size_x=85, size_y=127, size_z=20)\n", "\n", - "## Example With Micronic" - ] + "result = await reader.scan_rack(rack=rack, timeout=60.0, poll_interval=1.0)\n", + "print(result.rack_id)\n", + "print(result.entries[0].position, result.entries[0].tube_id)" + ], + "execution_count": null, + "outputs": [] }, { "cell_type": "code", "metadata": {}, "source": [ - "from pylabrobot.micronic import MicronicCodeReader\n", - "\n", - "reader = MicronicCodeReader(\n", - " scanner_backend=\"sane\",\n", - " sane_device=\"avision:libusb:001:004\",\n", - " serial_port=\"/dev/ttyUSB0\",\n", - ")\n", - "await reader.setup()\n", - "\n", - "try:\n", - " result = await reader.rack_reading.scan_rack(rack=my_rack, timeout=90.0, poll_interval=1.0)\n", - " print(result.rack_id)\n", - " print(result.entries[0].position, result.entries[0].tube_id)\n", - "\n", - " rack_id = await reader.rack_reading.scan_rack_id(timeout=60.0, poll_interval=1.0)\n", - " print(rack_id)\n", - "finally:\n", - " await reader.stop()" + "rack_id = await reader.scan_rack_id(timeout=60.0, poll_interval=1.0)\n", + "print(rack_id)" ], "execution_count": null, "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Public API\n", + "\n", + "- `scan_rack(rack, timeout, poll_interval)` — scan a `TubeRack` and return a `RackScanResult`. The\n", + " backend validates that the rack shape matches what the hardware supports.\n", + "- `scan_rack_id(timeout, poll_interval)` — read just the rack barcode (no per-position decoding)\n", + " and return the rack identifier.\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} rack reading\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.rack_reading.rack_reader.RackReader` and {class}`~pylabrobot.capabilities.rack_reading.backend.RackReaderBackend`." + ] } ], "metadata": { diff --git a/docs/user_guide/micronic/index.ipynb b/docs/user_guide/micronic/index.ipynb new file mode 100644 index 00000000000..5d3321787b5 --- /dev/null +++ b/docs/user_guide/micronic/index.ipynb @@ -0,0 +1,129 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Micronic\n", + "\n", + "PyLabRobot includes `v1b1` Micronic integrations built on the generic\n", + "`rack_reading` capability.\n", + "\n", + "`MicronicCodeReader` controls the local hardware directly. It acquires the rack\n", + "image through a configured scanner command, a Windows TWAIN helper available on\n", + "PATH, or Ubuntu/Linux SANE `scanimage`; reads the side rack barcode through the\n", + "serial reader; decodes tube DataMatrix codes locally; and returns a standard\n", + "`RackScanResult`. It does not call Micronic Code Reader or IO Monitor, and\n", + "PyLabRobot does not package any scanner helper executable.\n", + "\n", + "## Supported operations\n", + "\n", + "Rack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n", + "\n", + "- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n", + " positions, read the side rack barcode, and return a `RackScanResult`. The driver\n", + " only supports 8x12 racks; passing a different shape raises `MicronicError`.\n", + "- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n", + "\n", + "## Hardware example\n", + "\n", + "The operator is responsible for installing any OS-level scanner bridge\n", + "(`twain_scan`, `scanimage`, or a custom command), the PLR serial extra\n", + "(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\n", + "environment." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.micronic import MicronicCodeReader\n", + "\n", + "reader = MicronicCodeReader(\n", + " scanner_backend=\"twain\",\n", + " twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n", + " twain_source=\"AVA6PlusG\",\n", + " image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n", + " serial_port=\"COM4\",\n", + " keep_images=True,\n", + ")\n", + "await reader.setup()\n", + "\n", + "try:\n", + " rack_result = await reader.rack_reading.scan_rack(\n", + " rack=my_rack, timeout=90.0, poll_interval=1.0\n", + " )\n", + " print(rack_result.rack_id)\n", + " print(len([entry for entry in rack_result.entries if entry.tube_id]))\n", + "\n", + " rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n", + " print(rack_id)\n", + "finally:\n", + " await reader.stop()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "On Ubuntu/Linux, use SANE if the scanner is exposed by a SANE backend:" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "reader = MicronicCodeReader(\n", + " scanner_backend=\"sane\",\n", + " sane_device=\"avision:libusb:001:004\",\n", + " serial_port=\"/dev/ttyUSB0\",\n", + " image_extension=\"tiff\",\n", + ")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For any other acquisition stack, pass `scan_command`. Each command argument is\n", + "formatted with `{output_path}`, `{timeout_ms}`, `{twain_source}`, and\n", + "`{sane_device}` before execution. The command must write the rack image to\n", + "`{output_path}`.\n", + "\n", + "## Notes\n", + "\n", + "- `scan_rack` reads every tube barcode and finishes by reading the rack ID, so\n", + " it typically takes tens of seconds. `scan_rack_id` only reads the rack\n", + " barcode and completes in a few seconds.\n", + "- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n", + " bridge binary and does not install one for you; configure\n", + " `twain_scanner_path`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n", + " named `twain_scan`/`twain_scan.exe` on PATH when using the `twain` backend.\n", + "- Ubuntu/Linux scanner control should use SANE `scanimage` or a custom\n", + " `scan_command`. PyLabRobot does not install SANE or vendor scanner drivers.\n", + " Rack-ID reads use `pylabrobot.io.Serial`, which is installed through the\n", + " `pylabrobot[serial]` extra.\n", + "- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n", + " `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot.\n", + "- Use `image_input` for offline decode checks without touching scanner hardware." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md deleted file mode 100644 index e0c52017fa6..00000000000 --- a/docs/user_guide/micronic/index.md +++ /dev/null @@ -1,86 +0,0 @@ -# Micronic - -PyLabRobot includes `v1b1` Micronic integrations built on the generic -`rack_reading` capability. - -`MicronicCodeReader` controls the local hardware directly. It acquires the rack -image through a configured scanner command, a Windows TWAIN helper available on -PATH, or Ubuntu/Linux SANE `scanimage`; reads the side rack barcode through the -serial reader; decodes tube DataMatrix codes locally; and returns a standard -`RackScanResult`. It does not call Micronic Code Reader or IO Monitor, and -PyLabRobot does not package any scanner helper executable. - -## Supported operations - -Rack reading (large scanner that decodes 96 tubes plus the side rack barcode): - -- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube - positions, read the side rack barcode, and return a `RackScanResult`. The driver - only supports 8x12 racks; passing a different shape raises `MicronicError`. -- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader - -## Hardware example - -The operator is responsible for installing any OS-level scanner bridge -(`twain_scan`, `scanimage`, or a custom command), the PLR serial extra -(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime -environment. - -```python -from pylabrobot.micronic import MicronicCodeReader - -reader = MicronicCodeReader( - scanner_backend="twain", - twain_scanner_path=r"C:\Tools\twain_scan.exe", - twain_source="AVA6PlusG", - image_dir=r"C:\ProgramData\PyLabRobot\micronic-images", - serial_port="COM4", - keep_images=True, -) -await reader.setup() - -try: - rack_result = await reader.rack_reading.scan_rack( - rack=my_rack, timeout=90.0, poll_interval=1.0 - ) - print(rack_result.rack_id) - print(len([entry for entry in rack_result.entries if entry.tube_id])) - - rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5) - print(rack_id) -finally: - await reader.stop() -``` - -On Ubuntu/Linux, use SANE if the scanner is exposed by a SANE backend: - -```python -reader = MicronicCodeReader( - scanner_backend="sane", - sane_device="avision:libusb:001:004", - serial_port="/dev/ttyUSB0", - image_extension="tiff", -) -``` - -For any other acquisition stack, pass `scan_command`. Each command argument is -formatted with `{output_path}`, `{timeout_ms}`, `{twain_source}`, and -`{sane_device}` before execution. The command must write the rack image to -`{output_path}`. - -## Notes - -- `scan_rack` reads every tube barcode and finishes by reading the rack ID, so - it typically takes tens of seconds. `scan_rack_id` only reads the rack - barcode and completes in a few seconds. -- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN - bridge binary and does not install one for you; configure - `twain_scanner_path`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper - named `twain_scan`/`twain_scan.exe` on PATH when using the `twain` backend. -- Ubuntu/Linux scanner control should use SANE `scanimage` or a custom - `scan_command`. PyLabRobot does not install SANE or vendor scanner drivers. - Rack-ID reads use `pylabrobot.io.Serial`, which is installed through the - `pylabrobot[serial]` extra. -- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and - `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot. -- Use `image_input` for offline decode checks without touching scanner hardware. From 481a541d3676fd5a33c245277c1a7bfcba6cbb96 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 21:07:01 -0700 Subject: [PATCH 35/54] Refactor Micronic scanner acquisition into Scanner classes Replace MicronicDriver's flat twain_scanner_path/sane_device/scan_command/ scanner_backend/image_extension/image_input params with a single scanner: Scanner argument. Concrete subclasses TwainScanner and SaneScanner own their own resolution and extension. For other acquisition stacks, users subclass Scanner directly. - New: pylabrobot/micronic/code_reader/scanner.py (Scanner ABC + TwainScanner + SaneScanner) and errors.py (MicronicError moved here to break the import cycle). - MicronicDriver.__init__ takes scanner + required serial_port (no more "COM4" default) plus a smaller flat-param surface. - MicronicCodeReader matches: scanner + serial_port required, no driver injection. - Module-level run_scan / choose_image_extension / normalize_* / resolve_* helpers in driver.py are deleted; what remains is owned by the scanner classes. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/pylabrobot.micronic.rst | 25 +- docs/user_guide/micronic/index.ipynb | 95 +------- pylabrobot/micronic/__init__.py | 3 + pylabrobot/micronic/code_reader/__init__.py | 8 +- .../micronic/code_reader/code_reader.py | 39 ++-- pylabrobot/micronic/code_reader/driver.py | 216 ++---------------- pylabrobot/micronic/code_reader/errors.py | 2 + .../micronic/code_reader/micronic_tests.py | 211 ++++++++--------- pylabrobot/micronic/code_reader/scanner.py | 115 ++++++++++ 9 files changed, 289 insertions(+), 425 deletions(-) create mode 100644 pylabrobot/micronic/code_reader/errors.py create mode 100644 pylabrobot/micronic/code_reader/scanner.py diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index c2de3cda9d0..4814c15dda1 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -29,9 +29,32 @@ Driver :recursive: MicronicDriver - MicronicError MicronicRackReaderState +.. currentmodule:: pylabrobot.micronic.code_reader.errors + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MicronicError + + +Scanners +-------- + +.. currentmodule:: pylabrobot.micronic.code_reader.scanner + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Scanner + TwainScanner + SaneScanner + Capabilities ------------ diff --git a/docs/user_guide/micronic/index.ipynb b/docs/user_guide/micronic/index.ipynb index 5d3321787b5..44c0dd05bdb 100644 --- a/docs/user_guide/micronic/index.ipynb +++ b/docs/user_guide/micronic/index.ipynb @@ -3,114 +3,31 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "# Micronic\n", - "\n", - "PyLabRobot includes `v1b1` Micronic integrations built on the generic\n", - "`rack_reading` capability.\n", - "\n", - "`MicronicCodeReader` controls the local hardware directly. It acquires the rack\n", - "image through a configured scanner command, a Windows TWAIN helper available on\n", - "PATH, or Ubuntu/Linux SANE `scanimage`; reads the side rack barcode through the\n", - "serial reader; decodes tube DataMatrix codes locally; and returns a standard\n", - "`RackScanResult`. It does not call Micronic Code Reader or IO Monitor, and\n", - "PyLabRobot does not package any scanner helper executable.\n", - "\n", - "## Supported operations\n", - "\n", - "Rack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n", - "\n", - "- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n", - " positions, read the side rack barcode, and return a `RackScanResult`. The driver\n", - " only supports 8x12 racks; passing a different shape raises `MicronicError`.\n", - "- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n", - "\n", - "## Hardware example\n", - "\n", - "The operator is responsible for installing any OS-level scanner bridge\n", - "(`twain_scan`, `scanimage`, or a custom command), the PLR serial extra\n", - "(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\n", - "environment." - ] + "source": "# Micronic\n\nPyLabRobot includes `v1b1` Micronic integrations built on the generic\n`rack_reading` capability.\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or `CommandScanner` for any other acquisition stack); the driver reads\nthe side rack barcode through the serial reader, decodes tube DataMatrix codes\nlocally, and returns a standard `RackScanResult`. It does not call Micronic\nCode Reader or IO Monitor, and PyLabRobot does not package any scanner helper\nexecutable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The driver\n only supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or a custom command), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment." }, { "cell_type": "code", "metadata": {}, - "source": [ - "from pylabrobot.micronic import MicronicCodeReader\n", - "\n", - "reader = MicronicCodeReader(\n", - " scanner_backend=\"twain\",\n", - " twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n", - " twain_source=\"AVA6PlusG\",\n", - " image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n", - " serial_port=\"COM4\",\n", - " keep_images=True,\n", - ")\n", - "await reader.setup()\n", - "\n", - "try:\n", - " rack_result = await reader.rack_reading.scan_rack(\n", - " rack=my_rack, timeout=90.0, poll_interval=1.0\n", - " )\n", - " print(rack_result.rack_id)\n", - " print(len([entry for entry in rack_result.entries if entry.tube_id]))\n", - "\n", - " rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n", - " print(rack_id)\n", - "finally:\n", - " await reader.stop()" - ], + "source": "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\n\nreader = MicronicCodeReader(\n scanner=TwainScanner(\n twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n twain_source=\"AVA6PlusG\",\n ),\n serial_port=\"COM4\",\n image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n keep_images=True,\n)\nawait reader.setup()\n\ntry:\n rack_result = await reader.rack_reading.scan_rack(\n rack=my_rack, timeout=90.0, poll_interval=1.0\n )\n print(rack_result.rack_id)\n print(len([entry for entry in rack_result.entries if entry.tube_id]))\n\n rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n print(rack_id)\nfinally:\n await reader.stop()", "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "On Ubuntu/Linux, use SANE if the scanner is exposed by a SANE backend:" - ] + "source": "On Ubuntu/Linux, use `SaneScanner` if the scanner is exposed by a SANE backend:" }, { "cell_type": "code", "metadata": {}, - "source": [ - "reader = MicronicCodeReader(\n", - " scanner_backend=\"sane\",\n", - " sane_device=\"avision:libusb:001:004\",\n", - " serial_port=\"/dev/ttyUSB0\",\n", - " image_extension=\"tiff\",\n", - ")" - ], + "source": "from pylabrobot.micronic import MicronicCodeReader, SaneScanner\n\nreader = MicronicCodeReader(\n scanner=SaneScanner(sane_device=\"avision:libusb:001:004\"),\n serial_port=\"/dev/ttyUSB0\",\n)", "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "For any other acquisition stack, pass `scan_command`. Each command argument is\n", - "formatted with `{output_path}`, `{timeout_ms}`, `{twain_source}`, and\n", - "`{sane_device}` before execution. The command must write the rack image to\n", - "`{output_path}`.\n", - "\n", - "## Notes\n", - "\n", - "- `scan_rack` reads every tube barcode and finishes by reading the rack ID, so\n", - " it typically takes tens of seconds. `scan_rack_id` only reads the rack\n", - " barcode and completes in a few seconds.\n", - "- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n", - " bridge binary and does not install one for you; configure\n", - " `twain_scanner_path`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n", - " named `twain_scan`/`twain_scan.exe` on PATH when using the `twain` backend.\n", - "- Ubuntu/Linux scanner control should use SANE `scanimage` or a custom\n", - " `scan_command`. PyLabRobot does not install SANE or vendor scanner drivers.\n", - " Rack-ID reads use `pylabrobot.io.Serial`, which is installed through the\n", - " `pylabrobot[serial]` extra.\n", - "- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n", - " `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot.\n", - "- Use `image_input` for offline decode checks without touching scanner hardware." - ] + "source": "For any other acquisition stack, subclass `Scanner` and implement `acquire` to\nwrite a rack image to the given path.\n\n## Notes\n\n- `scan_rack` reads every tube barcode and finishes by reading the rack ID, so\n it typically takes tens of seconds. `scan_rack_id` only reads the rack\n barcode and completes in a few seconds.\n- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n bridge binary and does not install one for you; pass `twain_scanner_path` to\n `TwainScanner`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n named `twain_scan` / `twain_scan.exe` on `PATH`.\n- Ubuntu/Linux scanner control uses SANE `scanimage` (via `SaneScanner`).\n PyLabRobot does not install SANE or vendor scanner drivers. Rack-ID reads use\n `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]`\n extra.\n- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot." } ], "metadata": { @@ -126,4 +43,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index e7a5a587759..926bd38069a 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -4,4 +4,7 @@ MicronicError, MicronicRackReaderState, MicronicRackReadingBackend, + SaneScanner, + Scanner, + TwainScanner, ) diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index 1dc5a2ab11d..ccc98119a22 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -1,7 +1,5 @@ from pylabrobot.micronic.code_reader.code_reader import MicronicCodeReader -from pylabrobot.micronic.code_reader.driver import ( - MicronicDriver, - MicronicError, - MicronicRackReaderState, -) +from pylabrobot.micronic.code_reader.driver import MicronicDriver, MicronicRackReaderState +from pylabrobot.micronic.code_reader.errors import MicronicError from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend +from pylabrobot.micronic.code_reader.scanner import SaneScanner, Scanner, TwainScanner diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index 20ebfc4a7ab..7c95350420f 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -9,6 +9,7 @@ from .driver import MicronicDriver from .rack_reading_backend import MicronicRackReadingBackend +from .scanner import Scanner class MicronicCodeReader(Device): @@ -19,40 +20,26 @@ class MicronicCodeReader(Device): def __init__( self, - twain_scanner_path: Optional[str] = None, - twain_source: str = "AVA6PlusG", - sane_device: Optional[str] = None, - scanner_backend: str = "auto", - scan_command: Optional[Sequence[str]] = None, - image_extension: Optional[str] = None, + scanner: Scanner, + serial_port: str, image_dir: Optional[str] = None, - serial_port: str = "COM4", rack_id_command: Optional[Sequence[str]] = None, timeout: float = 90.0, poll_interval: float = 1.0, serial_timeout_ms: int = 2500, keep_images: bool = False, - image_input: Optional[str] = None, rack_id_override: Optional[str] = None, - driver: Optional[MicronicDriver] = None, ): - if driver is None: - driver = MicronicDriver( - twain_scanner_path=twain_scanner_path, - twain_source=twain_source, - sane_device=sane_device, - scanner_backend=scanner_backend, - scan_command=scan_command, - image_extension=image_extension, - image_dir=image_dir, - serial_port=serial_port, - rack_id_command=rack_id_command, - scanner_timeout_ms=int(timeout * 1000), - serial_timeout_ms=serial_timeout_ms, - keep_images=keep_images, - image_input=image_input, - rack_id_override=rack_id_override, - ) + driver = MicronicDriver( + scanner=scanner, + serial_port=serial_port, + image_dir=image_dir, + rack_id_command=rack_id_command, + scanner_timeout_ms=int(timeout * 1000), + serial_timeout_ms=serial_timeout_ms, + keep_images=keep_images, + rack_id_override=rack_id_override, + ) super().__init__(driver=driver) self.driver: MicronicDriver = driver self.default_timeout = timeout diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index a5f15dfc473..bdfc868dd9e 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -3,8 +3,7 @@ This driver does not call Micronic Code Reader or IO Monitor. It owns the local scanner path directly: -- acquire a rack image through a user-supplied scan command, Windows TWAIN - helper, or SANE ``scanimage`` command, +- acquire a rack image through a caller-supplied :class:`Scanner`, - read the rack ID through the side serial barcode reader, - decode tube DataMatrix codes locally, and - return the standard PLR rack-reading result. @@ -15,10 +14,8 @@ import asyncio import enum import logging -import os import re -import shutil -import subprocess # nosec B404 - local scanner/rack-id helper execution is the interface. +import subprocess # nosec B404 - local rack-id helper execution is the interface. import tempfile import time from dataclasses import dataclass @@ -32,6 +29,9 @@ from pylabrobot.io.serial import Serial from pylabrobot.resources.tube_rack import TubeRack +from .errors import MicronicError +from .scanner import Scanner + logger = logging.getLogger(__name__) ROWS = "ABCDEFGH" @@ -40,10 +40,6 @@ RACK_COLS = 12 -class MicronicError(Exception): - """Raised when Micronic driver operations fail.""" - - class MicronicRackReaderState(enum.Enum): """Micronic rack reader state machine.""" @@ -63,28 +59,17 @@ class MicronicDriver(Driver): def __init__( self, - twain_scanner_path: Optional[str] = None, - twain_source: str = "AVA6PlusG", - sane_device: Optional[str] = None, - scanner_backend: str = "auto", - scan_command: Optional[Sequence[str]] = None, - image_extension: Optional[str] = None, + scanner: Scanner, + serial_port: str, image_dir: Optional[str] = None, - serial_port: str = "COM4", rack_id_command: Optional[Sequence[str]] = None, scanner_timeout_ms: int = 90000, serial_timeout_ms: int = 2500, keep_images: bool = False, - image_input: Optional[str] = None, rack_id_override: Optional[str] = None, ): super().__init__() - self.twain_scanner_path = twain_scanner_path - self.twain_source = twain_source - self.sane_device = sane_device - self.scanner_backend = scanner_backend - self.scan_command = list(scan_command) if scan_command is not None else None - self.image_extension = normalize_image_extension(image_extension) if image_extension else None + self.scanner = scanner self.image_dir = ( Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "pylabrobot-micronic" ) @@ -93,7 +78,6 @@ def __init__( self.scanner_timeout_ms = scanner_timeout_ms self.serial_timeout_ms = serial_timeout_ms self.keep_images = keep_images - self.image_input = image_input self.rack_id_override = rack_id_override self._state = MicronicRackReaderState.IDLE self._expected_well_count = 0 @@ -137,19 +121,12 @@ async def stop(self): def serialize(self) -> dict: return { **super().serialize(), - "twain_scanner_path": self.twain_scanner_path, - "twain_source": self.twain_source, - "sane_device": self.sane_device, - "scanner_backend": self.scanner_backend, - "scan_command": self.scan_command, - "image_extension": self.image_extension, "image_dir": str(self.image_dir), "serial_port": self.serial_port, "rack_id_command": self.rack_id_command, "scanner_timeout_ms": self.scanner_timeout_ms, "serial_timeout_ms": self.serial_timeout_ms, "keep_images": self.keep_images, - "image_input": self.image_input, "rack_id_override": self.rack_id_override, } @@ -192,7 +169,7 @@ async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: if self.rack_id_command is not None: return await asyncio.to_thread( read_rack_id_command, - format_command( + _format_command( self.rack_id_command, serial_port=self.serial_port, timeout_ms=self.serial_timeout_ms, @@ -237,28 +214,12 @@ def _complete_scan_task(self, task: asyncio.Future[RackScanResult]) -> None: def _scan_rack_blocking(self) -> RackScanResult: self.image_dir.mkdir(parents=True, exist_ok=True) - image_extension = choose_image_extension( - image_extension=self.image_extension, - image_input=self.image_input, - scanner_backend=self.scanner_backend, - scan_command=self.scan_command, - twain_scanner_path=self.twain_scanner_path, - sane_device=self.sane_device, - ) image_path = ( - self.image_dir / f"micronic_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.{image_extension}" + self.image_dir + / f"micronic_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.{self.scanner.image_extension}" ) - self.last_scan_metadata = run_scan( - twain_scanner_path=self.twain_scanner_path, - twain_source=self.twain_source, - sane_device=self.sane_device, - scanner_backend=self.scanner_backend, - scan_command=self.scan_command, - output_path=image_path, - timeout_ms=self.scanner_timeout_ms, - image_input=self.image_input, - ) + self.last_scan_metadata = self.scanner.acquire(image_path, self.scanner_timeout_ms) self.last_image_path = image_path rack_id = read_rack_id( @@ -287,7 +248,7 @@ def _scan_rack_blocking(self) -> RackScanResult: for position in iter_positions() ] - if not self.keep_images and self.image_input is None: + if not self.keep_images: try: image_path.unlink() self.last_image_path = None @@ -297,101 +258,6 @@ def _scan_rack_blocking(self) -> RackScanResult: return RackScanResult(rack_id=rack_id, entries=entries) -def run_scan( - output_path: Path, - timeout_ms: int, - twain_scanner_path: Optional[str] = None, - twain_source: str = "AVA6PlusG", - sane_device: Optional[str] = None, - scanner_backend: str = "auto", - scan_command: Optional[Sequence[str]] = None, - image_input: Optional[str] = None, -) -> dict[str, object]: - if image_input: - source_path = Path(image_input) - if not source_path.exists(): - raise MicronicError(f"Image input does not exist: {source_path}") - output_path.write_bytes(source_path.read_bytes()) - return {"stdout": "", "stderr": "", "source": str(source_path)} - - if scan_command is not None: - command = format_command( - scan_command, - output_path=output_path, - timeout_ms=timeout_ms, - twain_source=twain_source, - sane_device=sane_device or "", - ) - return run_scan_command(command, output_path, timeout_ms, source="command") - - backend = normalize_scanner_backend(scanner_backend) - if backend == "command": - raise MicronicError("Command scan requested, but scan_command was not configured.") - - if backend in {"auto", "twain"}: - resolved_twain_path = resolve_twain_scanner_path(twain_scanner_path) - if resolved_twain_path is not None: - return run_scan_command( - [resolved_twain_path, str(output_path), twain_source, str(timeout_ms)], - output_path, - timeout_ms, - source="twain", - ) - if backend == "twain": - raise MicronicError( - "TWAIN scan requested, but no TWAIN helper was configured. Set " - "twain_scanner_path, MICRONIC_TWAIN_SCANNER_PATH, or put twain_scan on PATH." - ) - - if backend in {"auto", "sane"}: - scanimage_path = shutil.which("scanimage") - if scanimage_path is not None: - command = [scanimage_path] - if sane_device: - command.extend(["--device-name", sane_device]) - command.extend(["--format=tiff", "--output-file", str(output_path)]) - return run_scan_command(command, output_path, timeout_ms, source="sane") - if backend == "sane": - raise MicronicError("SANE scan requested, but scanimage was not found on PATH.") - - raise MicronicError( - "No scan acquisition method is available. Configure scan_command, " - "twain_scanner_path/MICRONIC_TWAIN_SCANNER_PATH, or install SANE scanimage." - ) - - -def run_scan_command( - command: Sequence[str], - output_path: Path, - timeout_ms: int, - source: str, -) -> dict[str, object]: - try: - completed = subprocess.run( # nosec B603 - operator-configured command, shell=False. - list(command), - check=False, - capture_output=True, - text=True, - timeout=(timeout_ms / 1000) + 15, - ) - except FileNotFoundError as exc: - raise MicronicError(f"Scan command was not found: {command[0]}") from exc - - if completed.returncode != 0: - raise MicronicError( - "Scan command failed with exit code " - f"{completed.returncode}: {completed.stderr.strip() or completed.stdout.strip()}" - ) - if not output_path.exists(): - raise MicronicError(f"Scan command did not create image: {output_path}") - return { - "stdout": completed.stdout.strip(), - "stderr": completed.stderr.strip(), - "source": source, - "command": list(command), - } - - def read_rack_id( serial_port: str = "COM4", timeout_ms: int = 2500, @@ -402,7 +268,7 @@ def read_rack_id( return rack_id_override if rack_id_command is not None: - command = format_command( + command = _format_command( rack_id_command, serial_port=serial_port, timeout_ms=timeout_ms, @@ -472,59 +338,7 @@ def extract_rack_id(text: str) -> str: return match.group(0) if match else "NOREAD" -def normalize_scanner_backend(scanner_backend: str) -> str: - backend = scanner_backend.strip().lower() - if backend not in {"auto", "twain", "sane", "command"}: - raise MicronicError( - "Unsupported scanner backend " - f"{scanner_backend!r}; expected 'auto', 'twain', 'sane', or 'command'." - ) - return backend - - -def normalize_image_extension(image_extension: str) -> str: - normalized = image_extension.strip().lstrip(".") - if not normalized: - raise MicronicError("image_extension must not be empty.") - return normalized - - -def choose_image_extension( - image_extension: Optional[str], - image_input: Optional[str], - scanner_backend: str, - scan_command: Optional[Sequence[str]], - twain_scanner_path: Optional[str], - sane_device: Optional[str], -) -> str: - if image_extension: - return normalize_image_extension(image_extension) - if image_input and Path(image_input).suffix: - return normalize_image_extension(Path(image_input).suffix) - - backend = normalize_scanner_backend(scanner_backend) - if backend == "sane" or ( - backend == "auto" - and scan_command is None - and twain_scanner_path is None - and (sane_device is not None or os.name != "nt") - ): - return "tiff" - return "bmp" - - -def resolve_twain_scanner_path(twain_scanner_path: Optional[str]) -> Optional[str]: - if twain_scanner_path: - return twain_scanner_path - - env_path = os.environ.get("MICRONIC_TWAIN_SCANNER_PATH") - if env_path: - return env_path - - return shutil.which("twain_scan.exe") or shutil.which("twain_scan") - - -def format_command(command: Sequence[str], **values: object) -> list[str]: +def _format_command(command: Sequence[str], **values: object) -> list[str]: return [part.format(**values) for part in command] diff --git a/pylabrobot/micronic/code_reader/errors.py b/pylabrobot/micronic/code_reader/errors.py new file mode 100644 index 00000000000..ac8d9529598 --- /dev/null +++ b/pylabrobot/micronic/code_reader/errors.py @@ -0,0 +1,2 @@ +class MicronicError(Exception): + """Raised when Micronic driver or scanner operations fail.""" diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 1b2a3d32a00..039f30a9442 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -1,4 +1,5 @@ import asyncio +import os import sys import tempfile import unittest @@ -6,16 +7,14 @@ from typing import cast from unittest.mock import AsyncMock, MagicMock, patch -from pylabrobot.micronic import MicronicCodeReader +from pylabrobot.micronic import MicronicCodeReader, SaneScanner, TwainScanner from pylabrobot.micronic.code_reader.driver import ( DecodeResult, MicronicDriver, MicronicError, MicronicRackReaderState, - choose_image_extension, read_rack_id, read_rack_id_plr_serial, - run_scan, ) from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend from pylabrobot.resources.tube_rack import TubeRack @@ -29,6 +28,13 @@ def _rack(num_items_x: int = 12, num_items_y: int = 8, num_items: int = 96) -> T return rack +def _mock_scanner(image_extension: str = "bmp") -> MagicMock: + scanner = MagicMock() + scanner.image_extension = image_extension + scanner.acquire = MagicMock(return_value={"source": "test"}) + return scanner + + async def wait_for_dataready(driver: MicronicDriver) -> None: for _ in range(100): if await driver.get_rack_reader_state() == MicronicRackReaderState.DATAREADY: @@ -37,15 +43,85 @@ async def wait_for_dataready(driver: MicronicDriver) -> None: raise AssertionError("Micronic test scan did not reach dataready.") -class TestMicronicDriver(unittest.IsolatedAsyncioTestCase): - def test_driver_does_not_default_to_packaged_twain_helper(self): - driver = MicronicDriver() - self.assertIsNone(driver.twain_scanner_path) - self.assertIsNone(driver.scan_command) +class TestScannerClasses(unittest.IsolatedAsyncioTestCase): + def test_sane_scanner_invokes_scanimage(self): + with tempfile.TemporaryDirectory() as image_dir: + output_path = Path(image_dir) / "rack.tiff" + with ( + patch( + "pylabrobot.micronic.code_reader.scanner.shutil.which", + return_value="/usr/bin/scanimage", + ), + patch( + "pylabrobot.micronic.code_reader.scanner._run_scan_command", + return_value={"source": "sane"}, + ) as run_scan_command, + ): + scanner = SaneScanner(sane_device="avision:libusb:001:004") + metadata = scanner.acquire(output_path, timeout_ms=1000) + self.assertEqual(metadata["source"], "sane") + self.assertEqual(scanner.image_extension, "tiff") + run_scan_command.assert_called_once_with( + [ + "/usr/bin/scanimage", + "--device-name", + "avision:libusb:001:004", + "--format=tiff", + "--output-file", + str(output_path), + ], + output_path, + 1000, + source="sane", + ) + + def test_sane_scanner_raises_when_scanimage_missing(self): + with patch("pylabrobot.micronic.code_reader.scanner.shutil.which", return_value=None): + with self.assertRaises(MicronicError): + SaneScanner() + + def test_twain_scanner_resolves_path_from_env(self): + with ( + patch.dict(os.environ, {"MICRONIC_TWAIN_SCANNER_PATH": "/opt/twain_scan"}, clear=False), + patch("pylabrobot.micronic.code_reader.scanner.shutil.which", return_value=None), + ): + scanner = TwainScanner() + self.assertEqual(scanner.twain_scanner_path, "/opt/twain_scan") + + def test_twain_scanner_raises_when_helper_missing(self): + with ( + patch.dict(os.environ, {}, clear=True), + patch("pylabrobot.micronic.code_reader.scanner.shutil.which", return_value=None), + ): + with self.assertRaises(MicronicError): + TwainScanner() + + def test_twain_scanner_acquire_runs_helper(self): + with tempfile.TemporaryDirectory() as image_dir: + output_path = Path(image_dir) / "rack.bmp" + with patch( + "pylabrobot.micronic.code_reader.scanner._run_scan_command", + return_value={"source": "twain"}, + ) as run_scan_command: + scanner = TwainScanner(twain_scanner_path="/opt/twain_scan", twain_source="AVA6PlusG") + scanner.acquire(output_path, timeout_ms=1000) + + run_scan_command.assert_called_once_with( + ["/opt/twain_scan", str(output_path), "AVA6PlusG", "1000"], + output_path, + 1000, + source="twain", + ) + + +class TestMicronicDriver(unittest.IsolatedAsyncioTestCase): async def test_driver_scan_populates_standard_rack_result(self): with tempfile.TemporaryDirectory() as image_dir: + scanner = _mock_scanner() driver = MicronicDriver( + scanner=scanner, + serial_port="/dev/ttyUSB0", image_dir=image_dir, keep_images=True, ) @@ -54,10 +130,6 @@ async def test_driver_scan_populates_standard_rack_result(self): "A2": DecodeResult(tube_id="2222222222", method="test"), } with ( - patch( - "pylabrobot.micronic.code_reader.driver.run_scan", - return_value={"source": "test"}, - ) as run_scan_mock, patch( "pylabrobot.micronic.code_reader.driver.read_rack_id", return_value="9500017722", @@ -79,24 +151,21 @@ async def test_driver_scan_populates_standard_rack_result(self): self.assertEqual(result.entries[1].tube_id, "2222222222") self.assertEqual(driver.last_scan_metadata, {"source": "test"}) self.assertEqual(driver.last_decode_metadata, {"decodedWells": 2}) - run_scan_mock.assert_called_once() + scanner.acquire.assert_called_once() read_rack_id_mock.assert_called_once() decode_image_mock.assert_called_once() async def test_reader_can_scan_twice_after_dataready(self): with tempfile.TemporaryDirectory() as image_dir: + scanner = _mock_scanner() reader = MicronicCodeReader( - driver=MicronicDriver( - image_dir=image_dir, - keep_images=True, - ) + scanner=scanner, + serial_port="/dev/ttyUSB0", + image_dir=image_dir, + keep_images=True, ) decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} with ( - patch( - "pylabrobot.micronic.code_reader.driver.run_scan", - return_value={"source": "test"}, - ) as run_scan_mock, patch( "pylabrobot.micronic.code_reader.driver.read_rack_id", return_value="9500017722", @@ -116,81 +185,7 @@ async def test_reader_can_scan_twice_after_dataready(self): self.assertEqual(first.rack_id, "9500017722") self.assertEqual(second.rack_id, "9500017722") - self.assertEqual(run_scan_mock.call_count, 2) - - async def test_run_scan_uses_explicit_command(self): - with tempfile.TemporaryDirectory() as image_dir: - output_path = Path(image_dir) / "rack.bmp" - metadata = run_scan( - output_path=output_path, - timeout_ms=1000, - scan_command=[ - sys.executable, - "-c", - "from pathlib import Path; Path(r'{output_path}').write_bytes(b'image')", - ], - ) - - self.assertEqual(metadata["source"], "command") - self.assertTrue(output_path.exists()) - - async def test_run_scan_uses_sane_scanimage_when_requested(self): - with tempfile.TemporaryDirectory() as image_dir: - output_path = Path(image_dir) / "micronic-test.tiff" - with ( - patch( - "pylabrobot.micronic.code_reader.driver.shutil.which", - return_value="/usr/bin/scanimage", - ), - patch( - "pylabrobot.micronic.code_reader.driver.run_scan_command", - return_value={"source": "sane"}, - ) as run_scan_command, - ): - metadata = run_scan( - output_path=output_path, - timeout_ms=1000, - scanner_backend="sane", - sane_device="avision:libusb:001:004", - ) - - self.assertEqual(metadata["source"], "sane") - run_scan_command.assert_called_once_with( - [ - "/usr/bin/scanimage", - "--device-name", - "avision:libusb:001:004", - "--format=tiff", - "--output-file", - str(output_path), - ], - output_path, - 1000, - source="sane", - ) - - async def test_run_scan_requires_configured_acquisition(self): - with tempfile.TemporaryDirectory() as image_dir: - with ( - patch("pylabrobot.micronic.code_reader.driver.shutil.which", return_value=None), - self.assertRaises(MicronicError), - ): - run_scan( - output_path=Path(image_dir) / "micronic-test.bmp", - timeout_ms=1000, - scanner_backend="twain", - ) - - async def test_choose_image_extension_prefers_sane_tiff_on_non_windows_auto(self): - extension = choose_image_extension( - image_extension=None, - image_input=None, - scanner_backend="auto", - scan_command=None, - twain_scanner_path=None, - sane_device="avision:libusb:001:004", - ) - self.assertEqual(extension, "tiff") + self.assertEqual(scanner.acquire.call_count, 2) async def test_read_rack_id_uses_configured_command(self): rack_id = read_rack_id( @@ -226,12 +221,12 @@ async def stop(self): self.calls.append("stop") with patch("pylabrobot.micronic.code_reader.driver.Serial", FakeSerial): - rack_id = await read_rack_id_plr_serial(serial_port="COM4", timeout_ms=1000) + rack_id = await read_rack_id_plr_serial(serial_port="/dev/ttyUSB0", timeout_ms=1000) self.assertEqual(len(instances), 1) fake_serial = cast(FakeSerial, instances[0]) self.assertEqual(rack_id, "9500017722") - self.assertEqual(fake_serial.kwargs["port"], "COM4") + self.assertEqual(fake_serial.kwargs["port"], "/dev/ttyUSB0") self.assertEqual(fake_serial.kwargs["bytesize"], 7) self.assertEqual(fake_serial.kwargs["parity"], "E") self.assertIn("reset_input_buffer", fake_serial.calls) @@ -239,16 +234,20 @@ async def stop(self): self.assertEqual(fake_serial.calls[-1], "stop") async def test_driver_scan_rack_id_uses_configured_command(self): - driver = MicronicDriver(rack_id_command=[sys.executable, "-c", "print('rack 9500017722')"]) + driver = MicronicDriver( + scanner=_mock_scanner(), + serial_port="/dev/ttyUSB0", + rack_id_command=[sys.executable, "-c", "print('rack 9500017722')"], + ) self.assertEqual(await driver.scan_rack_id(timeout=1.0, poll_interval=0.1), "9500017722") async def test_driver_raises_when_scan_result_is_not_ready(self): - driver = MicronicDriver() + driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with self.assertRaises(MicronicError): await driver.get_scan_result() async def test_driver_rejects_mismatched_rack_shape(self): - driver = MicronicDriver() + driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with self.assertRaises(MicronicError): await driver.trigger_rack_scan(_rack(num_items_x=6, num_items_y=4)) @@ -323,9 +322,11 @@ async def test_scan_rack_id_delegates_to_driver(self): class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): async def test_device_exposes_rack_reading_only(self): reader = MicronicCodeReader( + scanner=_mock_scanner(), + serial_port="/dev/ttyUSB0", timeout=12.0, poll_interval=0.25, - driver=MicronicDriver(rack_id_override="9500017722"), + rack_id_override="9500017722", ) await reader.setup() try: @@ -348,7 +349,11 @@ async def test_device_exposes_rack_reading_only(self): scan_rack.assert_called_once() async def test_frontend_uses_driver(self): - reader = MicronicCodeReader(rack_id_override="9500017722") + reader = MicronicCodeReader( + scanner=_mock_scanner(), + serial_port="/dev/ttyUSB0", + rack_id_override="9500017722", + ) self.assertIsInstance(reader.driver, MicronicDriver) self.assertFalse(hasattr(reader, "barcode_scanning")) diff --git a/pylabrobot/micronic/code_reader/scanner.py b/pylabrobot/micronic/code_reader/scanner.py new file mode 100644 index 00000000000..5a4f79224bc --- /dev/null +++ b/pylabrobot/micronic/code_reader/scanner.py @@ -0,0 +1,115 @@ +"""Scanner classes that acquire a rack image for the Micronic driver.""" + +from __future__ import annotations + +import os +import shutil +import subprocess # nosec B404 - local scanner helper execution is the interface. +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional, Sequence + +from .errors import MicronicError + + +class Scanner(ABC): + """Abstract scanner that writes a rack image to disk on demand.""" + + image_extension: str + + @abstractmethod + def acquire(self, output_path: Path, timeout_ms: int) -> dict[str, object]: + """Write a rack image to ``output_path`` and return acquisition metadata.""" + + +class TwainScanner(Scanner): + """Windows TWAIN scanner driven by an operator-installed helper executable. + + Resolves the helper path from (in order): the ``twain_scanner_path`` argument, + the ``MICRONIC_TWAIN_SCANNER_PATH`` environment variable, or ``twain_scan`` / + ``twain_scan.exe`` on PATH. Raises ``MicronicError`` if none resolve. + """ + + image_extension = "bmp" + + def __init__( + self, + twain_scanner_path: Optional[str] = None, + twain_source: str = "AVA6PlusG", + ): + resolved = twain_scanner_path or _resolve_twain_scanner_path() + if resolved is None: + raise MicronicError( + "No TWAIN helper was found. Pass twain_scanner_path, set " + "MICRONIC_TWAIN_SCANNER_PATH, or put twain_scan on PATH." + ) + self.twain_scanner_path = resolved + self.twain_source = twain_source + + def acquire(self, output_path: Path, timeout_ms: int) -> dict[str, object]: + command = [self.twain_scanner_path, str(output_path), self.twain_source, str(timeout_ms)] + return _run_scan_command(command, output_path, timeout_ms, source="twain") + + +class SaneScanner(Scanner): + """Linux SANE scanner driven through the ``scanimage`` CLI.""" + + image_extension = "tiff" + + def __init__( + self, + sane_device: Optional[str] = None, + scanimage_path: Optional[str] = None, + ): + resolved = scanimage_path or shutil.which("scanimage") + if resolved is None: + raise MicronicError("scanimage was not found on PATH. Install SANE or pass scanimage_path.") + self.scanimage_path = resolved + self.sane_device = sane_device + + def acquire(self, output_path: Path, timeout_ms: int) -> dict[str, object]: + command = [self.scanimage_path] + if self.sane_device: + command.extend(["--device-name", self.sane_device]) + command.extend(["--format=tiff", "--output-file", str(output_path)]) + return _run_scan_command(command, output_path, timeout_ms, source="sane") + + +def _run_scan_command( + command: Sequence[str], + output_path: Path, + timeout_ms: int, + source: str, +) -> dict[str, object]: + try: + completed = subprocess.run( # nosec B603 - operator-configured command, shell=False. + list(command), + check=False, + capture_output=True, + text=True, + timeout=(timeout_ms / 1000) + 15, + ) + except FileNotFoundError as exc: + raise MicronicError(f"Scan command was not found: {command[0]}") from exc + + if completed.returncode != 0: + raise MicronicError( + "Scan command failed with exit code " + f"{completed.returncode}: {completed.stderr.strip() or completed.stdout.strip()}" + ) + if not output_path.exists(): + raise MicronicError(f"Scan command did not create image: {output_path}") + return { + "stdout": completed.stdout.strip(), + "stderr": completed.stderr.strip(), + "source": source, + "command": list(command), + } + + +def _resolve_twain_scanner_path() -> Optional[str]: + return ( + os.environ.get("MICRONIC_TWAIN_SCANNER_PATH") + or shutil.which("twain_scan.exe") + or shutil.which("twain_scan") + ) From 6dedfe4fc39a42a03bdc58ea85d29612d21c7469 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 21:09:32 -0700 Subject: [PATCH 36/54] Remove rack_id_command subprocess escape hatch from Micronic driver Co-Authored-By: Claude Opus 4.7 (1M context) --- .../micronic/code_reader/code_reader.py | 4 +- pylabrobot/micronic/code_reader/driver.py | 50 +------------------ .../micronic/code_reader/micronic_tests.py | 17 ------- 3 files changed, 2 insertions(+), 69 deletions(-) diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index 7c95350420f..eb1a2394b27 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional, Sequence +from typing import Optional from pylabrobot.capabilities.rack_reading import RackReader from pylabrobot.device import Device @@ -23,7 +23,6 @@ def __init__( scanner: Scanner, serial_port: str, image_dir: Optional[str] = None, - rack_id_command: Optional[Sequence[str]] = None, timeout: float = 90.0, poll_interval: float = 1.0, serial_timeout_ms: int = 2500, @@ -34,7 +33,6 @@ def __init__( scanner=scanner, serial_port=serial_port, image_dir=image_dir, - rack_id_command=rack_id_command, scanner_timeout_ms=int(timeout * 1000), serial_timeout_ms=serial_timeout_ms, keep_images=keep_images, diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index bdfc868dd9e..a1f2feb47a4 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -15,13 +15,12 @@ import enum import logging import re -import subprocess # nosec B404 - local rack-id helper execution is the interface. import tempfile import time from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Iterable, Optional, Sequence +from typing import Iterable, Optional from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.rack_reading import RackScanEntry, RackScanResult @@ -62,7 +61,6 @@ def __init__( scanner: Scanner, serial_port: str, image_dir: Optional[str] = None, - rack_id_command: Optional[Sequence[str]] = None, scanner_timeout_ms: int = 90000, serial_timeout_ms: int = 2500, keep_images: bool = False, @@ -74,7 +72,6 @@ def __init__( Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "pylabrobot-micronic" ) self.serial_port = serial_port - self.rack_id_command = list(rack_id_command) if rack_id_command is not None else None self.scanner_timeout_ms = scanner_timeout_ms self.serial_timeout_ms = serial_timeout_ms self.keep_images = keep_images @@ -123,7 +120,6 @@ def serialize(self) -> dict: **super().serialize(), "image_dir": str(self.image_dir), "serial_port": self.serial_port, - "rack_id_command": self.rack_id_command, "scanner_timeout_ms": self.scanner_timeout_ms, "serial_timeout_ms": self.serial_timeout_ms, "keep_images": self.keep_images, @@ -166,16 +162,6 @@ async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: del timeout, poll_interval if self.rack_id_override: return self.rack_id_override - if self.rack_id_command is not None: - return await asyncio.to_thread( - read_rack_id_command, - _format_command( - self.rack_id_command, - serial_port=self.serial_port, - timeout_ms=self.serial_timeout_ms, - ), - self.serial_timeout_ms, - ) return await read_rack_id_plr_serial( serial_port=self.serial_port, timeout_ms=self.serial_timeout_ms, @@ -226,7 +212,6 @@ def _scan_rack_blocking(self) -> RackScanResult: serial_port=self.serial_port, timeout_ms=self.serial_timeout_ms, rack_id_override=self.rack_id_override, - rack_id_command=self.rack_id_command, ) decoded, self.last_decode_metadata = decode_image(image_path) if len(decoded) < self._expected_well_count: @@ -262,19 +247,10 @@ def read_rack_id( serial_port: str = "COM4", timeout_ms: int = 2500, rack_id_override: Optional[str] = None, - rack_id_command: Optional[Sequence[str]] = None, ) -> str: if rack_id_override: return rack_id_override - if rack_id_command is not None: - command = _format_command( - rack_id_command, - serial_port=serial_port, - timeout_ms=timeout_ms, - ) - return read_rack_id_command(command, timeout_ms) - return asyncio.run(read_rack_id_plr_serial(serial_port=serial_port, timeout_ms=timeout_ms)) @@ -313,35 +289,11 @@ async def read_rack_id_plr_serial(serial_port: str, timeout_ms: int) -> str: return extract_rack_id(b"".join(chunks).decode("utf-8", errors="ignore")) -def read_rack_id_command(command: Sequence[str], timeout_ms: int) -> str: - try: - completed = subprocess.run( # nosec B603 - operator-configured command, shell=False. - list(command), - check=False, - capture_output=True, - text=True, - timeout=(timeout_ms / 1000) + 5, - ) - except FileNotFoundError as exc: - raise MicronicError(f"Rack ID command was not found: {command[0]}") from exc - - if completed.returncode != 0: - raise MicronicError( - "Rack ID command failed with exit code " - f"{completed.returncode}: {completed.stderr.strip() or completed.stdout.strip()}" - ) - return extract_rack_id(completed.stdout) - - def extract_rack_id(text: str) -> str: match = re.search(r"\d{6,}", text) return match.group(0) if match else "NOREAD" -def _format_command(command: Sequence[str], **values: object) -> list[str]: - return [part.format(**values) for part in command] - - def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, object]]: cv2, np, zxingcpp, Image, ImageOps = import_decode_dependencies() with Image.open(image_path) as loaded_image: diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 039f30a9442..1744cf55e9f 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -1,6 +1,5 @@ import asyncio import os -import sys import tempfile import unittest from pathlib import Path @@ -13,7 +12,6 @@ MicronicDriver, MicronicError, MicronicRackReaderState, - read_rack_id, read_rack_id_plr_serial, ) from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend @@ -187,13 +185,6 @@ async def test_reader_can_scan_twice_after_dataready(self): self.assertEqual(second.rack_id, "9500017722") self.assertEqual(scanner.acquire.call_count, 2) - async def test_read_rack_id_uses_configured_command(self): - rack_id = read_rack_id( - timeout_ms=1000, - rack_id_command=[sys.executable, "-c", "print('rack 9500017722')"], - ) - self.assertEqual(rack_id, "9500017722") - async def test_read_rack_id_uses_plr_serial(self): instances: list[object] = [] @@ -233,14 +224,6 @@ async def stop(self): self.assertIn("write:b'\\r\\n'", fake_serial.calls) self.assertEqual(fake_serial.calls[-1], "stop") - async def test_driver_scan_rack_id_uses_configured_command(self): - driver = MicronicDriver( - scanner=_mock_scanner(), - serial_port="/dev/ttyUSB0", - rack_id_command=[sys.executable, "-c", "print('rack 9500017722')"], - ) - self.assertEqual(await driver.scan_rack_id(timeout=1.0, poll_interval=0.1), "9500017722") - async def test_driver_raises_when_scan_result_is_not_ready(self): driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with self.assertRaises(MicronicError): From b73cab3f2df1bf97fb1a9ea975e00a5c0b4814e7 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 21:10:57 -0700 Subject: [PATCH 37/54] Remove rack_id_override from Micronic driver Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/micronic/code_reader/code_reader.py | 2 -- pylabrobot/micronic/code_reader/driver.py | 10 ---------- pylabrobot/micronic/code_reader/micronic_tests.py | 2 -- 3 files changed, 14 deletions(-) diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index eb1a2394b27..6e077ecccce 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -27,7 +27,6 @@ def __init__( poll_interval: float = 1.0, serial_timeout_ms: int = 2500, keep_images: bool = False, - rack_id_override: Optional[str] = None, ): driver = MicronicDriver( scanner=scanner, @@ -36,7 +35,6 @@ def __init__( scanner_timeout_ms=int(timeout * 1000), serial_timeout_ms=serial_timeout_ms, keep_images=keep_images, - rack_id_override=rack_id_override, ) super().__init__(driver=driver) self.driver: MicronicDriver = driver diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index a1f2feb47a4..2f2fa8806ce 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -64,7 +64,6 @@ def __init__( scanner_timeout_ms: int = 90000, serial_timeout_ms: int = 2500, keep_images: bool = False, - rack_id_override: Optional[str] = None, ): super().__init__() self.scanner = scanner @@ -75,7 +74,6 @@ def __init__( self.scanner_timeout_ms = scanner_timeout_ms self.serial_timeout_ms = serial_timeout_ms self.keep_images = keep_images - self.rack_id_override = rack_id_override self._state = MicronicRackReaderState.IDLE self._expected_well_count = 0 self._last_result: Optional[RackScanResult] = None @@ -123,7 +121,6 @@ def serialize(self) -> dict: "scanner_timeout_ms": self.scanner_timeout_ms, "serial_timeout_ms": self.serial_timeout_ms, "keep_images": self.keep_images, - "rack_id_override": self.rack_id_override, } async def get_rack_reader_state(self) -> MicronicRackReaderState: @@ -160,8 +157,6 @@ def _validate_rack(rack: TubeRack) -> None: async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: del timeout, poll_interval - if self.rack_id_override: - return self.rack_id_override return await read_rack_id_plr_serial( serial_port=self.serial_port, timeout_ms=self.serial_timeout_ms, @@ -211,7 +206,6 @@ def _scan_rack_blocking(self) -> RackScanResult: rack_id = read_rack_id( serial_port=self.serial_port, timeout_ms=self.serial_timeout_ms, - rack_id_override=self.rack_id_override, ) decoded, self.last_decode_metadata = decode_image(image_path) if len(decoded) < self._expected_well_count: @@ -246,11 +240,7 @@ def _scan_rack_blocking(self) -> RackScanResult: def read_rack_id( serial_port: str = "COM4", timeout_ms: int = 2500, - rack_id_override: Optional[str] = None, ) -> str: - if rack_id_override: - return rack_id_override - return asyncio.run(read_rack_id_plr_serial(serial_port=serial_port, timeout_ms=timeout_ms)) diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 1744cf55e9f..4cf4d4eb094 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -309,7 +309,6 @@ async def test_device_exposes_rack_reading_only(self): serial_port="/dev/ttyUSB0", timeout=12.0, poll_interval=0.25, - rack_id_override="9500017722", ) await reader.setup() try: @@ -335,7 +334,6 @@ async def test_frontend_uses_driver(self): reader = MicronicCodeReader( scanner=_mock_scanner(), serial_port="/dev/ttyUSB0", - rack_id_override="9500017722", ) self.assertIsInstance(reader.driver, MicronicDriver) self.assertFalse(hasattr(reader, "barcode_scanning")) From 96f9853151e0c98e4662b50e4f3c05d471c2e8b0 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 21:22:54 -0700 Subject: [PATCH 38/54] MicronicDriver owns Serial and reads the rack ID on the main loop Replace the standalone read_rack_id_plr_serial / read_rack_id / extract_rack_id trio with a single scan_rack_id method on the driver. The driver holds the Serial instance, opens it in setup(), closes it in stop(), and reads the side barcode through self.io. trigger_rack_scan now awaits scan_rack_id() before kicking off the executor thread, so the sync _scan_rack_blocking receives the rack ID as an argument and no longer bridges sync/async via asyncio.run. serial_port is no longer stored on the driver (the Serial instance owns the connection info). Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/micronic/code_reader/driver.py | 140 ++++++++---------- .../micronic/code_reader/micronic_tests.py | 63 ++++---- 2 files changed, 95 insertions(+), 108 deletions(-) diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 2f2fa8806ce..7a808a4f043 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -70,10 +70,19 @@ def __init__( self.image_dir = ( Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "pylabrobot-micronic" ) - self.serial_port = serial_port self.scanner_timeout_ms = scanner_timeout_ms self.serial_timeout_ms = serial_timeout_ms self.keep_images = keep_images + self.io = Serial( + human_readable_device_name="Micronic rack ID reader", + port=serial_port, + baudrate=9600, + bytesize=7, + parity="E", + stopbits=1, + timeout=0.1, + write_timeout=1.0, + ) self._state = MicronicRackReaderState.IDLE self._expected_well_count = 0 self._last_result: Optional[RackScanResult] = None @@ -87,37 +96,40 @@ def __init__( async def setup(self, backend_params: Optional[BackendParams] = None): del backend_params self.image_dir.mkdir(parents=True, exist_ok=True) + await self.io.setup() async def stop(self): - scan_task = self._scan_task - if scan_task is None: + try: + scan_task = self._scan_task + if scan_task is None: + if self._scan_error is not None: + raise self._scan_error + return + if scan_task.done(): + self._complete_scan_task(scan_task) + else: + try: + self._last_result = await scan_task + except asyncio.CancelledError: + self._scan_error = MicronicError("Micronic rack scan was cancelled.") + self._state = MicronicRackReaderState.IDLE + except Exception as exc: + self._scan_error = exc + self._state = MicronicRackReaderState.IDLE + else: + self._scan_error = None + self._state = MicronicRackReaderState.DATAREADY + finally: + self._scan_task = None if self._scan_error is not None: raise self._scan_error - return - if scan_task.done(): - self._complete_scan_task(scan_task) - else: - try: - self._last_result = await scan_task - except asyncio.CancelledError: - self._scan_error = MicronicError("Micronic rack scan was cancelled.") - self._state = MicronicRackReaderState.IDLE - except Exception as exc: - self._scan_error = exc - self._state = MicronicRackReaderState.IDLE - else: - self._scan_error = None - self._state = MicronicRackReaderState.DATAREADY - finally: - self._scan_task = None - if self._scan_error is not None: - raise self._scan_error + finally: + await self.io.stop() def serialize(self) -> dict: return { **super().serialize(), "image_dir": str(self.image_dir), - "serial_port": self.serial_port, "scanner_timeout_ms": self.scanner_timeout_ms, "serial_timeout_ms": self.serial_timeout_ms, "keep_images": self.keep_images, @@ -139,13 +151,14 @@ async def trigger_rack_scan(self, rack: TubeRack) -> None: self._complete_finished_scan_task() if self._scan_task is not None and not self._scan_task.done(): raise MicronicError("Micronic rack scan is already in progress.") + rack_id = await self.scan_rack_id() self._last_result = None self._expected_well_count = rack.num_items self._state = MicronicRackReaderState.SCANNING self._scan_error = None self._reported_scanning_since_trigger = False loop = asyncio.get_running_loop() - self._scan_task = loop.run_in_executor(None, self._scan_rack_blocking) + self._scan_task = loop.run_in_executor(None, self._scan_rack_blocking, rack_id) @staticmethod def _validate_rack(rack: TubeRack) -> None: @@ -155,12 +168,28 @@ def _validate_rack(rack: TubeRack) -> None: f"got {rack.num_items_y}x{rack.num_items_x}." ) - async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: + async def scan_rack_id(self, timeout: float = 0, poll_interval: float = 0) -> str: del timeout, poll_interval - return await read_rack_id_plr_serial( - serial_port=self.serial_port, - timeout_ms=self.serial_timeout_ms, - ) + deadline = time.monotonic() + self.serial_timeout_ms / 1000 + chunks: list[bytes] = [] + try: + await self.io.reset_input_buffer() + await self.io.write(b"\r\n") + while time.monotonic() < deadline: + value = await self.io.read(1) + if value: + chunks.append(value) + if value in {b"\r", b"\n"}: + break + except Exception as exc: + raise MicronicError( + "Rack ID serial read failed. Install the PLR serial extra with " + "`pip install pylabrobot[serial]` and verify the serial port: " + f"{exc}" + ) from exc + text = b"".join(chunks).decode("utf-8", errors="ignore") + match = re.search(r"\d{6,}", text) + return match.group(0) if match else "NOREAD" async def get_scan_result(self) -> RackScanResult: self._complete_finished_scan_task() @@ -193,7 +222,7 @@ def _complete_scan_task(self, task: asyncio.Future[RackScanResult]) -> None: self._scan_error = None self._state = MicronicRackReaderState.DATAREADY - def _scan_rack_blocking(self) -> RackScanResult: + def _scan_rack_blocking(self, rack_id: str) -> RackScanResult: self.image_dir.mkdir(parents=True, exist_ok=True) image_path = ( self.image_dir @@ -203,10 +232,6 @@ def _scan_rack_blocking(self) -> RackScanResult: self.last_scan_metadata = self.scanner.acquire(image_path, self.scanner_timeout_ms) self.last_image_path = image_path - rack_id = read_rack_id( - serial_port=self.serial_port, - timeout_ms=self.serial_timeout_ms, - ) decoded, self.last_decode_metadata = decode_image(image_path) if len(decoded) < self._expected_well_count: missing = ", ".join(position for position in iter_positions() if position not in decoded) @@ -237,53 +262,6 @@ def _scan_rack_blocking(self) -> RackScanResult: return RackScanResult(rack_id=rack_id, entries=entries) -def read_rack_id( - serial_port: str = "COM4", - timeout_ms: int = 2500, -) -> str: - return asyncio.run(read_rack_id_plr_serial(serial_port=serial_port, timeout_ms=timeout_ms)) - - -async def read_rack_id_plr_serial(serial_port: str, timeout_ms: int) -> str: - deadline = time.monotonic() + timeout_ms / 1000 - chunks: list[bytes] = [] - io = Serial( - human_readable_device_name="Micronic rack ID reader", - port=serial_port, - baudrate=9600, - bytesize=7, - parity="E", - stopbits=1, - timeout=0.1, - write_timeout=1.0, - ) - try: - await io.setup() - await io.reset_input_buffer() - await io.write(b"\r\n") - while time.monotonic() < deadline: - value = await io.read(1) - if value: - chunks.append(value) - if value in {b"\r", b"\n"}: - break - except Exception as exc: - raise MicronicError( - "Rack ID serial read failed. Install the PLR serial extra with " - "`pip install pylabrobot[serial]` and verify the serial port: " - f"{exc}" - ) from exc - finally: - await io.stop() - - return extract_rack_id(b"".join(chunks).decode("utf-8", errors="ignore")) - - -def extract_rack_id(text: str) -> str: - match = re.search(r"\d{6,}", text) - return match.group(0) if match else "NOREAD" - - def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, object]]: cv2, np, zxingcpp, Image, ImageOps = import_decode_dependencies() with Image.open(image_path) as loaded_image: diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 4cf4d4eb094..f401e12a710 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -12,7 +12,6 @@ MicronicDriver, MicronicError, MicronicRackReaderState, - read_rack_id_plr_serial, ) from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend from pylabrobot.resources.tube_rack import TubeRack @@ -128,9 +127,10 @@ async def test_driver_scan_populates_standard_rack_result(self): "A2": DecodeResult(tube_id="2222222222", method="test"), } with ( - patch( - "pylabrobot.micronic.code_reader.driver.read_rack_id", - return_value="9500017722", + patch.object(driver.io, "setup", AsyncMock()), + patch.object(driver.io, "stop", AsyncMock()), + patch.object( + driver, "scan_rack_id", AsyncMock(return_value="9500017722") ) as read_rack_id_mock, patch( "pylabrobot.micronic.code_reader.driver.decode_image", @@ -150,7 +150,7 @@ async def test_driver_scan_populates_standard_rack_result(self): self.assertEqual(driver.last_scan_metadata, {"source": "test"}) self.assertEqual(driver.last_decode_metadata, {"decodedWells": 2}) scanner.acquire.assert_called_once() - read_rack_id_mock.assert_called_once() + read_rack_id_mock.assert_awaited_once() decode_image_mock.assert_called_once() async def test_reader_can_scan_twice_after_dataready(self): @@ -164,10 +164,9 @@ async def test_reader_can_scan_twice_after_dataready(self): ) decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} with ( - patch( - "pylabrobot.micronic.code_reader.driver.read_rack_id", - return_value="9500017722", - ), + patch.object(reader.driver.io, "setup", AsyncMock()), + patch.object(reader.driver.io, "stop", AsyncMock()), + patch.object(reader.driver, "scan_rack_id", AsyncMock(return_value="9500017722")), patch( "pylabrobot.micronic.code_reader.driver.decode_image", return_value=(decoded, {"decodedWells": 1}), @@ -185,7 +184,7 @@ async def test_reader_can_scan_twice_after_dataready(self): self.assertEqual(second.rack_id, "9500017722") self.assertEqual(scanner.acquire.call_count, 2) - async def test_read_rack_id_uses_plr_serial(self): + async def test_driver_read_rack_id_uses_plr_serial(self): instances: list[object] = [] class FakeSerial: @@ -212,7 +211,12 @@ async def stop(self): self.calls.append("stop") with patch("pylabrobot.micronic.code_reader.driver.Serial", FakeSerial): - rack_id = await read_rack_id_plr_serial(serial_port="/dev/ttyUSB0", timeout_ms=1000) + driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + await driver.setup() + try: + rack_id = await driver.scan_rack_id(timeout=0, poll_interval=0) + finally: + await driver.stop() self.assertEqual(len(instances), 1) fake_serial = cast(FakeSerial, instances[0]) @@ -220,6 +224,7 @@ async def stop(self): self.assertEqual(fake_serial.kwargs["port"], "/dev/ttyUSB0") self.assertEqual(fake_serial.kwargs["bytesize"], 7) self.assertEqual(fake_serial.kwargs["parity"], "E") + self.assertIn("setup", fake_serial.calls) self.assertIn("reset_input_buffer", fake_serial.calls) self.assertIn("write:b'\\r\\n'", fake_serial.calls) self.assertEqual(fake_serial.calls[-1], "stop") @@ -310,22 +315,26 @@ async def test_device_exposes_rack_reading_only(self): timeout=12.0, poll_interval=0.25, ) - await reader.setup() - try: - self.assertIn(reader.rack_reading, reader._capabilities) - self.assertFalse(hasattr(reader, "barcode_scanning")) - with patch.object( - reader.rack_reading, - "scan_rack", - return_value=MagicMock(rack_id="9500017722"), - ) as scan_rack: - result = await reader.rack_reading.scan_rack( - rack=_rack(), - timeout=reader.default_timeout, - poll_interval=reader.default_poll_interval, - ) - finally: - await reader.stop() + with ( + patch.object(reader.driver.io, "setup", AsyncMock()), + patch.object(reader.driver.io, "stop", AsyncMock()), + ): + await reader.setup() + try: + self.assertIn(reader.rack_reading, reader._capabilities) + self.assertFalse(hasattr(reader, "barcode_scanning")) + with patch.object( + reader.rack_reading, + "scan_rack", + return_value=MagicMock(rack_id="9500017722"), + ) as scan_rack: + result = await reader.rack_reading.scan_rack( + rack=_rack(), + timeout=reader.default_timeout, + poll_interval=reader.default_poll_interval, + ) + finally: + await reader.stop() self.assertEqual(result.rack_id, "9500017722") scan_rack.assert_called_once() From 75b0b4917330f19f6d5814710a8e671daaa8fab6 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 21:36:38 -0700 Subject: [PATCH 39/54] Drop stale scan_command and image_input references from Micronic doc Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/user_guide/micronic/index.ipynb | 88 ++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/docs/user_guide/micronic/index.ipynb b/docs/user_guide/micronic/index.ipynb index 44c0dd05bdb..7d88ea38967 100644 --- a/docs/user_guide/micronic/index.ipynb +++ b/docs/user_guide/micronic/index.ipynb @@ -3,31 +3,107 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Micronic\n\nPyLabRobot includes `v1b1` Micronic integrations built on the generic\n`rack_reading` capability.\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or `CommandScanner` for any other acquisition stack); the driver reads\nthe side rack barcode through the serial reader, decodes tube DataMatrix codes\nlocally, and returns a standard `RackScanResult`. It does not call Micronic\nCode Reader or IO Monitor, and PyLabRobot does not package any scanner helper\nexecutable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The driver\n only supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or a custom command), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment." + "source": [ + "# Micronic\n", + "\n", + "PyLabRobot includes `v1b1` Micronic integrations built on the generic\n", + "`rack_reading` capability.\n", + "\n", + "`MicronicCodeReader` controls the local hardware directly. It acquires the rack\n", + "image through a Windows TWAIN helper available on PATH or Ubuntu/Linux SANE\n", + "`scanimage`; reads the side rack barcode through the serial reader; decodes tube\n", + "DataMatrix codes locally; and returns a standard `RackScanResult`. It does not\n", + "call Micronic Code Reader or IO Monitor, and PyLabRobot does not package any\n", + "scanner helper executable.\n", + "\n", + "## Supported operations\n", + "\n", + "Rack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n", + "\n", + "- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n", + " positions, read the side rack barcode, and return a `RackScanResult`. The driver\n", + " only supports 8x12 racks; passing a different shape raises `MicronicError`.\n", + "- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n", + "\n", + "## Hardware example\n", + "\n", + "The operator is responsible for installing any OS-level scanner bridge\n", + "(`twain_scan` or `scanimage`), the PLR serial extra (`pylabrobot[serial]`), and\n", + "the local Python decode dependencies in the runtime environment." + ] }, { "cell_type": "code", "metadata": {}, - "source": "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\n\nreader = MicronicCodeReader(\n scanner=TwainScanner(\n twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n twain_source=\"AVA6PlusG\",\n ),\n serial_port=\"COM4\",\n image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n keep_images=True,\n)\nawait reader.setup()\n\ntry:\n rack_result = await reader.rack_reading.scan_rack(\n rack=my_rack, timeout=90.0, poll_interval=1.0\n )\n print(rack_result.rack_id)\n print(len([entry for entry in rack_result.entries if entry.tube_id]))\n\n rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n print(rack_id)\nfinally:\n await reader.stop()", + "source": [ + "from pylabrobot.micronic import MicronicCodeReader\n", + "\n", + "reader = MicronicCodeReader(\n", + " scanner_backend=\"twain\",\n", + " twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n", + " twain_source=\"AVA6PlusG\",\n", + " image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n", + " serial_port=\"COM4\",\n", + " keep_images=True,\n", + ")\n", + "await reader.setup()\n", + "\n", + "try:\n", + " rack_result = await reader.rack_reading.scan_rack(\n", + " rack=my_rack, timeout=90.0, poll_interval=1.0\n", + " )\n", + " print(rack_result.rack_id)\n", + " print(len([entry for entry in rack_result.entries if entry.tube_id]))\n", + "\n", + " rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n", + " print(rack_id)\n", + "finally:\n", + " await reader.stop()" + ], "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "metadata": {}, - "source": "On Ubuntu/Linux, use `SaneScanner` if the scanner is exposed by a SANE backend:" + "source": [ + "On Ubuntu/Linux, use SANE if the scanner is exposed by a SANE backend:" + ] }, { "cell_type": "code", "metadata": {}, - "source": "from pylabrobot.micronic import MicronicCodeReader, SaneScanner\n\nreader = MicronicCodeReader(\n scanner=SaneScanner(sane_device=\"avision:libusb:001:004\"),\n serial_port=\"/dev/ttyUSB0\",\n)", + "source": [ + "reader = MicronicCodeReader(\n", + " scanner_backend=\"sane\",\n", + " sane_device=\"avision:libusb:001:004\",\n", + " serial_port=\"/dev/ttyUSB0\",\n", + " image_extension=\"tiff\",\n", + ")" + ], "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "metadata": {}, - "source": "For any other acquisition stack, subclass `Scanner` and implement `acquire` to\nwrite a rack image to the given path.\n\n## Notes\n\n- `scan_rack` reads every tube barcode and finishes by reading the rack ID, so\n it typically takes tens of seconds. `scan_rack_id` only reads the rack\n barcode and completes in a few seconds.\n- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n bridge binary and does not install one for you; pass `twain_scanner_path` to\n `TwainScanner`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n named `twain_scan` / `twain_scan.exe` on `PATH`.\n- Ubuntu/Linux scanner control uses SANE `scanimage` (via `SaneScanner`).\n PyLabRobot does not install SANE or vendor scanner drivers. Rack-ID reads use\n `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]`\n extra.\n- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot." + "source": [ + "## Notes\n", + "\n", + "- `scan_rack` reads every tube barcode and finishes by reading the rack ID, so\n", + " it typically takes tens of seconds. `scan_rack_id` only reads the rack\n", + " barcode and completes in a few seconds.\n", + "- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n", + " bridge binary and does not install one for you; configure\n", + " `twain_scanner_path`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n", + " named `twain_scan`/`twain_scan.exe` on PATH when using the `twain` backend.\n", + "- Ubuntu/Linux scanner control uses SANE `scanimage`. PyLabRobot does not\n", + " install SANE or vendor scanner drivers. Rack-ID reads use\n", + " `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]`\n", + " extra.\n", + "- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n", + " `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot." + ] } ], "metadata": { @@ -43,4 +119,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} From 8f3cba37cf67da5605f517eb253a7c981d11438e Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 21:37:10 -0700 Subject: [PATCH 40/54] Revert "Drop stale scan_command and image_input references from Micronic doc" This reverts commit baf689b8616d90811188a722c11ad6f2b73a86a8. --- docs/user_guide/micronic/index.ipynb | 88 ++-------------------------- 1 file changed, 6 insertions(+), 82 deletions(-) diff --git a/docs/user_guide/micronic/index.ipynb b/docs/user_guide/micronic/index.ipynb index 7d88ea38967..44c0dd05bdb 100644 --- a/docs/user_guide/micronic/index.ipynb +++ b/docs/user_guide/micronic/index.ipynb @@ -3,107 +3,31 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "# Micronic\n", - "\n", - "PyLabRobot includes `v1b1` Micronic integrations built on the generic\n", - "`rack_reading` capability.\n", - "\n", - "`MicronicCodeReader` controls the local hardware directly. It acquires the rack\n", - "image through a Windows TWAIN helper available on PATH or Ubuntu/Linux SANE\n", - "`scanimage`; reads the side rack barcode through the serial reader; decodes tube\n", - "DataMatrix codes locally; and returns a standard `RackScanResult`. It does not\n", - "call Micronic Code Reader or IO Monitor, and PyLabRobot does not package any\n", - "scanner helper executable.\n", - "\n", - "## Supported operations\n", - "\n", - "Rack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n", - "\n", - "- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n", - " positions, read the side rack barcode, and return a `RackScanResult`. The driver\n", - " only supports 8x12 racks; passing a different shape raises `MicronicError`.\n", - "- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n", - "\n", - "## Hardware example\n", - "\n", - "The operator is responsible for installing any OS-level scanner bridge\n", - "(`twain_scan` or `scanimage`), the PLR serial extra (`pylabrobot[serial]`), and\n", - "the local Python decode dependencies in the runtime environment." - ] + "source": "# Micronic\n\nPyLabRobot includes `v1b1` Micronic integrations built on the generic\n`rack_reading` capability.\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or `CommandScanner` for any other acquisition stack); the driver reads\nthe side rack barcode through the serial reader, decodes tube DataMatrix codes\nlocally, and returns a standard `RackScanResult`. It does not call Micronic\nCode Reader or IO Monitor, and PyLabRobot does not package any scanner helper\nexecutable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The driver\n only supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or a custom command), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment." }, { "cell_type": "code", "metadata": {}, - "source": [ - "from pylabrobot.micronic import MicronicCodeReader\n", - "\n", - "reader = MicronicCodeReader(\n", - " scanner_backend=\"twain\",\n", - " twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n", - " twain_source=\"AVA6PlusG\",\n", - " image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n", - " serial_port=\"COM4\",\n", - " keep_images=True,\n", - ")\n", - "await reader.setup()\n", - "\n", - "try:\n", - " rack_result = await reader.rack_reading.scan_rack(\n", - " rack=my_rack, timeout=90.0, poll_interval=1.0\n", - " )\n", - " print(rack_result.rack_id)\n", - " print(len([entry for entry in rack_result.entries if entry.tube_id]))\n", - "\n", - " rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n", - " print(rack_id)\n", - "finally:\n", - " await reader.stop()" - ], + "source": "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\n\nreader = MicronicCodeReader(\n scanner=TwainScanner(\n twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n twain_source=\"AVA6PlusG\",\n ),\n serial_port=\"COM4\",\n image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n keep_images=True,\n)\nawait reader.setup()\n\ntry:\n rack_result = await reader.rack_reading.scan_rack(\n rack=my_rack, timeout=90.0, poll_interval=1.0\n )\n print(rack_result.rack_id)\n print(len([entry for entry in rack_result.entries if entry.tube_id]))\n\n rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n print(rack_id)\nfinally:\n await reader.stop()", "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "On Ubuntu/Linux, use SANE if the scanner is exposed by a SANE backend:" - ] + "source": "On Ubuntu/Linux, use `SaneScanner` if the scanner is exposed by a SANE backend:" }, { "cell_type": "code", "metadata": {}, - "source": [ - "reader = MicronicCodeReader(\n", - " scanner_backend=\"sane\",\n", - " sane_device=\"avision:libusb:001:004\",\n", - " serial_port=\"/dev/ttyUSB0\",\n", - " image_extension=\"tiff\",\n", - ")" - ], + "source": "from pylabrobot.micronic import MicronicCodeReader, SaneScanner\n\nreader = MicronicCodeReader(\n scanner=SaneScanner(sane_device=\"avision:libusb:001:004\"),\n serial_port=\"/dev/ttyUSB0\",\n)", "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Notes\n", - "\n", - "- `scan_rack` reads every tube barcode and finishes by reading the rack ID, so\n", - " it typically takes tens of seconds. `scan_rack_id` only reads the rack\n", - " barcode and completes in a few seconds.\n", - "- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n", - " bridge binary and does not install one for you; configure\n", - " `twain_scanner_path`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n", - " named `twain_scan`/`twain_scan.exe` on PATH when using the `twain` backend.\n", - "- Ubuntu/Linux scanner control uses SANE `scanimage`. PyLabRobot does not\n", - " install SANE or vendor scanner drivers. Rack-ID reads use\n", - " `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]`\n", - " extra.\n", - "- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n", - " `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot." - ] + "source": "For any other acquisition stack, subclass `Scanner` and implement `acquire` to\nwrite a rack image to the given path.\n\n## Notes\n\n- `scan_rack` reads every tube barcode and finishes by reading the rack ID, so\n it typically takes tens of seconds. `scan_rack_id` only reads the rack\n barcode and completes in a few seconds.\n- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n bridge binary and does not install one for you; pass `twain_scanner_path` to\n `TwainScanner`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n named `twain_scan` / `twain_scan.exe` on `PATH`.\n- Ubuntu/Linux scanner control uses SANE `scanimage` (via `SaneScanner`).\n PyLabRobot does not install SANE or vendor scanner drivers. Rack-ID reads use\n `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]`\n extra.\n- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot." } ], "metadata": { @@ -119,4 +43,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file From b52b41adfacaf5b838f082f8d03e04bf2854feaa Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 21:38:04 -0700 Subject: [PATCH 41/54] Remove the Micronic state machine trigger_rack_scan / get_rack_reader_state / get_scan_result and the MicronicRackReaderState enum existed only to expose the executor Future as a poll-and-finalize state machine. Replace them with a single async scan_rack method on the driver that awaits the executor directly. The backend collapses to asyncio.wait_for, the stale-DATAREADY workaround disappears, and the driver loses _state / _scan_task / _scan_error / _reported_scanning_since_trigger / _expected_well_count. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/pylabrobot.micronic.rst | 1 - pylabrobot/micronic/__init__.py | 1 - pylabrobot/micronic/code_reader/__init__.py | 2 +- pylabrobot/micronic/code_reader/driver.py | 102 ++---------------- .../micronic/code_reader/micronic_tests.py | 77 ++++--------- .../code_reader/rack_reading_backend.py | 29 +---- 6 files changed, 32 insertions(+), 180 deletions(-) diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index 4814c15dda1..fa1b78023d0 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -29,7 +29,6 @@ Driver :recursive: MicronicDriver - MicronicRackReaderState .. currentmodule:: pylabrobot.micronic.code_reader.errors diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index 926bd38069a..e5f213591bb 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -2,7 +2,6 @@ MicronicCodeReader, MicronicDriver, MicronicError, - MicronicRackReaderState, MicronicRackReadingBackend, SaneScanner, Scanner, diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index ccc98119a22..7de53b6b651 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -1,5 +1,5 @@ from pylabrobot.micronic.code_reader.code_reader import MicronicCodeReader -from pylabrobot.micronic.code_reader.driver import MicronicDriver, MicronicRackReaderState +from pylabrobot.micronic.code_reader.driver import MicronicDriver from pylabrobot.micronic.code_reader.errors import MicronicError from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend from pylabrobot.micronic.code_reader.scanner import SaneScanner, Scanner, TwainScanner diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 7a808a4f043..fc8dcdd7201 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -12,7 +12,6 @@ from __future__ import annotations import asyncio -import enum import logging import re import tempfile @@ -39,14 +38,6 @@ RACK_COLS = 12 -class MicronicRackReaderState(enum.Enum): - """Micronic rack reader state machine.""" - - IDLE = "idle" - SCANNING = "scanning" - DATAREADY = "dataready" - - @dataclass(frozen=True) class DecodeResult: tube_id: str @@ -83,12 +74,6 @@ def __init__( timeout=0.1, write_timeout=1.0, ) - self._state = MicronicRackReaderState.IDLE - self._expected_well_count = 0 - self._last_result: Optional[RackScanResult] = None - self._scan_task: Optional[asyncio.Future[RackScanResult]] = None - self._scan_error: Optional[Exception] = None - self._reported_scanning_since_trigger = False self.last_image_path: Optional[Path] = None self.last_scan_metadata: dict[str, object] = {} self.last_decode_metadata: dict[str, object] = {} @@ -99,32 +84,7 @@ async def setup(self, backend_params: Optional[BackendParams] = None): await self.io.setup() async def stop(self): - try: - scan_task = self._scan_task - if scan_task is None: - if self._scan_error is not None: - raise self._scan_error - return - if scan_task.done(): - self._complete_scan_task(scan_task) - else: - try: - self._last_result = await scan_task - except asyncio.CancelledError: - self._scan_error = MicronicError("Micronic rack scan was cancelled.") - self._state = MicronicRackReaderState.IDLE - except Exception as exc: - self._scan_error = exc - self._state = MicronicRackReaderState.IDLE - else: - self._scan_error = None - self._state = MicronicRackReaderState.DATAREADY - finally: - self._scan_task = None - if self._scan_error is not None: - raise self._scan_error - finally: - await self.io.stop() + await self.io.stop() def serialize(self) -> dict: return { @@ -135,30 +95,11 @@ def serialize(self) -> dict: "keep_images": self.keep_images, } - async def get_rack_reader_state(self) -> MicronicRackReaderState: - if ( - self._state == MicronicRackReaderState.SCANNING and not self._reported_scanning_since_trigger - ): - self._reported_scanning_since_trigger = True - return self._state - self._complete_finished_scan_task() - if self._scan_error is not None: - raise self._scan_error - return self._state - - async def trigger_rack_scan(self, rack: TubeRack) -> None: + async def scan_rack(self, rack: TubeRack) -> RackScanResult: self._validate_rack(rack) - self._complete_finished_scan_task() - if self._scan_task is not None and not self._scan_task.done(): - raise MicronicError("Micronic rack scan is already in progress.") rack_id = await self.scan_rack_id() - self._last_result = None - self._expected_well_count = rack.num_items - self._state = MicronicRackReaderState.SCANNING - self._scan_error = None - self._reported_scanning_since_trigger = False loop = asyncio.get_running_loop() - self._scan_task = loop.run_in_executor(None, self._scan_rack_blocking, rack_id) + return await loop.run_in_executor(None, self._scan_rack_blocking, rack_id, rack.num_items) @staticmethod def _validate_rack(rack: TubeRack) -> None: @@ -191,38 +132,7 @@ async def scan_rack_id(self, timeout: float = 0, poll_interval: float = 0) -> st match = re.search(r"\d{6,}", text) return match.group(0) if match else "NOREAD" - async def get_scan_result(self) -> RackScanResult: - self._complete_finished_scan_task() - if self._scan_error is not None: - raise self._scan_error - if self._state == MicronicRackReaderState.SCANNING: - raise MicronicError("Micronic rack scan is still in progress.") - if self._last_result is None: - raise MicronicError("No Micronic rack scan has completed yet.") - return self._last_result - - def _complete_finished_scan_task(self) -> None: - if self._scan_task is not None and self._scan_task.done(): - self._complete_scan_task(self._scan_task) - - def _complete_scan_task(self, task: asyncio.Future[RackScanResult]) -> None: - if task is not self._scan_task: - return - - self._scan_task = None - try: - self._last_result = task.result() - except asyncio.CancelledError: - self._scan_error = MicronicError("Micronic rack scan was cancelled.") - self._state = MicronicRackReaderState.IDLE - except Exception as exc: - self._scan_error = exc - self._state = MicronicRackReaderState.IDLE - else: - self._scan_error = None - self._state = MicronicRackReaderState.DATAREADY - - def _scan_rack_blocking(self, rack_id: str) -> RackScanResult: + def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackScanResult: self.image_dir.mkdir(parents=True, exist_ok=True) image_path = ( self.image_dir @@ -233,11 +143,11 @@ def _scan_rack_blocking(self, rack_id: str) -> RackScanResult: self.last_image_path = image_path decoded, self.last_decode_metadata = decode_image(image_path) - if len(decoded) < self._expected_well_count: + if len(decoded) < expected_well_count: missing = ", ".join(position for position in iter_positions() if position not in decoded) raise MicronicError( f"Micronic decode found {len(decoded)} wells; expected at least " - f"{self._expected_well_count}. Missing: {missing}" + f"{expected_well_count}. Missing: {missing}" ) for position, result in decoded.items(): diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index f401e12a710..a41d061f621 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -1,4 +1,3 @@ -import asyncio import os import tempfile import unittest @@ -11,7 +10,6 @@ DecodeResult, MicronicDriver, MicronicError, - MicronicRackReaderState, ) from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend from pylabrobot.resources.tube_rack import TubeRack @@ -32,14 +30,6 @@ def _mock_scanner(image_extension: str = "bmp") -> MagicMock: return scanner -async def wait_for_dataready(driver: MicronicDriver) -> None: - for _ in range(100): - if await driver.get_rack_reader_state() == MicronicRackReaderState.DATAREADY: - return - await asyncio.sleep(0.01) - raise AssertionError("Micronic test scan did not reach dataready.") - - class TestScannerClasses(unittest.IsolatedAsyncioTestCase): def test_sane_scanner_invokes_scanimage(self): with tempfile.TemporaryDirectory() as image_dir: @@ -113,7 +103,7 @@ def test_twain_scanner_acquire_runs_helper(self): class TestMicronicDriver(unittest.IsolatedAsyncioTestCase): - async def test_driver_scan_populates_standard_rack_result(self): + async def test_scan_rack_populates_standard_rack_result(self): with tempfile.TemporaryDirectory() as image_dir: scanner = _mock_scanner() driver = MicronicDriver( @@ -131,18 +121,15 @@ async def test_driver_scan_populates_standard_rack_result(self): patch.object(driver.io, "stop", AsyncMock()), patch.object( driver, "scan_rack_id", AsyncMock(return_value="9500017722") - ) as read_rack_id_mock, + ) as scan_rack_id_mock, patch( "pylabrobot.micronic.code_reader.driver.decode_image", return_value=(decoded, {"decodedWells": 2}), ) as decode_image_mock, ): await driver.setup() - await driver.trigger_rack_scan(_rack(num_items=2)) - await wait_for_dataready(driver) - result = await driver.get_scan_result() + result = await driver.scan_rack(_rack(num_items=2)) - self.assertEqual(await driver.get_rack_reader_state(), MicronicRackReaderState.DATAREADY) self.assertEqual(result.rack_id, "9500017722") self.assertEqual(result.entries[0].position, "A1") self.assertEqual(result.entries[0].tube_id, "1111111111") @@ -150,10 +137,10 @@ async def test_driver_scan_populates_standard_rack_result(self): self.assertEqual(driver.last_scan_metadata, {"source": "test"}) self.assertEqual(driver.last_decode_metadata, {"decodedWells": 2}) scanner.acquire.assert_called_once() - read_rack_id_mock.assert_awaited_once() + scan_rack_id_mock.assert_awaited_once() decode_image_mock.assert_called_once() - async def test_reader_can_scan_twice_after_dataready(self): + async def test_reader_can_scan_twice(self): with tempfile.TemporaryDirectory() as image_dir: scanner = _mock_scanner() reader = MicronicCodeReader( @@ -174,17 +161,17 @@ async def test_reader_can_scan_twice_after_dataready(self): ): await reader.setup() first = await reader.rack_reading.scan_rack( - rack=_rack(num_items=1), timeout=1.0, poll_interval=0.01 + rack=_rack(num_items=1), timeout=1.0, poll_interval=0.0 ) second = await reader.rack_reading.scan_rack( - rack=_rack(num_items=1), timeout=1.0, poll_interval=0.01 + rack=_rack(num_items=1), timeout=1.0, poll_interval=0.0 ) self.assertEqual(first.rack_id, "9500017722") self.assertEqual(second.rack_id, "9500017722") self.assertEqual(scanner.acquire.call_count, 2) - async def test_driver_read_rack_id_uses_plr_serial(self): + async def test_driver_scan_rack_id_uses_plr_serial(self): instances: list[object] = [] class FakeSerial: @@ -214,7 +201,7 @@ async def stop(self): driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") await driver.setup() try: - rack_id = await driver.scan_rack_id(timeout=0, poll_interval=0) + rack_id = await driver.scan_rack_id() finally: await driver.stop() @@ -229,68 +216,48 @@ async def stop(self): self.assertIn("write:b'\\r\\n'", fake_serial.calls) self.assertEqual(fake_serial.calls[-1], "stop") - async def test_driver_raises_when_scan_result_is_not_ready(self): - driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") - with self.assertRaises(MicronicError): - await driver.get_scan_result() - async def test_driver_rejects_mismatched_rack_shape(self): driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with self.assertRaises(MicronicError): - await driver.trigger_rack_scan(_rack(num_items_x=6, num_items_y=4)) + await driver.scan_rack(_rack(num_items_x=6, num_items_y=4)) class TestMicronicRackReadingBackend(unittest.IsolatedAsyncioTestCase): def _backend_with_mocked_driver(self) -> tuple[MicronicRackReadingBackend, MagicMock]: driver = MagicMock(spec=MicronicDriver) - driver.trigger_rack_scan = AsyncMock() - driver.get_rack_reader_state = AsyncMock() - driver.get_scan_result = AsyncMock() + driver.scan_rack = AsyncMock() driver.scan_rack_id = AsyncMock() return MicronicRackReadingBackend(driver=driver), driver - async def test_scan_rack_triggers_polls_and_returns_result(self): + async def test_scan_rack_delegates_to_driver(self): backend, driver = self._backend_with_mocked_driver() - driver.get_rack_reader_state.side_effect = [ - MicronicRackReaderState.IDLE, - MicronicRackReaderState.DATAREADY, - ] expected = MagicMock() - driver.get_scan_result.return_value = expected + driver.scan_rack.return_value = expected rack = _rack() result = await backend.scan_rack(rack=rack, timeout=1.0, poll_interval=0.0) self.assertIs(result, expected) - driver.trigger_rack_scan.assert_awaited_once_with(rack) - self.assertEqual(driver.get_rack_reader_state.await_count, 2) - driver.get_scan_result.assert_awaited_once() + driver.scan_rack.assert_awaited_once_with(rack) - async def test_scan_rack_waits_for_fresh_dataready(self): + async def test_scan_rack_times_out(self): backend, driver = self._backend_with_mocked_driver() - driver.get_rack_reader_state.side_effect = [ - MicronicRackReaderState.DATAREADY, - MicronicRackReaderState.SCANNING, - MicronicRackReaderState.DATAREADY, - ] - driver.get_scan_result.return_value = MagicMock() - await backend.scan_rack(rack=_rack(), timeout=1.0, poll_interval=0.0) + async def slow(rack): + del rack + import asyncio - self.assertEqual(driver.get_rack_reader_state.await_count, 3) - driver.get_scan_result.assert_awaited_once() + await asyncio.sleep(1) + return MagicMock() - async def test_scan_rack_times_out_with_standard_timeout_error(self): - backend, driver = self._backend_with_mocked_driver() - driver.get_rack_reader_state.return_value = MicronicRackReaderState.SCANNING + driver.scan_rack.side_effect = slow with self.assertRaises(TimeoutError): await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) async def test_scan_rack_propagates_driver_micronic_error(self): backend, driver = self._backend_with_mocked_driver() - driver.get_rack_reader_state.return_value = MicronicRackReaderState.IDLE - driver.trigger_rack_scan.side_effect = MicronicError("rack shape mismatch") + driver.scan_rack.side_effect = MicronicError("rack shape mismatch") with self.assertRaises(MicronicError): await backend.scan_rack( diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index cdc0bf6d825..6f30dd6717a 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -3,12 +3,11 @@ from __future__ import annotations import asyncio -import time from pylabrobot.capabilities.rack_reading import RackReaderBackend, RackScanResult from pylabrobot.resources.tube_rack import TubeRack -from .driver import MicronicDriver, MicronicRackReaderState +from .driver import MicronicDriver class MicronicRackReadingBackend(RackReaderBackend): @@ -19,30 +18,8 @@ def __init__(self, driver: MicronicDriver): self.driver = driver async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) -> RackScanResult: - initial_state = await self.driver.get_rack_reader_state() - await self.driver.trigger_rack_scan(rack) - await self._wait_for_fresh_dataready( - initial_state=initial_state, timeout=timeout, poll_interval=poll_interval - ) - return await self.driver.get_scan_result() + del poll_interval + return await asyncio.wait_for(self.driver.scan_rack(rack), timeout=timeout) async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: return await self.driver.scan_rack_id(timeout=timeout, poll_interval=poll_interval) - - async def _wait_for_fresh_dataready( - self, - initial_state: MicronicRackReaderState, - timeout: float, - poll_interval: float, - ) -> None: - require_state_change = initial_state == MicronicRackReaderState.DATAREADY - deadline = time.monotonic() + timeout - while True: - state = await self.driver.get_rack_reader_state() - if state != MicronicRackReaderState.DATAREADY: - require_state_change = False - elif not require_state_change: - return - if time.monotonic() >= deadline: - raise TimeoutError("Timed out waiting for Micronic rack reader to reach dataready.") - await asyncio.sleep(poll_interval) From f14c909740b2fba4df5bd8d3cbd615e68e13d653 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 21:42:19 -0700 Subject: [PATCH 42/54] Move Micronic notebook under code_reader/ and use index.md vendor pattern Co-Authored-By: Claude Opus 4.7 (1M context) --- .../{index.ipynb => code_reader/hello-world.ipynb} | 0 docs/user_guide/micronic/index.md | 7 +++++++ 2 files changed, 7 insertions(+) rename docs/user_guide/micronic/{index.ipynb => code_reader/hello-world.ipynb} (100%) create mode 100644 docs/user_guide/micronic/index.md diff --git a/docs/user_guide/micronic/index.ipynb b/docs/user_guide/micronic/code_reader/hello-world.ipynb similarity index 100% rename from docs/user_guide/micronic/index.ipynb rename to docs/user_guide/micronic/code_reader/hello-world.ipynb diff --git a/docs/user_guide/micronic/index.md b/docs/user_guide/micronic/index.md new file mode 100644 index 00000000000..b1a7a4a55c7 --- /dev/null +++ b/docs/user_guide/micronic/index.md @@ -0,0 +1,7 @@ +# Micronic + +```{toctree} +:maxdepth: 1 + +code_reader/hello-world +``` From 4c8bc87fe7ad2015e54fd0eaaa57de13da6e5925 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 21:43:39 -0700 Subject: [PATCH 43/54] Reject concurrent Micronic rack scans The state-machine removal dropped the "already in progress" guard. Replace it with an asyncio.Lock + locked() check that refuses overlapping scan_rack calls rather than queuing them, since the underlying hardware can only run one scan at a time. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/micronic/code_reader/driver.py | 16 ++++++++++------ .../micronic/code_reader/micronic_tests.py | 9 +++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index fc8dcdd7201..f0089fec9f3 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -77,6 +77,7 @@ def __init__( self.last_image_path: Optional[Path] = None self.last_scan_metadata: dict[str, object] = {} self.last_decode_metadata: dict[str, object] = {} + self._scan_lock = asyncio.Lock() async def setup(self, backend_params: Optional[BackendParams] = None): del backend_params @@ -95,12 +96,6 @@ def serialize(self) -> dict: "keep_images": self.keep_images, } - async def scan_rack(self, rack: TubeRack) -> RackScanResult: - self._validate_rack(rack) - rack_id = await self.scan_rack_id() - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, self._scan_rack_blocking, rack_id, rack.num_items) - @staticmethod def _validate_rack(rack: TubeRack) -> None: if rack.num_items_x != RACK_COLS or rack.num_items_y != RACK_ROWS: @@ -132,6 +127,15 @@ async def scan_rack_id(self, timeout: float = 0, poll_interval: float = 0) -> st match = re.search(r"\d{6,}", text) return match.group(0) if match else "NOREAD" + async def scan_rack(self, rack: TubeRack) -> RackScanResult: + self._validate_rack(rack) + if self._scan_lock.locked(): + raise MicronicError("Micronic rack scan is already in progress.") + async with self._scan_lock: + rack_id = await self.scan_rack_id() + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self._scan_rack_blocking, rack_id, rack.num_items) + def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackScanResult: self.image_dir.mkdir(parents=True, exist_ok=True) image_path = ( diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index a41d061f621..3720d7800ec 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -221,6 +221,15 @@ async def test_driver_rejects_mismatched_rack_shape(self): with self.assertRaises(MicronicError): await driver.scan_rack(_rack(num_items_x=6, num_items_y=4)) + async def test_driver_rejects_concurrent_scan(self): + driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + await driver._scan_lock.acquire() + try: + with self.assertRaises(MicronicError): + await driver.scan_rack(_rack()) + finally: + driver._scan_lock.release() + class TestMicronicRackReadingBackend(unittest.IsolatedAsyncioTestCase): def _backend_with_mocked_driver(self) -> tuple[MicronicRackReadingBackend, MagicMock]: From b136f0492395d02607c1dfba816b23d213a4ed21 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 22:01:33 -0700 Subject: [PATCH 44/54] Drop unused default_timeout / default_poll_interval on MicronicCodeReader poll_interval became dead weight when the state machine went away; nothing polls now. default_timeout was misleading: it only fed the scanner subprocess timeout, never any rack-reading default. Rename the ctor arg to scanner_timeout (its real meaning), drop both stored attributes, and drop the serialize override since Device.serialize already emits the driver state. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/micronic/code_reader/code_reader.py | 14 ++------------ pylabrobot/micronic/code_reader/micronic_tests.py | 7 +++---- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index 6e077ecccce..6ad85247595 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -23,8 +23,7 @@ def __init__( scanner: Scanner, serial_port: str, image_dir: Optional[str] = None, - timeout: float = 90.0, - poll_interval: float = 1.0, + scanner_timeout: float = 90.0, serial_timeout_ms: int = 2500, keep_images: bool = False, ): @@ -32,20 +31,11 @@ def __init__( scanner=scanner, serial_port=serial_port, image_dir=image_dir, - scanner_timeout_ms=int(timeout * 1000), + scanner_timeout_ms=int(scanner_timeout * 1000), serial_timeout_ms=serial_timeout_ms, keep_images=keep_images, ) super().__init__(driver=driver) self.driver: MicronicDriver = driver - self.default_timeout = timeout - self.default_poll_interval = poll_interval self.rack_reading = RackReader(backend=MicronicRackReadingBackend(driver)) self._capabilities = [self.rack_reading] - - def serialize(self) -> dict: - return { - **super().serialize(), - "timeout": self.default_timeout, - "poll_interval": self.default_poll_interval, - } diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 3720d7800ec..fb29473d255 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -288,8 +288,7 @@ async def test_device_exposes_rack_reading_only(self): reader = MicronicCodeReader( scanner=_mock_scanner(), serial_port="/dev/ttyUSB0", - timeout=12.0, - poll_interval=0.25, + scanner_timeout=12.0, ) with ( patch.object(reader.driver.io, "setup", AsyncMock()), @@ -306,8 +305,8 @@ async def test_device_exposes_rack_reading_only(self): ) as scan_rack: result = await reader.rack_reading.scan_rack( rack=_rack(), - timeout=reader.default_timeout, - poll_interval=reader.default_poll_interval, + timeout=12.0, + poll_interval=1.0, ) finally: await reader.stop() From 1368134ed023483599a93116fb88d26d093ac13e Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 20 May 2026 16:02:36 -0700 Subject: [PATCH 45/54] Address Micronic rack reader review notes --- .../capabilities/rack-reading.ipynb | 26 ++++++++++++++++--- .../micronic/code_reader/hello-world.ipynb | 8 +++--- .../capabilities/rack_reading/chatterbox.py | 11 +++++++- .../rack_reading/rack_reader_tests.py | 11 +++++++- .../capabilities/rack_reading/standard.py | 4 +++ pylabrobot/micronic/code_reader/driver.py | 22 +++++++++++++++- .../micronic/code_reader/micronic_tests.py | 6 +++++ 7 files changed, 77 insertions(+), 11 deletions(-) diff --git a/docs/user_guide/capabilities/rack-reading.ipynb b/docs/user_guide/capabilities/rack-reading.ipynb index 885218fe71c..66b0d0323ae 100644 --- a/docs/user_guide/capabilities/rack-reading.ipynb +++ b/docs/user_guide/capabilities/rack-reading.ipynb @@ -18,9 +18,9 @@ "cell_type": "code", "metadata": {}, "source": [ - "from pylabrobot.capabilities.rack_reading import RackReader\n", - "from pylabrobot.capabilities.rack_reading.chatterbox import RackReaderChatterboxBackend\n", - "from pylabrobot.resources.tube_rack import TubeRack\n", + "from pylabrobot.capabilities.rack_reading import RackReader\n", + "from pylabrobot.capabilities.rack_reading.chatterbox import RackReaderChatterboxBackend\n", + "from pylabrobot.resources import ResourceHolder, TubeRack, create_ordered_items_2d\n", "\n", "reader = RackReader(backend=RackReaderChatterboxBackend())\n", "await reader._on_setup()" @@ -32,7 +32,25 @@ "cell_type": "code", "metadata": {}, "source": [ - "rack = TubeRack(name=\"rack\", size_x=85, size_y=127, size_z=20)\n", + "rack = TubeRack(\n", + " name=\"rack\",\n", + " size_x=85.0,\n", + " size_y=127.0,\n", + " size_z=20.0,\n", + " ordered_items=create_ordered_items_2d(\n", + " ResourceHolder,\n", + " num_items_x=12,\n", + " num_items_y=8,\n", + " dx=0,\n", + " dy=0,\n", + " dz=0,\n", + " item_dx=9.0,\n", + " item_dy=9.0,\n", + " size_x=9.0,\n", + " size_y=9.0,\n", + " size_z=20.0,\n", + " ),\n", + ")\n", "\n", "result = await reader.scan_rack(rack=rack, timeout=60.0, poll_interval=1.0)\n", "print(result.rack_id)\n", diff --git a/docs/user_guide/micronic/code_reader/hello-world.ipynb b/docs/user_guide/micronic/code_reader/hello-world.ipynb index 44c0dd05bdb..fb01bc92a45 100644 --- a/docs/user_guide/micronic/code_reader/hello-world.ipynb +++ b/docs/user_guide/micronic/code_reader/hello-world.ipynb @@ -3,12 +3,12 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Micronic\n\nPyLabRobot includes `v1b1` Micronic integrations built on the generic\n`rack_reading` capability.\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or `CommandScanner` for any other acquisition stack); the driver reads\nthe side rack barcode through the serial reader, decodes tube DataMatrix codes\nlocally, and returns a standard `RackScanResult`. It does not call Micronic\nCode Reader or IO Monitor, and PyLabRobot does not package any scanner helper\nexecutable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The driver\n only supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or a custom command), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment." + "source": "# Micronic\n\nPyLabRobot includes `v1b1` Micronic integrations built on the generic\n`rack_reading` capability.\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or a custom `Scanner` subclass for any other acquisition stack); the driver\nreads the side rack barcode through the serial reader, decodes tube DataMatrix\ncodes locally, and returns a standard `RackScanResult`. It does not call Micronic\nCode Reader or IO Monitor, and PyLabRobot does not package any scanner helper\nexecutable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The driver\n only supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or custom scanner integration), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment." }, { "cell_type": "code", "metadata": {}, - "source": "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\n\nreader = MicronicCodeReader(\n scanner=TwainScanner(\n twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n twain_source=\"AVA6PlusG\",\n ),\n serial_port=\"COM4\",\n image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n keep_images=True,\n)\nawait reader.setup()\n\ntry:\n rack_result = await reader.rack_reading.scan_rack(\n rack=my_rack, timeout=90.0, poll_interval=1.0\n )\n print(rack_result.rack_id)\n print(len([entry for entry in rack_result.entries if entry.tube_id]))\n\n rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n print(rack_id)\nfinally:\n await reader.stop()", + "source": "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\nfrom pylabrobot.resources import ResourceHolder, TubeRack, create_ordered_items_2d\n\nrack = TubeRack(\n name=\"micronic_96_well_rack\",\n size_x=85.0,\n size_y=127.0,\n size_z=20.0,\n ordered_items=create_ordered_items_2d(\n ResourceHolder,\n num_items_x=12,\n num_items_y=8,\n dx=0,\n dy=0,\n dz=0,\n item_dx=9.0,\n item_dy=9.0,\n size_x=9.0,\n size_y=9.0,\n size_z=20.0,\n ),\n)\n\nreader = MicronicCodeReader(\n scanner=TwainScanner(\n twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n twain_source=\"AVA6PlusG\",\n ),\n serial_port=\"COM4\",\n image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n keep_images=True,\n)\nawait reader.setup()\n\ntry:\n rack_result = await reader.rack_reading.scan_rack(\n rack=rack, timeout=90.0, poll_interval=1.0\n )\n print(rack_result.rack_id)\n print(len([entry for entry in rack_result.entries if entry.tube_id]))\n\n rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n print(rack_id)\nfinally:\n await reader.stop()", "execution_count": null, "outputs": [] }, @@ -27,7 +27,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "For any other acquisition stack, subclass `Scanner` and implement `acquire` to\nwrite a rack image to the given path.\n\n## Notes\n\n- `scan_rack` reads every tube barcode and finishes by reading the rack ID, so\n it typically takes tens of seconds. `scan_rack_id` only reads the rack\n barcode and completes in a few seconds.\n- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n bridge binary and does not install one for you; pass `twain_scanner_path` to\n `TwainScanner`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n named `twain_scan` / `twain_scan.exe` on `PATH`.\n- Ubuntu/Linux scanner control uses SANE `scanimage` (via `SaneScanner`).\n PyLabRobot does not install SANE or vendor scanner drivers. Rack-ID reads use\n `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]`\n extra.\n- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot." + "source": "For any other acquisition stack, subclass `Scanner` and implement `acquire` to\nwrite a rack image to the given path.\n\n## Notes\n\n- `scan_rack` reads the rack ID and every tube barcode, so it typically takes\n tens of seconds. `scan_rack_id` only reads the rack barcode and completes in a\n few seconds.\n- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n bridge binary and does not install one for you; pass `twain_scanner_path` to\n `TwainScanner`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n named `twain_scan` / `twain_scan.exe` on `PATH`.\n- Ubuntu/Linux scanner control uses SANE `scanimage` (via `SaneScanner`).\n PyLabRobot does not install SANE or vendor scanner drivers. Rack-ID reads use\n `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]`\n extra.\n- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot." } ], "metadata": { @@ -43,4 +43,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/pylabrobot/capabilities/rack_reading/chatterbox.py b/pylabrobot/capabilities/rack_reading/chatterbox.py index a3f560755db..f380ff667b9 100644 --- a/pylabrobot/capabilities/rack_reading/chatterbox.py +++ b/pylabrobot/capabilities/rack_reading/chatterbox.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pylabrobot.resources.barcode import Barcode from pylabrobot.resources.tube_rack import TubeRack from .backend import RackReaderBackend @@ -14,8 +15,16 @@ async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) return RackScanResult( rack_id="CHATTERBOX", entries=[ - RackScanEntry(position="A1", tube_id="SIMULATED", status="OK"), + RackScanEntry( + position="A1", + tube_id="SIMULATED", + status="OK", + barcode=Barcode(data="SIMULATED", symbology="DataMatrix", position_on_resource="bottom"), + ), ], + rack_barcode=Barcode( + data="CHATTERBOX", symbology="Code 128 (Subset B and C)", position_on_resource="right" + ), ) async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py index 11edd883f1b..a05ffd51ffc 100644 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py @@ -3,13 +3,22 @@ from pylabrobot.capabilities.rack_reading.rack_reader import RackReader from pylabrobot.capabilities.rack_reading.standard import RackScanEntry, RackScanResult +from pylabrobot.resources.barcode import Barcode from pylabrobot.resources.tube_rack import TubeRack SCAN_RESULT = RackScanResult( rack_id="5500135415", entries=[ - RackScanEntry(position="A1", tube_id="7518613629", status="OK"), + RackScanEntry( + position="A1", + tube_id="7518613629", + status="OK", + barcode=Barcode(data="7518613629", symbology="DataMatrix", position_on_resource="bottom"), + ), ], + rack_barcode=Barcode( + data="5500135415", symbology="Code 128 (Subset B and C)", position_on_resource="right" + ), ) diff --git a/pylabrobot/capabilities/rack_reading/standard.py b/pylabrobot/capabilities/rack_reading/standard.py index 355ac90098f..021c500f379 100644 --- a/pylabrobot/capabilities/rack_reading/standard.py +++ b/pylabrobot/capabilities/rack_reading/standard.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import Literal, Optional +from pylabrobot.resources.barcode import Barcode + @dataclass class RackScanEntry: @@ -11,6 +13,7 @@ class RackScanEntry: position: str tube_id: Optional[str] status: Literal["OK", "NOREAD"] + barcode: Optional[Barcode] = None @dataclass @@ -19,3 +22,4 @@ class RackScanResult: rack_id: str entries: list[RackScanEntry] + rack_barcode: Optional[Barcode] = None diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index f0089fec9f3..205519ff655 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -25,6 +25,7 @@ from pylabrobot.capabilities.rack_reading import RackScanEntry, RackScanResult from pylabrobot.device import Driver from pylabrobot.io.serial import Serial +from pylabrobot.resources.barcode import Barcode from pylabrobot.resources.tube_rack import TubeRack from .errors import MicronicError @@ -162,6 +163,15 @@ def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackSca position=position, tube_id=decoded[position].tube_id if position in decoded else None, status="OK" if position in decoded else "NOREAD", + barcode=( + Barcode( + data=decoded[position].tube_id, + symbology="DataMatrix", + position_on_resource="bottom", + ) + if position in decoded + else None + ), ) for position in iter_positions() ] @@ -173,7 +183,17 @@ def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackSca except OSError: pass - return RackScanResult(rack_id=rack_id, entries=entries) + return RackScanResult( + rack_id=rack_id, + entries=entries, + rack_barcode=Barcode( + data=rack_id, + symbology="Code 128 (Subset B and C)", + position_on_resource="right", + ) + if rack_id != "NOREAD" + else None, + ) def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, object]]: diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index fb29473d255..e88663e0f2b 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -131,8 +131,14 @@ async def test_scan_rack_populates_standard_rack_result(self): result = await driver.scan_rack(_rack(num_items=2)) self.assertEqual(result.rack_id, "9500017722") + self.assertIsNotNone(result.rack_barcode) + self.assertEqual(result.rack_barcode.data, "9500017722") + self.assertEqual(result.rack_barcode.symbology, "Code 128 (Subset B and C)") self.assertEqual(result.entries[0].position, "A1") self.assertEqual(result.entries[0].tube_id, "1111111111") + self.assertIsNotNone(result.entries[0].barcode) + self.assertEqual(result.entries[0].barcode.data, "1111111111") + self.assertEqual(result.entries[0].barcode.symbology, "DataMatrix") self.assertEqual(result.entries[1].tube_id, "2222222222") self.assertEqual(driver.last_scan_metadata, {"source": "test"}) self.assertEqual(driver.last_decode_metadata, {"decodedWells": 2}) From 94e2f94bbb135a449a77087e1c89951ace1eebc0 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 20 May 2026 16:15:08 -0700 Subject: [PATCH 46/54] Align Micronic reader with v1b1 layering --- docs/api/pylabrobot.micronic.rst | 4 +- .../micronic/code_reader/hello-world.ipynb | 2 +- pylabrobot/micronic/__init__.py | 4 +- pylabrobot/micronic/code_reader/__init__.py | 6 +- .../micronic/code_reader/code_reader.py | 12 +- pylabrobot/micronic/code_reader/driver.py | 81 +------ .../micronic/code_reader/micronic_tests.py | 207 +++++++++--------- .../code_reader/rack_reading_backend.py | 85 ++++++- 8 files changed, 203 insertions(+), 198 deletions(-) diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index fa1b78023d0..712b80f98aa 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -28,7 +28,7 @@ Driver :nosignatures: :recursive: - MicronicDriver + MicronicCodeReaderDriver .. currentmodule:: pylabrobot.micronic.code_reader.errors @@ -65,4 +65,4 @@ Capabilities :nosignatures: :recursive: - MicronicRackReadingBackend + MicronicCodeReaderRackReadingBackend diff --git a/docs/user_guide/micronic/code_reader/hello-world.ipynb b/docs/user_guide/micronic/code_reader/hello-world.ipynb index fb01bc92a45..314f1cb2da7 100644 --- a/docs/user_guide/micronic/code_reader/hello-world.ipynb +++ b/docs/user_guide/micronic/code_reader/hello-world.ipynb @@ -3,7 +3,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Micronic\n\nPyLabRobot includes `v1b1` Micronic integrations built on the generic\n`rack_reading` capability.\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or a custom `Scanner` subclass for any other acquisition stack); the driver\nreads the side rack barcode through the serial reader, decodes tube DataMatrix\ncodes locally, and returns a standard `RackScanResult`. It does not call Micronic\nCode Reader or IO Monitor, and PyLabRobot does not package any scanner helper\nexecutable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The driver\n only supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or custom scanner integration), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment." + "source": "# Micronic\n\nPyLabRobot includes `v1b1` Micronic integrations built on the generic\n`rack_reading` capability.\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or a custom `Scanner` subclass for any other acquisition stack). The driver\nhandles image acquisition and side-rack serial barcode reads; the rack-reading\nbackend decodes tube DataMatrix codes locally and returns a standard\n`RackScanResult`. It does not call Micronic Code Reader or IO Monitor, and\nPyLabRobot does not package any scanner helper executable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The device\n currently supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or custom scanner integration), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment." }, { "cell_type": "code", diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index e5f213591bb..2c37b8b19b9 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -1,8 +1,8 @@ from pylabrobot.micronic.code_reader import ( MicronicCodeReader, - MicronicDriver, + MicronicCodeReaderDriver, + MicronicCodeReaderRackReadingBackend, MicronicError, - MicronicRackReadingBackend, SaneScanner, Scanner, TwainScanner, diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index 7de53b6b651..0f67135b25a 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -1,5 +1,7 @@ from pylabrobot.micronic.code_reader.code_reader import MicronicCodeReader -from pylabrobot.micronic.code_reader.driver import MicronicDriver +from pylabrobot.micronic.code_reader.driver import MicronicCodeReaderDriver from pylabrobot.micronic.code_reader.errors import MicronicError -from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend +from pylabrobot.micronic.code_reader.rack_reading_backend import ( + MicronicCodeReaderRackReadingBackend, +) from pylabrobot.micronic.code_reader.scanner import SaneScanner, Scanner, TwainScanner diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py index 6ad85247595..1ecd7dfb507 100644 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ b/pylabrobot/micronic/code_reader/code_reader.py @@ -7,15 +7,15 @@ from pylabrobot.capabilities.rack_reading import RackReader from pylabrobot.device import Device -from .driver import MicronicDriver -from .rack_reading_backend import MicronicRackReadingBackend +from .driver import MicronicCodeReaderDriver +from .rack_reading_backend import MicronicCodeReaderRackReadingBackend from .scanner import Scanner class MicronicCodeReader(Device): """Micronic rack reader device. - The rack-reading capability is driven by ``MicronicDriver``. + The rack-reading capability is driven by ``MicronicCodeReaderDriver``. """ def __init__( @@ -27,7 +27,7 @@ def __init__( serial_timeout_ms: int = 2500, keep_images: bool = False, ): - driver = MicronicDriver( + driver = MicronicCodeReaderDriver( scanner=scanner, serial_port=serial_port, image_dir=image_dir, @@ -36,6 +36,6 @@ def __init__( keep_images=keep_images, ) super().__init__(driver=driver) - self.driver: MicronicDriver = driver - self.rack_reading = RackReader(backend=MicronicRackReadingBackend(driver)) + self.driver: MicronicCodeReaderDriver = driver + self.rack_reading = RackReader(backend=MicronicCodeReaderRackReadingBackend(driver)) self._capabilities = [self.rack_reading] diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 205519ff655..5b6dc3f1dd7 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -4,15 +4,12 @@ scanner path directly: - acquire a rack image through a caller-supplied :class:`Scanner`, -- read the rack ID through the side serial barcode reader, -- decode tube DataMatrix codes locally, and -- return the standard PLR rack-reading result. +- read barcodes through the side serial barcode reader, and +- expose acquisition metadata for the rack-reading backend. """ from __future__ import annotations -import asyncio -import logging import re import tempfile import time @@ -22,17 +19,12 @@ from typing import Iterable, Optional from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.capabilities.rack_reading import RackScanEntry, RackScanResult from pylabrobot.device import Driver from pylabrobot.io.serial import Serial -from pylabrobot.resources.barcode import Barcode -from pylabrobot.resources.tube_rack import TubeRack from .errors import MicronicError from .scanner import Scanner -logger = logging.getLogger(__name__) - ROWS = "ABCDEFGH" COLS = 12 RACK_ROWS = 8 @@ -45,7 +37,7 @@ class DecodeResult: method: str -class MicronicDriver(Driver): +class MicronicCodeReaderDriver(Driver): """Driver that controls the Micronic scanner without the OEM app.""" def __init__( @@ -78,7 +70,6 @@ def __init__( self.last_image_path: Optional[Path] = None self.last_scan_metadata: dict[str, object] = {} self.last_decode_metadata: dict[str, object] = {} - self._scan_lock = asyncio.Lock() async def setup(self, backend_params: Optional[BackendParams] = None): del backend_params @@ -97,16 +88,7 @@ def serialize(self) -> dict: "keep_images": self.keep_images, } - @staticmethod - def _validate_rack(rack: TubeRack) -> None: - if rack.num_items_x != RACK_COLS or rack.num_items_y != RACK_ROWS: - raise MicronicError( - f"Micronic driver only supports {RACK_ROWS}x{RACK_COLS} racks; " - f"got {rack.num_items_y}x{rack.num_items_x}." - ) - - async def scan_rack_id(self, timeout: float = 0, poll_interval: float = 0) -> str: - del timeout, poll_interval + async def read_barcode(self) -> str: deadline = time.monotonic() + self.serial_timeout_ms / 1000 chunks: list[bytes] = [] try: @@ -128,54 +110,17 @@ async def scan_rack_id(self, timeout: float = 0, poll_interval: float = 0) -> st match = re.search(r"\d{6,}", text) return match.group(0) if match else "NOREAD" - async def scan_rack(self, rack: TubeRack) -> RackScanResult: - self._validate_rack(rack) - if self._scan_lock.locked(): - raise MicronicError("Micronic rack scan is already in progress.") - async with self._scan_lock: - rack_id = await self.scan_rack_id() - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, self._scan_rack_blocking, rack_id, rack.num_items) - - def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackScanResult: + def acquire_image(self) -> Path: self.image_dir.mkdir(parents=True, exist_ok=True) image_path = ( self.image_dir / f"micronic_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.{self.scanner.image_extension}" ) - self.last_scan_metadata = self.scanner.acquire(image_path, self.scanner_timeout_ms) self.last_image_path = image_path + return image_path - decoded, self.last_decode_metadata = decode_image(image_path) - if len(decoded) < expected_well_count: - missing = ", ".join(position for position in iter_positions() if position not in decoded) - raise MicronicError( - f"Micronic decode found {len(decoded)} wells; expected at least " - f"{expected_well_count}. Missing: {missing}" - ) - - for position, result in decoded.items(): - logger.debug("Micronic decoded %s via %s", position, result.method) - - entries = [ - RackScanEntry( - position=position, - tube_id=decoded[position].tube_id if position in decoded else None, - status="OK" if position in decoded else "NOREAD", - barcode=( - Barcode( - data=decoded[position].tube_id, - symbology="DataMatrix", - position_on_resource="bottom", - ) - if position in decoded - else None - ), - ) - for position in iter_positions() - ] - + def release_image(self, image_path: Path) -> None: if not self.keep_images: try: image_path.unlink() @@ -183,18 +128,6 @@ def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackSca except OSError: pass - return RackScanResult( - rack_id=rack_id, - entries=entries, - rack_barcode=Barcode( - data=rack_id, - symbology="Code 128 (Subset B and C)", - position_on_resource="right", - ) - if rack_id != "NOREAD" - else None, - ) - def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, object]]: cv2, np, zxingcpp, Image, ImageOps = import_decode_dependencies() diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index e88663e0f2b..7307a54f6d8 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -8,10 +8,12 @@ from pylabrobot.micronic import MicronicCodeReader, SaneScanner, TwainScanner from pylabrobot.micronic.code_reader.driver import ( DecodeResult, - MicronicDriver, + MicronicCodeReaderDriver, MicronicError, ) -from pylabrobot.micronic.code_reader.rack_reading_backend import MicronicRackReadingBackend +from pylabrobot.micronic.code_reader.rack_reading_backend import ( + MicronicCodeReaderRackReadingBackend, +) from pylabrobot.resources.tube_rack import TubeRack @@ -102,33 +104,93 @@ def test_twain_scanner_acquire_runs_helper(self): ) -class TestMicronicDriver(unittest.IsolatedAsyncioTestCase): +class TestMicronicCodeReaderDriver(unittest.IsolatedAsyncioTestCase): + def test_acquire_image_runs_scanner_and_tracks_metadata(self): + with tempfile.TemporaryDirectory() as image_dir: + scanner = _mock_scanner() + driver = MicronicCodeReaderDriver( + scanner=scanner, + serial_port="/dev/ttyUSB0", + image_dir=image_dir, + keep_images=True, + ) + image_path = driver.acquire_image() + + self.assertEqual(driver.last_image_path, image_path) + self.assertEqual(driver.last_scan_metadata, {"source": "test"}) + scanner.acquire.assert_called_once() + self.assertTrue(image_path.name.startswith("micronic_")) + self.assertEqual(image_path.suffix, ".bmp") + + async def test_read_barcode_uses_plr_serial(self): + instances: list[object] = [] + + class FakeSerial: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.reads = iter([b"9", b"5", b"0", b"0", b"0", b"1", b"7", b"7", b"2", b"2", b"\r"]) + self.calls: list[str] = [] + instances.append(self) + + async def setup(self): + self.calls.append("setup") + + async def reset_input_buffer(self): + self.calls.append("reset_input_buffer") + + async def write(self, data: bytes): + self.calls.append(f"write:{data!r}") + + async def read(self, num_bytes: int = 1) -> bytes: + self.calls.append(f"read:{num_bytes}") + return next(self.reads) + + async def stop(self): + self.calls.append("stop") + + with patch("pylabrobot.micronic.code_reader.driver.Serial", FakeSerial): + driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + await driver.setup() + try: + rack_id = await driver.read_barcode() + finally: + await driver.stop() + + self.assertEqual(len(instances), 1) + fake_serial = cast(FakeSerial, instances[0]) + self.assertEqual(rack_id, "9500017722") + self.assertEqual(fake_serial.kwargs["port"], "/dev/ttyUSB0") + self.assertEqual(fake_serial.kwargs["bytesize"], 7) + self.assertEqual(fake_serial.kwargs["parity"], "E") + self.assertIn("setup", fake_serial.calls) + self.assertIn("reset_input_buffer", fake_serial.calls) + self.assertIn("write:b'\\r\\n'", fake_serial.calls) + self.assertEqual(fake_serial.calls[-1], "stop") + + +class TestMicronicCodeReaderRackReadingBackend(unittest.IsolatedAsyncioTestCase): async def test_scan_rack_populates_standard_rack_result(self): with tempfile.TemporaryDirectory() as image_dir: scanner = _mock_scanner() - driver = MicronicDriver( + driver = MicronicCodeReaderDriver( scanner=scanner, serial_port="/dev/ttyUSB0", image_dir=image_dir, keep_images=True, ) + backend = MicronicCodeReaderRackReadingBackend(driver=driver) decoded = { "A1": DecodeResult(tube_id="1111111111", method="test"), "A2": DecodeResult(tube_id="2222222222", method="test"), } with ( - patch.object(driver.io, "setup", AsyncMock()), - patch.object(driver.io, "stop", AsyncMock()), - patch.object( - driver, "scan_rack_id", AsyncMock(return_value="9500017722") - ) as scan_rack_id_mock, + patch.object(driver, "read_barcode", AsyncMock(return_value="9500017722")) as read_barcode, patch( - "pylabrobot.micronic.code_reader.driver.decode_image", + "pylabrobot.micronic.code_reader.rack_reading_backend.decode_image", return_value=(decoded, {"decodedWells": 2}), ) as decode_image_mock, ): - await driver.setup() - result = await driver.scan_rack(_rack(num_items=2)) + result = await backend.scan_rack(_rack(num_items=2), timeout=1.0, poll_interval=0.0) self.assertEqual(result.rack_id, "9500017722") self.assertIsNotNone(result.rack_barcode) @@ -143,7 +205,7 @@ async def test_scan_rack_populates_standard_rack_result(self): self.assertEqual(driver.last_scan_metadata, {"source": "test"}) self.assertEqual(driver.last_decode_metadata, {"decodedWells": 2}) scanner.acquire.assert_called_once() - scan_rack_id_mock.assert_awaited_once() + read_barcode.assert_awaited_once() decode_image_mock.assert_called_once() async def test_reader_can_scan_twice(self): @@ -159,9 +221,9 @@ async def test_reader_can_scan_twice(self): with ( patch.object(reader.driver.io, "setup", AsyncMock()), patch.object(reader.driver.io, "stop", AsyncMock()), - patch.object(reader.driver, "scan_rack_id", AsyncMock(return_value="9500017722")), + patch.object(reader.driver, "read_barcode", AsyncMock(return_value="9500017722")), patch( - "pylabrobot.micronic.code_reader.driver.decode_image", + "pylabrobot.micronic.code_reader.rack_reading_backend.decode_image", return_value=(decoded, {"decodedWells": 1}), ), ): @@ -177,86 +239,25 @@ async def test_reader_can_scan_twice(self): self.assertEqual(second.rack_id, "9500017722") self.assertEqual(scanner.acquire.call_count, 2) - async def test_driver_scan_rack_id_uses_plr_serial(self): - instances: list[object] = [] - - class FakeSerial: - def __init__(self, **kwargs): - self.kwargs = kwargs - self.reads = iter([b"9", b"5", b"0", b"0", b"0", b"1", b"7", b"7", b"2", b"2", b"\r"]) - self.calls: list[str] = [] - instances.append(self) - - async def setup(self): - self.calls.append("setup") - - async def reset_input_buffer(self): - self.calls.append("reset_input_buffer") - - async def write(self, data: bytes): - self.calls.append(f"write:{data!r}") - - async def read(self, num_bytes: int = 1) -> bytes: - self.calls.append(f"read:{num_bytes}") - return next(self.reads) - - async def stop(self): - self.calls.append("stop") - - with patch("pylabrobot.micronic.code_reader.driver.Serial", FakeSerial): - driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") - await driver.setup() - try: - rack_id = await driver.scan_rack_id() - finally: - await driver.stop() - - self.assertEqual(len(instances), 1) - fake_serial = cast(FakeSerial, instances[0]) - self.assertEqual(rack_id, "9500017722") - self.assertEqual(fake_serial.kwargs["port"], "/dev/ttyUSB0") - self.assertEqual(fake_serial.kwargs["bytesize"], 7) - self.assertEqual(fake_serial.kwargs["parity"], "E") - self.assertIn("setup", fake_serial.calls) - self.assertIn("reset_input_buffer", fake_serial.calls) - self.assertIn("write:b'\\r\\n'", fake_serial.calls) - self.assertEqual(fake_serial.calls[-1], "stop") - - async def test_driver_rejects_mismatched_rack_shape(self): - driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + async def test_backend_rejects_mismatched_rack_shape(self): + driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + backend = MicronicCodeReaderRackReadingBackend(driver=driver) with self.assertRaises(MicronicError): - await driver.scan_rack(_rack(num_items_x=6, num_items_y=4)) + await backend.scan_rack(_rack(num_items_x=6, num_items_y=4), timeout=1.0, poll_interval=0.0) - async def test_driver_rejects_concurrent_scan(self): - driver = MicronicDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") - await driver._scan_lock.acquire() + async def test_backend_rejects_concurrent_scan(self): + driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + backend = MicronicCodeReaderRackReadingBackend(driver=driver) + await backend._scan_lock.acquire() try: with self.assertRaises(MicronicError): - await driver.scan_rack(_rack()) + await backend.scan_rack(_rack(), timeout=1.0, poll_interval=0.0) finally: - driver._scan_lock.release() - - -class TestMicronicRackReadingBackend(unittest.IsolatedAsyncioTestCase): - def _backend_with_mocked_driver(self) -> tuple[MicronicRackReadingBackend, MagicMock]: - driver = MagicMock(spec=MicronicDriver) - driver.scan_rack = AsyncMock() - driver.scan_rack_id = AsyncMock() - return MicronicRackReadingBackend(driver=driver), driver - - async def test_scan_rack_delegates_to_driver(self): - backend, driver = self._backend_with_mocked_driver() - expected = MagicMock() - driver.scan_rack.return_value = expected - rack = _rack() - - result = await backend.scan_rack(rack=rack, timeout=1.0, poll_interval=0.0) - - self.assertIs(result, expected) - driver.scan_rack.assert_awaited_once_with(rack) + backend._scan_lock.release() async def test_scan_rack_times_out(self): - backend, driver = self._backend_with_mocked_driver() + driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + backend = MicronicCodeReaderRackReadingBackend(driver=driver) async def slow(rack): del rack @@ -265,28 +266,26 @@ async def slow(rack): await asyncio.sleep(1) return MagicMock() - driver.scan_rack.side_effect = slow - - with self.assertRaises(TimeoutError): - await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) - - async def test_scan_rack_propagates_driver_micronic_error(self): - backend, driver = self._backend_with_mocked_driver() - driver.scan_rack.side_effect = MicronicError("rack shape mismatch") + with patch.object(backend, "_scan_rack", slow): + with self.assertRaises(TimeoutError): + await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) + async def test_scan_rack_propagates_micronic_error(self): + driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + backend = MicronicCodeReaderRackReadingBackend(driver=driver) with self.assertRaises(MicronicError): await backend.scan_rack( rack=_rack(num_items_x=6, num_items_y=4), timeout=1.0, poll_interval=0.0 ) - async def test_scan_rack_id_delegates_to_driver(self): - backend, driver = self._backend_with_mocked_driver() - driver.scan_rack_id.return_value = "9500017722" - - rack_id = await backend.scan_rack_id(timeout=5.0, poll_interval=0.5) + async def test_scan_rack_id_reads_barcode(self): + driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + backend = MicronicCodeReaderRackReadingBackend(driver=driver) + with patch.object(driver, "read_barcode", AsyncMock(return_value="9500017722")) as read_barcode: + rack_id = await backend.scan_rack_id(timeout=5.0, poll_interval=0.5) - self.assertEqual(rack_id, "9500017722") - driver.scan_rack_id.assert_awaited_once_with(timeout=5.0, poll_interval=0.5) + self.assertEqual(rack_id, "9500017722") + read_barcode.assert_awaited_once_with() class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): @@ -325,7 +324,7 @@ async def test_frontend_uses_driver(self): scanner=_mock_scanner(), serial_port="/dev/ttyUSB0", ) - self.assertIsInstance(reader.driver, MicronicDriver) + self.assertIsInstance(reader.driver, MicronicCodeReaderDriver) self.assertFalse(hasattr(reader, "barcode_scanning")) diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index 6f30dd6717a..696bf01191d 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -3,23 +3,94 @@ from __future__ import annotations import asyncio +import logging -from pylabrobot.capabilities.rack_reading import RackReaderBackend, RackScanResult +from pylabrobot.capabilities.rack_reading import RackReaderBackend, RackScanEntry, RackScanResult +from pylabrobot.resources.barcode import Barcode from pylabrobot.resources.tube_rack import TubeRack -from .driver import MicronicDriver +from .driver import RACK_COLS, RACK_ROWS, MicronicCodeReaderDriver, decode_image, iter_positions +from .errors import MicronicError +logger = logging.getLogger(__name__) -class MicronicRackReadingBackend(RackReaderBackend): - """Rack-reading backend that delegates to the Micronic driver.""" - def __init__(self, driver: MicronicDriver): +class MicronicCodeReaderRackReadingBackend(RackReaderBackend): + """Rack-reading backend for the Micronic code reader.""" + + def __init__(self, driver: MicronicCodeReaderDriver): super().__init__() self.driver = driver + self._scan_lock = asyncio.Lock() + + @staticmethod + def _validate_rack(rack: TubeRack) -> None: + if rack.num_items_x != RACK_COLS or rack.num_items_y != RACK_ROWS: + raise MicronicError( + f"Micronic code reader only supports {RACK_ROWS}x{RACK_COLS} racks; " + f"got {rack.num_items_y}x{rack.num_items_x}." + ) async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) -> RackScanResult: del poll_interval - return await asyncio.wait_for(self.driver.scan_rack(rack), timeout=timeout) + return await asyncio.wait_for(self._scan_rack(rack), timeout=timeout) async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - return await self.driver.scan_rack_id(timeout=timeout, poll_interval=poll_interval) + del poll_interval + return await asyncio.wait_for(self.driver.read_barcode(), timeout=timeout) + + async def _scan_rack(self, rack: TubeRack) -> RackScanResult: + self._validate_rack(rack) + if self._scan_lock.locked(): + raise MicronicError("Micronic rack scan is already in progress.") + async with self._scan_lock: + rack_id = await self.driver.read_barcode() + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self._scan_rack_blocking, rack_id, rack.num_items) + + def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackScanResult: + image_path = self.driver.acquire_image() + + try: + decoded, self.driver.last_decode_metadata = decode_image(image_path) + if len(decoded) < expected_well_count: + missing = ", ".join(position for position in iter_positions() if position not in decoded) + raise MicronicError( + f"Micronic decode found {len(decoded)} wells; expected at least " + f"{expected_well_count}. Missing: {missing}" + ) + + for position, result in decoded.items(): + logger.debug("Micronic decoded %s via %s", position, result.method) + + entries = [ + RackScanEntry( + position=position, + tube_id=decoded[position].tube_id if position in decoded else None, + status="OK" if position in decoded else "NOREAD", + barcode=( + Barcode( + data=decoded[position].tube_id, + symbology="DataMatrix", + position_on_resource="bottom", + ) + if position in decoded + else None + ), + ) + for position in iter_positions() + ] + + return RackScanResult( + rack_id=rack_id, + entries=entries, + rack_barcode=Barcode( + data=rack_id, + symbology="Code 128 (Subset B and C)", + position_on_resource="right", + ) + if rack_id != "NOREAD" + else None, + ) + finally: + self.driver.release_image(image_path) From 8ac188214d1d1b54dd224181dd7281c37a192797 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 21 May 2026 16:34:17 -0700 Subject: [PATCH 47/54] Keep Micronic scan locked through timeout cleanup --- docs/api/pylabrobot.rst | 24 +- docs/user_guide/index.md | 121 ++++++++- docs/user_guide/machines.md | 244 +++++++++++++++--- .../hamilton/liquid_handlers/star/star.py | 223 ++++++++++++++++ .../micronic/code_reader/micronic_tests.py | 25 ++ .../code_reader/rack_reading_backend.py | 26 +- 6 files changed, 611 insertions(+), 52 deletions(-) create mode 100644 pylabrobot/hamilton/liquid_handlers/star/star.py diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 700ef14afcb..5f46dd6be3f 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -3,12 +3,25 @@ API === +Core +---- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + device.Device + + Subpackages ----------- .. toctree:: :maxdepth: 1 + pylabrobot.capabilities + pylabrobot.arms pylabrobot.config pylabrobot.resources pylabrobot.utils @@ -21,19 +34,16 @@ Manufacturers pylabrobot.agilent pylabrobot.azenta - pylabrobot.big_bear + pylabrobot.bmg_labtech pylabrobot.brooks pylabrobot.byonoy - pylabrobot.cole_parmer - pylabrobot.curiox pylabrobot.hamilton - pylabrobot.high_res pylabrobot.inheco - pylabrobot.kbioscience - pylabrobot.kbiosystems + pylabrobot.liconic pylabrobot.mettler_toledo pylabrobot.micronic pylabrobot.molecular_devices + pylabrobot.opentrons pylabrobot.qinstruments - pylabrobot.sartorius + pylabrobot.tecan pylabrobot.thermo_fisher diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index f3f798667e2..3f1ad03ea17 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -7,11 +7,12 @@ :caption: Getting started :hidden: -getting-started/installation -getting-started/rpi -getting-started/units +_getting-started/installation +How PLR Works <_getting-started/plr-architecture> +_getting-started/rpi ``` + ```{toctree} :maxdepth: 1 :caption: Machines @@ -20,6 +21,8 @@ getting-started/units machines definitions 00_liquid-handling/_liquid-handling +01_material-handling/_material-handling +02_analytical/_analytical ``` ```{toctree} @@ -29,21 +32,17 @@ definitions agilent/index azenta/index -big_bear/index +bmg_labtech/index brooks/index byonoy/index -cole_parmer/index -curiox/index hamilton/index -high_res/index inheco/index -kbioscience/index -kbiosystems/index +liconic/index mettler_toledo/index molecular_devices/index micronic/index +opentrons/index qinstruments/index -sartorius/index thermo_fisher/index ufactory/index ``` @@ -55,7 +54,6 @@ ufactory/index machine-agnostic-features/using-the-visualizer machine-agnostic-features/using-trackers -machine-agnostic-features/event-bus machine-agnostic-features/writing-robot-agnostic-protocols machine-agnostic-features/tip-spot-generators machine-agnostic-features/logging-and-validation/logging-and-validation @@ -63,6 +61,14 @@ machine-agnostic-features/error-handling-general machine-agnostic-features/sila-discovery ``` +```{toctree} +:maxdepth: 1 +:caption: Capabilities +:hidden: + +capabilities/index +``` + ```{toctree} :maxdepth: 1 :caption: Configuration @@ -82,4 +88,95 @@ The guide is divided into three parts: - **Machine-Agnostic Features** – Discover powerful tools that work across all devices, like the visualizer, trackers, reusable protocol patterns, validation tools, and error handling. -Check out [the list of supported machines](/user_guide/machines.md). +Check out [the list of supported machines](/user_guide/machines). + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Machines
├── Liquid Handling
│ ├── Pipetting Robots
│ ├── Plate Washers
│ └── Reagent Dispensers
├── ⚙️ Material Handling
│ ├── Transport Systems
│ │ ├── Conveyors
│ │ ├── Robotic Arms
│ │ └── Smart Storage (e.g. carousels)
│ ├── Consumable Manipulation
│ │ ├── Cappers & Decappers
│ │ └── Sealers & Peelers
│ └── Identification
│ └── Barcode Labellers And Readers
├── Environmental Control
│ ├── Temperature And Motion Control
│ │ ├── Automated Freezers/Fridges
│ │ ├── Automated Incubators
│ │ ├── Heated Cooled Blocks
│ │ ├── Incubated Shakers
│ │ ├── Shakers
│ │ ├── Thermal Shakers
│ │ └── Thermocyclers
│ └── Airflow & Contamination Control
│ ├── Air Circulation Fans
│ ├── HEPA Filtration Modules
│ └── UV-C Decontamination Units
├── Sample Preparation And Processing
│ ├── Automated Centrifuges
│ ├── Chromatography Systems
│ ├── Colony Pickers
│ └── Tissue Homogenizers
└── 🔬 Analytical And Detection
├── Balances / Scales
├── Optical Detection
│ ├── Automated Microscopes
│ ├── Flow Cytometers
│ ├── Plate Readers
│ └── Spectrophotometers
└── pH Meters
+ +
diff --git a/docs/user_guide/machines.md b/docs/user_guide/machines.md index 863413bd36f..db5a5fbcebb 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -1,44 +1,226 @@ # Supported Machines -Every machine PyLabRobot supports, and how complete each driver is. Some are still work in -progress (WIP) — if you have one of those, or a machine that is not listed at all, get in touch on -the [forum](https://discuss.pylabrobot.org). - -```{device-table} +```{raw} html + ``` -An asterisk after a device name marks a note about the models that entry covers; hover it to read -the note. -## Reading the table +## Liquid Handling + +### Liquid Handling Workstations + +| Manufacturer | Machine | Features | PLR-Support | Links | +|--------------|---------|----------|-------------|--------| +| Hamilton | STAR(let) | arm | Full | [PLR](00_liquid-handling/hamilton-star/_hamilton-star.rst) / [OEM](https://www.hamiltoncompany.com/microlab-star) | +| Hamilton | Vantage | arm | Mostly | [PLR](00_liquid-handling/hamilton-vantage/_hamilton-vantage.rst) / [OEM](https://www.hamiltoncompany.com/microlab-vantage) | +| Hamilton | Prep | | WIP | [OEM](https://www.hamiltoncompany.com/microlab-prep) | +| Hamilton | Nimbus | arm | WIP | [OEM](https://www.hamiltoncompany.com/microlab-nimbus) | +| Tecan | EVO | arm | Basic | [PLR](00_liquid-handling/tecan-evo/_tecan-evo.rst) / [OEM](https://lifesciences.tecan.com/freedom-evo-platform) | +| Opentrons | OT-2 | | Mostly | [PLR](00_liquid-handling/opentrons/ot2/hello-world.ipynb) / [OEM](https://opentrons.com/products/ot-2-robot) | + +### Pumps + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Cole Parmer | Masterflex L/S 07522-20 07522-30 07551-20 07551-30 07575-30 07575-40 | Full | [PLR](00_liquid-handling/pumps/cole-parmer-masterflex.md) / [OEM](https://www.masterflex.nl/assets/uploads/2017/09/07551-xx.pdf) | +| Agrowtek | Pump Array | Full | [OEM](https://www.agrowtek.com/index.php/products/dosing_systems/dosing-pumps/agrowdose-adi-digital-persitaltic-dosing-pumps-detail) | + +### Plate Washers + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Agilent (BioTek) | EL406 | Mostly | [PLR](00_liquid-handling/plate-washing/biotek-el406.ipynb) / [OEM](https://www.agilent.com/en/product/microplate-instrumentation/microplate-washers-dispensers/biotek-el406-washer-dispenser-795212) | + +--- + +## Material Handling + +### Arms + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Brooks Automation | PreciseFlex PF400 | Full | [PLR](01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb) / [OEM](https://www.brooks.com/laboratory-automation/collaborative-robots/preciseflex-400/) | +| Brooks Automation | PreciseFlex PF3400 | Full | [PLR](01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb) / [OEM](https://www.brooks.com/laboratory-automation/collaborative-robots/preciseflex-400/) | +| PAA | KX2 | WIP | | + +### Centrifuges + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Agilent | VSpin | Mostly | [PLR](01_material-handling/centrifuge/agilent_vspin.ipynb) / [OEM](https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge) | +| Agilent | VSpin Access2 Loader | Full | [PLR](01_material-handling/centrifuge/agilent_vspin.ipynb#loader) / [OEM](https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge) | + +### Fans + +| Manufacturer | Machine | Features | PLR-Support | Links | +|--------------|---------|----------|-------------|--------| +| Hamilton | HEPA Fan | air filtration | Full | [PLR](01_material-handling/fans/fans.md) | + +### Heater Shakers + +| Manufacturer | Machine | Features | PLR-Support | Links | +|--------------|---------|----------|-------------|--------| +| Inheco | Thermoshake RM | heating | Full | [PLR](01_material-handling/heating_shaking/inheco.ipynb) / [OEM](https://www.inheco.com/thermoshake-classic.html) | +| Inheco | Thermoshake | heatingactive cooling | Full | [PLR](01_material-handling/heating_shaking/inheco.ipynb) / [OEM](https://www.inheco.com/thermoshake.html) | +| Inheco | Thermoshake AC | heatingactive cooling | Full | [PLR](01_material-handling/heating_shaking/inheco.ipynb) / [OEM](https://www.inheco.com/thermoshake-ac.html) | +| Opentrons | Thermoshake | heating | Full | [OEM](https://opentrons.com/products/heater-shaker-module) | +| Hamilton | Heater Shaker | heating | Full | [PLR](01_material-handling/heating_shaking/hamilton.ipynb) / [OEM](https://www.hamiltoncompany.com/temperature-control/hamilton-heater-shaker) | +| QInstruments | BioShake | heatingactive cooling | Full | [PLR](01_material-handling/heating_shaking/qinstruments.ipynb) / [OEM](https://www.qinstruments.com/automation/) | + +### Storage + +| Manufacturer | Machine | Features | PLR-Support | Links | +|--------------|---------|----------|-------------|--------| +| Thermo Fisher Scientific | Cytomat 6000 | heating | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / [OEM](https://assets.thermofisher.com/TFS-Assets/CMD/brochures/br-90468-cytomat-2-c-lin-br90468-en.pdf) | +| Thermo Fisher Scientific | Cytomat 6002 | heating | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / [OEM](https://www.thermofisher.com/order/catalog/product/50075279) | +| Thermo Fisher Scientific | Cytomat 2 C_50 | heating | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / OEM? | +| Thermo Fisher Scientific | Cytomat 2 C425 | heatingactive cooling | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / [OEM](https://www.thermofisher.com/order/catalog/product/51033032) | +| Thermo Fisher Scientific | Cytomat 2 C450_SHAKE | heatingshaking | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / [OEM](https://www.thermofisher.com/order/catalog/product/51033035) | +| Thermo Fisher Scientific | Cytomat 5C | heating | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / [OEM](https://www.thermofisher.com/order/catalog/product/51031526) | +| Thermo/Liconic | Heraeus Cytomat | heating | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / OEM? | +| Inheco | Incubator Shaker (MTP/DWP) | heatingshaking | Mostly | [OEM](https://www.inheco.com/incubator-shaker.html) | +| Inheco | SCILA | heatingshaking | Mostly | [OEM](https://www.inheco.com/scila.html) | + +### Peelers + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Azenta Life Sciences | XPeel | Full | [OEM](https://www.azenta.com/products/automated-plate-seal-remover-formerly-xpeel) | + +### Sealers + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Azenta Life Sciences | a4S | Full | [PLR](01_material-handling/sealers/a4s.ipynb) / [OEM](https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s) | + +### Thermocyclers + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Opentrons | Thermocycler | Full | [OEM](https://opentrons.com/products/thermocycler-module-1) | +| Thermo Fisher Scientific | ATC | Full | [OEM](https://www.thermofisher.com/us/en/home/life-science/pcr/thermal-cyclers-realtime-instruments/thermal-cyclers/automated-thermal-cycler-atc.html) | +| Thermo Fisher Scientific | ProFlex | Full | [OEM](https://www.thermofisher.com/us/en/home/life-science/pcr/thermal-cyclers-realtime-instruments/thermal-cyclers/proflex-pcr-system.html) | +| Inheco | ODTC | WIP | [OEM](https://www.inheco.com/odtc.html) | + +### Temperature Controllers + +| Manufacturer | Machine | Features | PLR-Support | Links | +|--------------|---------|----------|-------------|--------| +| Opentrons | Temperature Module | heatingactive cooling | Mostly | [PLR](01_material-handling/temperature-controllers/ot-temperature-controller.ipynb) / [OEM](https://opentrons.com/products/temperature-module-gen2) | +| Inheco | CPAC | heatingactive cooling | Full | [OEM](https://www.inheco.com/cpac.html) | + +### Tilting + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Hamilton | Tilt Module | Full | [PLR](01_material-handling/tilting.md) / [OEM](https://www.hamiltoncompany.com/other-robotics/188061) | + +--- + +## Analytical Machines + +### Plate Readers + +| Manufacturer | Machine | Features | PLR-Support | Links | +|--------------|---------|----------|-------------|--------| +| BMG Labtech | CLARIOstar (Plus) | absorbancefluorescenceluminescence | Full | [PLR](02_analytical/plate-reading/bmg-clariostar.ipynb) / [OEM](https://www.bmglabtech.com/en/clariostar-plus/) | +| Agilent (BioTek) | Cytation 1 | absorbancefluorescenceluminescencemicroscopy | Full | [PLR](02_analytical/plate-reading/cytation.ipynb) / [OEM](https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-1-cell-imaging-multimode-reader-1623200) | +| Agilent (BioTek) | Cytation 5 | absorbancefluorescenceluminescencemicroscopy | Full | [PLR](02_analytical/plate-reading/cytation.ipynb) / [OEM](https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-5-cell-imaging-multimode-reader-1623202) | +| Agilent (BioTek) | Synergy H1 | absorbancefluorescenceluminescence | Full | [PLR](02_analytical/plate-reading/synergyh1.ipynb) / [OEM](https://www.agilent.com/en/product/microplate-instrumentation/microplate-readers/multimode-microplate-readers/biotek-synergy-h1-multimode-reader-1623193) | +| Byonoy | Absorbance 96 Automate | absorbance | Full | [PLR](02_analytical/plate-reading/byonoy/absorbance.ipynb) / [OEM](https://byonoy.com/absorbance-96-automate/) | +| Byonoy | Luminescence 96 | luminescence | Full | [PLR](02_analytical/plate-reading/byonoy/luminescence.ipynb) / [OEM](https://byonoy.com/luminescence-96/) | +| Byonoy | Luminescence 96 Automate | luminescence | Full | [PLR](02_analytical/plate-reading/byonoy/luminescence.ipynb) / [OEM](https://byonoy.com/luminescence-96-automate/) | +| Molecular Devices | SpectraMax M5e | absorbancefluorescence time-resolved fluorescencefluorescence polarization | Full | [OEM](https://www.moleculardevices.com/products/microplate-readers/multi-mode-readers/spectramax-m-series-readers) | +| Molecular Devices | SpectraMax 384plus | absorbance | Full | [OEM](https://www.moleculardevices.com/products/microplate-readers/absorbance-readers/spectramax-abs-plate-readers) | +| Molecular Devices | ImageXpress Pico | microscopy | Basics | [PLR](02_analytical/plate-reading/pico.ipynb) / [OEM](https://www.moleculardevices.com/products/cellular-imaging-systems/high-content-imaging/imagexpress-pico) | +| Tecan | Infinite 200 PRO | absorbancefluorescenceluminescence | Mostly | [PLR](tecan/infinite/hello-world.ipynb) / [OEM](https://lifesciences.tecan.com/infinite-200-pro) | + + +### Flow Cytometers + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Beckman Coulter | CytoFLEX S | WIP | [OEM](https://www.beckman.com/flow-cytometry/research-flow-cytometers/cytoflex-s) | + +### qPCR Machines + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Thermo Fisher Scientific | QuantStudio 5 | WIP | [OEM](https://www.thermofisher.com/order/catalog/product/A34322) | + +### Scales + +| Manufacturer | Machine | PLR-Support | Links | +|--------------|---------|-------------|--------| +| Mettler Toledo | WXS205SDU | Full | [PLR](02_analytical/scales/mettler-toledo-WXS205SDU.ipynb) / [OEM](https://www.mt.com/us/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html) | + +### Rack Readers + +| Manufacturer | Machine | Features | PLR-Support | Links | +|--------------|---------|----------|-------------|--------| +| Micronic | Direct local scanner + serial control | rack reading | Basics | [PLR](https://docs.pylabrobot.org/user_guide/micronic/index.html) / [OEM](https://www.micronic.com/products/code-reader/) | -Classifying lab automation equipment is hard. Many machines have overlapping capabilities (the -Thermo Fisher Cytomat 2 C470 is a fridge, heated chamber, oven, plate hotel *and* shaker in one), -different user groups refer to the same machine by whichever capability they happen to use, naming -sits somewhere between user intuition and historical legacy (a thermocycler is really just a fast -heater/cooler), and there is no widely accepted standard. +--- -PyLabRobot does not solve that. **Type** is the one label that names what a machine is, and -**capabilities** are the core functions it provides — a machine typically has several. Both are -filterable, so a machine you think of as a shaker is still findable by someone who thinks of it as -a heater. +## Understanding the Tables -**Support** is how complete the PyLabRobot integration is: +Classifying lab automation equipment can be challenging. +There are many reasons for this, including (but not limited to): +- many machines have overlapping capabilities (the TFS Cytomat 2 C470 can be a fridge, heated chamber, oven, smart storage/plate hotel and shaker all in one machine!), +- different user groups require varying levels of software integration but tend to only refer to the capabilities they use in that moment (e.g. what is a shaker to one group might be a heater to another), +- balancing naming/classification based on user intuition/historic legacy and first principles, (e.g. a thermocycler is just a speedy heater/cooler but scientists are used to the term thermocycler) +- there is no widely accepted naming standard. -- **WIP** — work in progress. -- **Basic** — core functionality is available, integrated into `pylabrobot:main`. -- **Mostly** — most capabilities are available, but some known commands are still missing. -- **Full** — comprehensive support (≥90% of capabilities), with documentation. +PyLabRobot does not solve these human classification issues. +But to provide *some* structure PyLabRobot classifies machines into three purposefully broad categories: -The `v0`/`v1` marker next to the support level is the API generation a machine's driver belongs to. -`v0` drivers live under `pylabrobot.legacy` and are being migrated. +- **Liquid handling**: Machines directly manipulating liquids. +- **Material handling**: Machines handling materials other than liquids. +- **Analytical**: Machines primarily responsible for performing measurements. -PyLabRobot aims to expose every hardware/firmware capability of integrated equipment, including -capabilities the OEM software does not surface. That lets you *choose* which functions you need. +**Table Columns Explained:** -## Adding or updating a machine +- **Features**: Core capabilities provided by the machine. +- **PLR-Support**: Indicates PyLabRobot integration status: + - **WIP**: Work in progress. + - **Basics**: Core functionalities available. + Code integrated into `pylabrobot:main`. Documentation pages in `docs.pylabrobot.org`. + - **Mostly**: Most capabilities available, but some known commands still missing. + - **Full**: Comprehensive capabilities (≥90%) fully supported, extensive documentation available. -The table is generated from `docs/_static/devices.json`. Adding a machine means adding one JSON -object — there is no table markup to keep in sync. See -{doc}`/contributor_guide/device-registry` for the schema, and for how to put a device card on a -machine's own documentation page. +Note: PyLabRobot aims to provide access to all hardware-firmware capabilities available on integrated equipment, even beyond OEM software. +This allows users to *choose* the machine functionalities they require. diff --git a/pylabrobot/hamilton/liquid_handlers/star/star.py b/pylabrobot/hamilton/liquid_handlers/star/star.py new file mode 100644 index 00000000000..4b106de1375 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/star.py @@ -0,0 +1,223 @@ +"""STAR device: wires STARDriver backends to PIP/Head96/iSWAP capability frontends.""" + +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional + +from pylabrobot.capabilities.arms.arm import FixedAxisGripperArm +from pylabrobot.capabilities.arms.orientable_arm import OrientableGripperArm +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.head96 import Head96 +from pylabrobot.capabilities.liquid_handling.pip import PIP +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate +from pylabrobot.resources.hamilton import HamiltonDeck, STARDeck, STARLetDeck +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers + +from .chatterbox import STARChatterboxDriver +from .core import CoreGripper +from .driver import STARDriver + + +class _HamiltonSTAR(Device): + """Base class for Hamilton STAR/STARLet liquid handlers. + + Wires capability frontends (PIP, Head96, iSWAP) to the STARDriver's backends + after hardware discovery during setup(). + """ + + def __init__(self, deck: HamiltonDeck, chatterbox: bool = False): + driver = STARChatterboxDriver(deck=deck) if chatterbox else STARDriver(deck=deck) + super().__init__(driver=driver) + self.driver: STARDriver = driver + self.deck = deck + self.pip: PIP # set in setup() + self.head96: Optional[Head96] = None # set in setup() if installed + self.iswap: Optional[OrientableGripperArm] = None # set in setup() if installed + + async def setup( + self, + backend_params: Optional[BackendParams] = None, + *, + skip_pip: bool = False, + skip_iswap: bool = False, + skip_core96_head: bool = False, + skip_autoload: bool = False, + ): + """Set up the STAR. Optional skip flags mirror the legacy backend. + + Args: + backend_params: forwarded to the driver setup. + skip_pip: if True, do not initialize the PIP capability. + skip_iswap: if True, do not initialize the iSWAP capability. + skip_core96_head: if True, do not initialize the 96-head capability. + skip_autoload: if True, do not initialize the autoload. + """ + await self.driver.setup(backend_params=backend_params) + + # PIP is always present. + self.pip = PIP(backend=self.driver.pip, deck=self.deck) + self._capabilities = [self.pip] + + # Head96 only if the hardware has a 96-head installed and the user did not opt out. + if self.driver.head96 is not None and not skip_core96_head: + self.head96 = Head96(backend=self.driver.head96, deck=self.deck) + self._capabilities.append(self.head96) + + # iSWAP only if installed and the user did not opt out. + if self.driver.iswap is not None and not skip_iswap: + self.iswap = OrientableGripperArm(backend=self.driver.iswap, reference_resource=self.deck) + self._capabilities.append(self.iswap) + + # Matches legacy: autoload runs in parallel with arm modules. + # Arm modules run sequentially (pip → iswap → head96) because they share the left x-arm. + async def setup_arm_modules(): + if not skip_pip: + await self.pip._on_setup() + if self.iswap is not None and not skip_iswap: + await self.iswap._on_setup() + if self.head96 is not None and not skip_core96_head: + await self.head96._on_setup() + + async def setup_autoload(): + if self.driver.autoload is not None and not skip_autoload: + await self.driver.autoload._on_setup() + + await asyncio.gather(setup_autoload(), setup_arm_modules()) + self._setup_finished = True + + async def stop(self): + for cap in reversed(self._capabilities): + await cap._on_stop() + await self.driver.stop() + self._setup_finished = False + self.head96 = None + self.iswap = None + + # -- traversal-height convenience aliases ---------------------------------- + # + # Mirror the top-level API from the legacy STARBackend: `set_minimum_channel_ + # traversal_height` updates the PIP backend's traversal height, and + # `set_minimum_iswap_traversal_height` / `iswap_minimum_traversal_height` + # update / context-scope the iSWAP backend's traversal height. + + def set_minimum_channel_traversal_height(self, traversal_height: float) -> None: + """Set the minimum traversal height (mm) for all PIP-channel operations.""" + if not 0 < traversal_height < 285: + raise ValueError("Traversal height must be between 0 and 285 mm") + pip = getattr(self.driver, "pip", None) + if pip is None: + raise RuntimeError("STAR has not been set up yet; call setup() first.") + pip.traversal_height = traversal_height + if self.driver.head96 is not None: + self.driver.head96.traversal_height = traversal_height + + def set_minimum_iswap_traversal_height(self, traversal_height: float) -> None: + """Set the minimum traversal height (mm) for iSWAP plate movements.""" + if self.driver.iswap is None: + raise RuntimeError( + "iSWAP is not installed or STAR has not been set up; cannot set its traversal height." + ) + if not 0 < traversal_height < 285: + raise ValueError("Traversal height must be between 0 and 285 mm") + self.driver.iswap.traversal_height = traversal_height + + def set_minimum_traversal_height(self, traversal_height: float) -> None: + """Deprecated: use ``set_minimum_channel_traversal_height`` / + ``set_minimum_iswap_traversal_height`` instead.""" + raise NotImplementedError( + "set_minimum_traversal_height is deprecated. Use " + "set_minimum_channel_traversal_height or set_minimum_iswap_traversal_height instead." + ) + + @asynccontextmanager + async def iswap_minimum_traversal_height(self, traversal_height: float): + """Context-manage the iSWAP traversal height for the duration of the block.""" + if self.driver.iswap is None: + raise RuntimeError("iSWAP is not installed; cannot set its traversal height.") + with self.driver.iswap.use_traversal_height(traversal_height): + yield + + # -- CoRe grippers --------------------------------------------------------- + + @asynccontextmanager + async def core_grippers( + self, + front_channel: int = 7, + front_offset: Coordinate = Coordinate.zero(), + back_offset: Coordinate = Coordinate.zero(), + traversal_height: float = 280.0, + ) -> AsyncIterator[FixedAxisGripperArm]: + """Context manager that picks up CoRe gripper tools on enter and returns them on exit. + + Usage:: + + async with star.core_grippers(front_channel=7) as arm: + await arm.move_resource(plate, destination) + """ + + # Park iSWAP first if it's out — the arms share the X drive. + if self.iswap is not None and not self.iswap.backend.parked: # type: ignore[attr-defined] + await self.iswap.backend.park() + + core_grippers_resource = self.deck.get_resource("core_grippers") + if not isinstance(core_grippers_resource, HamiltonCoreGrippers): + raise TypeError("core_grippers resource must be HamiltonCoreGrippers") + + back_channel = front_channel - 1 + loc = core_grippers_resource.get_absolute_location() + xs = loc.x + front_offset.x + back_y = int(loc.y + core_grippers_resource.back_channel_y_center + back_offset.y) + front_y = int(loc.y + core_grippers_resource.front_channel_y_center + front_offset.y) + z_offset = front_offset.z + + if back_y <= front_y: + raise ValueError( + f"back_channel_y ({back_y}) must be > front_channel_y ({front_y}) for CoRe gripper pickup" + ) + ext = self.driver.extended_conf + if front_y < ext.left_arm_min_y_position: + raise ValueError( + f"front_channel_y ({front_y}) is below the left arm's minimum y " + f"position ({ext.left_arm_min_y_position})" + ) + + await self.driver.pick_up_core_gripper_tools( + x_position=xs, + back_channel_y=back_y, + front_channel_y=front_y, + back_channel=back_channel, + front_channel=front_channel, + begin_z=235.0 + z_offset, + end_z=225.0 + z_offset, + traversal_height=traversal_height, + ) + + backend = CoreGripper(driver=self.driver) + arm = FixedAxisGripperArm(backend=backend, reference_resource=self.deck, grip_axis="y") + + try: + yield arm + finally: + await self.driver.return_core_gripper_tools( + x_position=xs, + back_channel_y=back_y, + front_channel_y=front_y, + begin_z=215.0 + z_offset, + end_z=205.0 + z_offset, + traversal_height=traversal_height, + ) + + +class STAR(_HamiltonSTAR): + """Hamilton STAR liquid handler.""" + + def __init__(self, chatterbox: bool = False): + super().__init__(deck=STARDeck(), chatterbox=chatterbox) + + +class STARLet(_HamiltonSTAR): + """Hamilton STARLet liquid handler.""" + + def __init__(self, chatterbox: bool = False): + super().__init__(deck=STARLetDeck(), chatterbox=chatterbox) diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 7307a54f6d8..f98c11e0ff3 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -270,6 +270,31 @@ async def slow(rack): with self.assertRaises(TimeoutError): await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) + async def test_timeout_keeps_scan_lock_until_blocking_scan_finishes(self): + driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + backend = MicronicCodeReaderRackReadingBackend(driver=driver) + + def slow_blocking_scan(rack_id, expected_well_count): + del rack_id, expected_well_count + import time + + time.sleep(0.05) + return MagicMock() + + with ( + patch.object(driver, "read_barcode", AsyncMock(return_value="9500017722")), + patch.object(backend, "_scan_rack_blocking", side_effect=slow_blocking_scan), + ): + with self.assertRaises(TimeoutError): + await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) + with self.assertRaises(MicronicError): + await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) + + import asyncio + + await asyncio.sleep(0.08) + self.assertFalse(backend._scan_lock.locked()) + async def test_scan_rack_propagates_micronic_error(self): driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") backend = MicronicCodeReaderRackReadingBackend(driver=driver) diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py index 696bf01191d..6c02d453b94 100644 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ b/pylabrobot/micronic/code_reader/rack_reading_backend.py @@ -43,10 +43,32 @@ async def _scan_rack(self, rack: TubeRack) -> RackScanResult: self._validate_rack(rack) if self._scan_lock.locked(): raise MicronicError("Micronic rack scan is already in progress.") - async with self._scan_lock: + await self._scan_lock.acquire() + release_lock = True + try: rack_id = await self.driver.read_barcode() loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, self._scan_rack_blocking, rack_id, rack.num_items) + scan_future = loop.run_in_executor(None, self._scan_rack_blocking, rack_id, rack.num_items) + try: + return await asyncio.shield(scan_future) + except asyncio.CancelledError: + release_lock = False + scan_future.add_done_callback(self._finish_cancelled_scan) + raise + finally: + if release_lock: + self._release_scan_lock() + + def _finish_cancelled_scan(self, future) -> None: + try: + future.exception() + except asyncio.CancelledError: + pass + self._release_scan_lock() + + def _release_scan_lock(self) -> None: + if self._scan_lock.locked(): + self._scan_lock.release() def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackScanResult: image_path = self.driver.acquire_image() From c837ef42c7d07c528e4f0804f41225d7cbb60805 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 17 Aug 2026 15:22:55 -0700 Subject: [PATCH 48/54] remove unused files --- docs/api/pylabrobot.capabilities.rst | 388 ------------------ docs/user_guide/capabilities/index.md | 68 --- .../capabilities/rack-reading.ipynb | 107 ----- pylabrobot/capabilities/__init__.py | 7 - .../capabilities/rack_reading/__init__.py | 4 - .../capabilities/rack_reading/backend.py | 20 - .../capabilities/rack_reading/chatterbox.py | 32 -- .../capabilities/rack_reading/rack_reader.py | 32 -- .../rack_reading/rack_reader_tests.py | 66 --- .../capabilities/rack_reading/standard.py | 25 -- pylabrobot/hamilton/star/__init__.py | 0 pylabrobot/hamilton/star/lock.py | 76 ---- 12 files changed, 825 deletions(-) delete mode 100644 docs/api/pylabrobot.capabilities.rst delete mode 100644 docs/user_guide/capabilities/index.md delete mode 100644 docs/user_guide/capabilities/rack-reading.ipynb delete mode 100644 pylabrobot/capabilities/__init__.py delete mode 100644 pylabrobot/capabilities/rack_reading/__init__.py delete mode 100644 pylabrobot/capabilities/rack_reading/backend.py delete mode 100644 pylabrobot/capabilities/rack_reading/chatterbox.py delete mode 100644 pylabrobot/capabilities/rack_reading/rack_reader.py delete mode 100644 pylabrobot/capabilities/rack_reading/rack_reader_tests.py delete mode 100644 pylabrobot/capabilities/rack_reading/standard.py delete mode 100644 pylabrobot/hamilton/star/__init__.py delete mode 100644 pylabrobot/hamilton/star/lock.py diff --git a/docs/api/pylabrobot.capabilities.rst b/docs/api/pylabrobot.capabilities.rst deleted file mode 100644 index 99be84cc2da..00000000000 --- a/docs/api/pylabrobot.capabilities.rst +++ /dev/null @@ -1,388 +0,0 @@ -.. currentmodule:: pylabrobot.capabilities - -pylabrobot.capabilities package -=============================== - -Base classes for the capability system. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - capability.Capability - capability.CapabilityBackend - capability.BackendParams - - -Temperature Control -------------------- - -.. currentmodule:: pylabrobot.capabilities.temperature_controlling.temperature_controller - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - TemperatureController - TemperatureControllerBackend - - -Shaking -------- - -.. currentmodule:: pylabrobot.capabilities.shaking.shaking - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Shaker - ShakerBackend - - -Fan Control ------------ - -.. currentmodule:: pylabrobot.capabilities.fan_control.fan_control - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Fan - FanBackend - - -Humidity Control ----------------- - -.. currentmodule:: pylabrobot.capabilities.humidity_controlling.humidity_controller - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - HumidityController - HumidityControllerBackend - - -Centrifuging ------------- - -.. currentmodule:: pylabrobot.capabilities.centrifuging.centrifuging - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Centrifuge - CentrifugeBackend - - -Sealing -------- - -.. currentmodule:: pylabrobot.capabilities.sealing.sealing - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Sealer - SealerBackend - - -Peeling -------- - -.. currentmodule:: pylabrobot.capabilities.peeling.peeling - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Peeler - PeelerBackend - - -Loading Tray ------------- - -.. currentmodule:: pylabrobot.capabilities.loading_tray.loading_tray - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - LoadingTray - -.. currentmodule:: pylabrobot.capabilities.loading_tray.backend - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - LoadingTrayBackend - - -Tilting -------- - -.. currentmodule:: pylabrobot.capabilities.tilting.tilting - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Tilter - TilterBackend - - -Pumping -------- - -.. currentmodule:: pylabrobot.capabilities.pumping.pumping - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Pump - PumpBackend - PumpCalibration - - -Weighing --------- - -.. currentmodule:: pylabrobot.capabilities.weighing.weighing - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Scale - ScaleBackend - - -Barcode Scanning ----------------- - -.. currentmodule:: pylabrobot.capabilities.barcode_scanning.barcode_scanning - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - BarcodeScanner - BarcodeScannerBackend - - -Rack Reading ------------- - -.. currentmodule:: pylabrobot.capabilities.rack_reading.rack_reader - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - RackReader - -.. currentmodule:: pylabrobot.capabilities.rack_reading.backend - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - RackReaderBackend - -.. currentmodule:: pylabrobot.capabilities.rack_reading.standard - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - RackScanEntry - RackScanResult - - -Microscopy ----------- - -.. currentmodule:: pylabrobot.capabilities.microscopy.microscopy - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Microscopy - MicroscopyBackend - - -Automated Retrieval -------------------- - -.. currentmodule:: pylabrobot.capabilities.automated_retrieval.automated_retrieval - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - AutomatedRetrieval - AutomatedRetrievalBackend - - -Plate Reading - Absorbance --------------------------- - -.. currentmodule:: pylabrobot.capabilities.plate_reading.absorbance.absorbance - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Absorbance - AbsorbanceBackend - - -Plate Reading - Fluorescence ------------------------------ - -.. currentmodule:: pylabrobot.capabilities.plate_reading.fluorescence.fluorescence - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Fluorescence - FluorescenceBackend - - -Plate Reading - Luminescence ------------------------------ - -.. currentmodule:: pylabrobot.capabilities.plate_reading.luminescence.luminescence - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Luminescence - LuminescenceBackend - - -Devices -------- - -.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.multidrop_combi - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - MultidropCombi - -.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.driver - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - MultidropCombiDriver - - -Bulk Dispensing - Peristaltic ------------------------------ - -.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - PeristalticDispensing8 - -.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.backend - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - PeristalticDispensingBackend8 - - -Bulk Dispensing - Syringe -------------------------- - -.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.syringe - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - SyringeDispensing8 - -.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.backend - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - SyringeDispensingBackend8 - - -Liquid Handling - PIP (Independent Channels) --------------------------------------------- - -.. currentmodule:: pylabrobot.capabilities.liquid_handling.pip - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - PIP - PIPBackend - - -Liquid Handling - Head96 (96-Channel Head) ------------------------------------------- - -.. currentmodule:: pylabrobot.capabilities.liquid_handling.head96 - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - Head96 - Head96Backend diff --git a/docs/user_guide/capabilities/index.md b/docs/user_guide/capabilities/index.md deleted file mode 100644 index fb4e0f7ddad..00000000000 --- a/docs/user_guide/capabilities/index.md +++ /dev/null @@ -1,68 +0,0 @@ -# Capabilities - -Capabilities are the building blocks of device functionality in PyLabRobot. Each capability defines a standard interface for a specific type of lab operation (e.g. temperature control, shaking, plate reading), decoupled from any particular hardware. - -A single device can expose multiple capabilities -- and capabilities can be optional (`None` if the hardware doesn't support it) or even duplicated (e.g. a device with two independent arms). For example, a heater-shaker exposes both a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` and a {class}`~pylabrobot.capabilities.shaking.shaking.Shaker`. - -## Architecture - -``` -Device - ├── driver (hardware communication) - └── capabilities - ├── TemperatureController (backend) - ├── Shaker (backend) - └── ... -``` - -Each capability has two layers: a **frontend** and a **backend**. - -### Frontend (the capability class) - -The frontend is what you interact with as a user. It provides: - -- A **stable, hardware-agnostic API** -- the same `set_temperature(37.0)` call works regardless of whether you're using an Inheco ThermoShake or a Hamilton Heater Cooler. -- **Validation** -- checking that arguments are in range, that the device is ready, that preconditions are met (e.g. you can't `wait_for_temperature` without first setting a target). -- **State tracking** -- keeping track of which tips are mounted, what the current tilt angle is, whether the door is open, etc. -- **Convenience methods** -- higher-level operations like `stamp` (aspirate + dispense) or `transfer` (one-to-many) built on top of the primitive backend calls. - -The frontend is the same across all hardware that supports the capability. - -### Backend (the hardware-specific implementation) - -The backend is what talks to the actual hardware. Each capability defines an abstract backend class (e.g. {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureControllerBackend`, {class}`~pylabrobot.capabilities.shaking.shaking.ShakerBackend`) that specifies the methods a hardware driver must implement. - -Backend methods are lower-level and closer to the wire protocol. For example, where the frontend {class}`~pylabrobot.capabilities.shaking.shaking.Shaker`.`shake(speed, duration)` handles timing and auto-stop, the backend only needs to implement `start_shaking(speed)` and `stop_shaking()`. - -To add support for a new piece of hardware, you implement the backend interface for the capabilities it supports. The frontend takes care of the rest. - -All capability methods are `async` and require the parent device to be set up before use (enforced by the `@need_capability_ready` decorator). - -## Available capabilities - -```{toctree} -:maxdepth: 1 - -temperature-control -shaking -fan-control -humidity-control -centrifuging -sealing -peeling -tilting -loading-tray -pumping -weighing -barcode-scanning -rack-reading -microscopy -automated-retrieval -absorbance -fluorescence -luminescence -dispensing/index -pip -head96 -arms -``` diff --git a/docs/user_guide/capabilities/rack-reading.ipynb b/docs/user_guide/capabilities/rack-reading.ipynb deleted file mode 100644 index 66b0d0323ae..00000000000 --- a/docs/user_guide/capabilities/rack-reading.ipynb +++ /dev/null @@ -1,107 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Rack Reading\n", - "\n", - "The `rack_reading` capability standardizes rack-scale code readers that decode a tube rack and\n", - "return structured per-position scan results.\n", - "\n", - "Unlike one-at-a-time code reads, rack reading is job-oriented and returns the full decoded rack map.\n", - "\n", - "## Walkthrough" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "from pylabrobot.capabilities.rack_reading import RackReader\n", - "from pylabrobot.capabilities.rack_reading.chatterbox import RackReaderChatterboxBackend\n", - "from pylabrobot.resources import ResourceHolder, TubeRack, create_ordered_items_2d\n", - "\n", - "reader = RackReader(backend=RackReaderChatterboxBackend())\n", - "await reader._on_setup()" - ], - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "rack = TubeRack(\n", - " name=\"rack\",\n", - " size_x=85.0,\n", - " size_y=127.0,\n", - " size_z=20.0,\n", - " ordered_items=create_ordered_items_2d(\n", - " ResourceHolder,\n", - " num_items_x=12,\n", - " num_items_y=8,\n", - " dx=0,\n", - " dy=0,\n", - " dz=0,\n", - " item_dx=9.0,\n", - " item_dy=9.0,\n", - " size_x=9.0,\n", - " size_y=9.0,\n", - " size_z=20.0,\n", - " ),\n", - ")\n", - "\n", - "result = await reader.scan_rack(rack=rack, timeout=60.0, poll_interval=1.0)\n", - "print(result.rack_id)\n", - "print(result.entries[0].position, result.entries[0].tube_id)" - ], - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "rack_id = await reader.scan_rack_id(timeout=60.0, poll_interval=1.0)\n", - "print(rack_id)" - ], - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Public API\n", - "\n", - "- `scan_rack(rack, timeout, poll_interval)` — scan a `TubeRack` and return a `RackScanResult`. The\n", - " backend validates that the rack shape matches what the hardware supports.\n", - "- `scan_rack_id(timeout, poll_interval)` — read just the rack barcode (no per-position decoding)\n", - " and return the rack identifier.\n", - "\n", - "## Supported hardware\n", - "\n", - "```{supported-devices} rack reading\n", - "```\n", - "\n", - "## API reference\n", - "\n", - "See {class}`~pylabrobot.capabilities.rack_reading.rack_reader.RackReader` and {class}`~pylabrobot.capabilities.rack_reading.backend.RackReaderBackend`." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.12.0" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/pylabrobot/capabilities/__init__.py b/pylabrobot/capabilities/__init__.py deleted file mode 100644 index 75c3c674762..00000000000 --- a/pylabrobot/capabilities/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .capability import Capability, CapabilityBackend, need_capability_ready -from .rack_reading import ( - RackReader, - RackReaderBackend, - RackScanEntry, - RackScanResult, -) diff --git a/pylabrobot/capabilities/rack_reading/__init__.py b/pylabrobot/capabilities/rack_reading/__init__.py deleted file mode 100644 index 9ef4c9936fa..00000000000 --- a/pylabrobot/capabilities/rack_reading/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .backend import RackReaderBackend -from .chatterbox import RackReaderChatterboxBackend -from .rack_reader import RackReader -from .standard import RackScanEntry, RackScanResult diff --git a/pylabrobot/capabilities/rack_reading/backend.py b/pylabrobot/capabilities/rack_reading/backend.py deleted file mode 100644 index d8657504443..00000000000 --- a/pylabrobot/capabilities/rack_reading/backend.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from abc import ABCMeta, abstractmethod - -from pylabrobot.capabilities.capability import CapabilityBackend -from pylabrobot.resources.tube_rack import TubeRack - -from .standard import RackScanResult - - -class RackReaderBackend(CapabilityBackend, metaclass=ABCMeta): - """Abstract backend for rack readers that decode position-indexed rack contents.""" - - @abstractmethod - async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) -> RackScanResult: - """Scan ``rack`` and return its decoded contents.""" - - @abstractmethod - async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - """Read the rack barcode only and return the rack identifier.""" diff --git a/pylabrobot/capabilities/rack_reading/chatterbox.py b/pylabrobot/capabilities/rack_reading/chatterbox.py deleted file mode 100644 index f380ff667b9..00000000000 --- a/pylabrobot/capabilities/rack_reading/chatterbox.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from pylabrobot.resources.barcode import Barcode -from pylabrobot.resources.tube_rack import TubeRack - -from .backend import RackReaderBackend -from .standard import RackScanEntry, RackScanResult - - -class RackReaderChatterboxBackend(RackReaderBackend): - """Device-free rack-reading backend for tests and examples.""" - - async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) -> RackScanResult: - del rack, timeout, poll_interval - return RackScanResult( - rack_id="CHATTERBOX", - entries=[ - RackScanEntry( - position="A1", - tube_id="SIMULATED", - status="OK", - barcode=Barcode(data="SIMULATED", symbology="DataMatrix", position_on_resource="bottom"), - ), - ], - rack_barcode=Barcode( - data="CHATTERBOX", symbology="Code 128 (Subset B and C)", position_on_resource="right" - ), - ) - - async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - del timeout, poll_interval - return "CHATTERBOX" diff --git a/pylabrobot/capabilities/rack_reading/rack_reader.py b/pylabrobot/capabilities/rack_reading/rack_reader.py deleted file mode 100644 index 0241e9aec56..00000000000 --- a/pylabrobot/capabilities/rack_reading/rack_reader.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from pylabrobot.capabilities.capability import Capability, need_capability_ready -from pylabrobot.resources.tube_rack import TubeRack - -from .backend import RackReaderBackend -from .standard import RackScanResult - - -class RackReader(Capability): - """Rack-reading capability.""" - - def __init__(self, backend: RackReaderBackend): - super().__init__(backend=backend) - self.backend: RackReaderBackend = backend - - @need_capability_ready - async def scan_rack( - self, - rack: TubeRack, - timeout: float = 60.0, - poll_interval: float = 1.0, - ) -> RackScanResult: - return await self.backend.scan_rack(rack=rack, timeout=timeout, poll_interval=poll_interval) - - @need_capability_ready - async def scan_rack_id( - self, - timeout: float = 60.0, - poll_interval: float = 1.0, - ) -> str: - return await self.backend.scan_rack_id(timeout=timeout, poll_interval=poll_interval) diff --git a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py b/pylabrobot/capabilities/rack_reading/rack_reader_tests.py deleted file mode 100644 index a05ffd51ffc..00000000000 --- a/pylabrobot/capabilities/rack_reading/rack_reader_tests.py +++ /dev/null @@ -1,66 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, MagicMock - -from pylabrobot.capabilities.rack_reading.rack_reader import RackReader -from pylabrobot.capabilities.rack_reading.standard import RackScanEntry, RackScanResult -from pylabrobot.resources.barcode import Barcode -from pylabrobot.resources.tube_rack import TubeRack - -SCAN_RESULT = RackScanResult( - rack_id="5500135415", - entries=[ - RackScanEntry( - position="A1", - tube_id="7518613629", - status="OK", - barcode=Barcode(data="7518613629", symbology="DataMatrix", position_on_resource="bottom"), - ), - ], - rack_barcode=Barcode( - data="5500135415", symbology="Code 128 (Subset B and C)", position_on_resource="right" - ), -) - - -def _make_backend() -> MagicMock: - backend = MagicMock() - backend.scan_rack = AsyncMock(return_value=SCAN_RESULT) - backend.scan_rack_id = AsyncMock(return_value=SCAN_RESULT.rack_id) - backend._on_setup = AsyncMock() - backend._on_stop = AsyncMock() - return backend - - -class TestRackReader(unittest.IsolatedAsyncioTestCase): - async def test_scan_rack_delegates_to_backend(self): - backend = _make_backend() - rack = MagicMock(spec=TubeRack) - reader = RackReader(backend=backend) - await reader._on_setup() - - result = await reader.scan_rack(rack=rack, timeout=1.0, poll_interval=0.01) - - self.assertEqual(result, SCAN_RESULT) - backend.scan_rack.assert_awaited_once_with(rack=rack, timeout=1.0, poll_interval=0.01) - - async def test_scan_rack_id_delegates_to_backend(self): - backend = _make_backend() - reader = RackReader(backend=backend) - await reader._on_setup() - - rack_id = await reader.scan_rack_id(timeout=1.0, poll_interval=0.01) - - self.assertEqual(rack_id, SCAN_RESULT.rack_id) - backend.scan_rack_id.assert_awaited_once_with(timeout=1.0, poll_interval=0.01) - - async def test_requires_setup(self): - backend = _make_backend() - rack = MagicMock(spec=TubeRack) - reader = RackReader(backend=backend) - - with self.assertRaises(RuntimeError): - await reader.scan_rack(rack=rack) - - -if __name__ == "__main__": - unittest.main() diff --git a/pylabrobot/capabilities/rack_reading/standard.py b/pylabrobot/capabilities/rack_reading/standard.py deleted file mode 100644 index 021c500f379..00000000000 --- a/pylabrobot/capabilities/rack_reading/standard.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal, Optional - -from pylabrobot.resources.barcode import Barcode - - -@dataclass -class RackScanEntry: - """One decoded rack position.""" - - position: str - tube_id: Optional[str] - status: Literal["OK", "NOREAD"] - barcode: Optional[Barcode] = None - - -@dataclass -class RackScanResult: - """A decoded rack scan.""" - - rack_id: str - entries: list[RackScanEntry] - rack_barcode: Optional[Barcode] = None diff --git a/pylabrobot/hamilton/star/__init__.py b/pylabrobot/hamilton/star/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/pylabrobot/hamilton/star/lock.py b/pylabrobot/hamilton/star/lock.py deleted file mode 100644 index 9fec9ad836e..00000000000 --- a/pylabrobot/hamilton/star/lock.py +++ /dev/null @@ -1,76 +0,0 @@ -import asyncio -import logging -from contextlib import AsyncExitStack, asynccontextmanager -from typing import Optional - -logger = logging.getLogger(__name__) - - -class _FirmwareLock: - """Coordinates firmware commands across modules. - - Two layers, both handled by ``slave_module(module)`` / ``c0()`` so callers never touch the - internals: - - * A readers/writer gate between the C0 master and the slave modules. A slave-module - command blocks the C0 master while in flight; a C0 master command (``c0()``) is - *exclusive*: it waits for all slave-module commands to drain, then runs alone. So no - slave-module command ever overlaps a C0 master command. - - * A per-module mutex so at most one command per module is in flight. Different modules - still overlap — an X0 arm move can run alongside an H0 head move and Px channel commands - — but you never have two X0, two H0, etc. at once. Modules without a dedicated mutex are - gated but not per-module serialized. - - Read-only request (``R*``) and query (``Q*``) commands are not coordinated here at all — - they take no lock and run fully in parallel. - - The first slave-module command acquires the exclusive lock and the last one releases it, - so a C0 command simply takes the same lock and automatically waits the slaves out. - """ - - # Slave modules that serialize against themselves: H0 (96-head), X0 (X-drives), - # R0 (iSWAP), I0 (autoload). All Px pipetting channels (P1..PG) share one mutex. - _SERIALIZED_MODULES = ("H0", "X0", "R0", "I0") - - def __init__(self): - # Per-module mutexes: at most one in-flight command per module. - self._px_lock = asyncio.Lock() # shared by all P1..PG pipetting channels - self._module_locks = {module: asyncio.Lock() for module in self._SERIALIZED_MODULES} - - # Readers/writer gate: slave-module commands vs the exclusive C0 master. - self._slave_count = 0 - self._slave_count_lock = asyncio.Lock() - self._exclusive_lock = asyncio.Lock() - - def _module_lock(self, module: str) -> Optional[asyncio.Lock]: - """The per-module mutex for ``module``, or None if it needs no per-module serialization.""" - if module.startswith("P"): - return self._px_lock # P1..PG pipetting channels - return self._module_locks.get(module) - - @asynccontextmanager - async def slave_module(self, module: str): - """Run a slave-module command: serialize on its module mutex and block the C0 master.""" - module_lock = self._module_lock(module) - async with AsyncExitStack() as stack: - if module_lock is not None: - await stack.enter_async_context(module_lock) - # Join the slave group; first in acquires the exclusive lock, last out releases it. - async with self._slave_count_lock: - self._slave_count += 1 - if self._slave_count == 1: - await self._exclusive_lock.acquire() - try: - yield - finally: - async with self._slave_count_lock: - self._slave_count -= 1 - if self._slave_count == 0: - self._exclusive_lock.release() - - @asynccontextmanager - async def c0(self): - """Run an exclusive C0 master command. Waits for all slave commands, then runs alone.""" - async with self._exclusive_lock: - yield From c2e0a4bd2606752e4956fe9da5425c6a3927edd3 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 17 Aug 2026 15:36:55 -0700 Subject: [PATCH 49/54] Clean up Micronic rack reader diff --- docs/_static/devices.json | 15 ++ docs/api/pylabrobot.rst | 24 +- docs/user_guide/index.md | 123 +-------- docs/user_guide/machines.md | 244 +++--------------- .../micronic/code_reader/hello-world.ipynb | 17 +- .../hamilton/liquid_handlers/star/star.py | 223 ---------------- pylabrobot/hamilton/star/__init__.py | 0 pylabrobot/hamilton/star/lock.py | 76 ++++++ 8 files changed, 153 insertions(+), 569 deletions(-) delete mode 100644 pylabrobot/hamilton/liquid_handlers/star/star.py create mode 100644 pylabrobot/hamilton/star/__init__.py create mode 100644 pylabrobot/hamilton/star/lock.py diff --git a/docs/_static/devices.json b/docs/_static/devices.json index 56cb4cba7c6..0ce671cf4d6 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -805,6 +805,21 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://www.mt.com/us/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html" }, + { + "id": "micronic-code-reader", + "vendor": "Micronic", + "name": "Code Reader", + "kind": "barcode scanner", + "capabilities": [ + "barcode reading" + ], + "status": "basic", + "api": "pylabrobot.micronic.MicronicCodeReader", + "api_version": "v1", + "code_slug": "micronic/code_reader", + "doc_slug": "micronic/code_reader/hello-world", + "oem": "https://www.micronic.com/products/code-reader/" + }, { "id": "molecular-devices-imagexpress-micro", "vendor": "Molecular Devices", diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 5f46dd6be3f..700ef14afcb 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -3,25 +3,12 @@ API === -Core ----- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - device.Device - - Subpackages ----------- .. toctree:: :maxdepth: 1 - pylabrobot.capabilities - pylabrobot.arms pylabrobot.config pylabrobot.resources pylabrobot.utils @@ -34,16 +21,19 @@ Manufacturers pylabrobot.agilent pylabrobot.azenta - pylabrobot.bmg_labtech + pylabrobot.big_bear pylabrobot.brooks pylabrobot.byonoy + pylabrobot.cole_parmer + pylabrobot.curiox pylabrobot.hamilton + pylabrobot.high_res pylabrobot.inheco - pylabrobot.liconic + pylabrobot.kbioscience + pylabrobot.kbiosystems pylabrobot.mettler_toledo pylabrobot.micronic pylabrobot.molecular_devices - pylabrobot.opentrons pylabrobot.qinstruments - pylabrobot.tecan + pylabrobot.sartorius pylabrobot.thermo_fisher diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 3f1ad03ea17..92cb98393b2 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -7,12 +7,11 @@ :caption: Getting started :hidden: -_getting-started/installation -How PLR Works <_getting-started/plr-architecture> -_getting-started/rpi +getting-started/installation +getting-started/rpi +getting-started/units ``` - ```{toctree} :maxdepth: 1 :caption: Machines @@ -21,8 +20,6 @@ _getting-started/rpi machines definitions 00_liquid-handling/_liquid-handling -01_material-handling/_material-handling -02_analytical/_analytical ``` ```{toctree} @@ -32,17 +29,21 @@ definitions agilent/index azenta/index -bmg_labtech/index +big_bear/index brooks/index byonoy/index +cole_parmer/index +curiox/index hamilton/index +high_res/index inheco/index -liconic/index +kbioscience/index +kbiosystems/index mettler_toledo/index -molecular_devices/index micronic/index -opentrons/index +molecular_devices/index qinstruments/index +sartorius/index thermo_fisher/index ufactory/index ``` @@ -54,6 +55,7 @@ ufactory/index machine-agnostic-features/using-the-visualizer machine-agnostic-features/using-trackers +machine-agnostic-features/event-bus machine-agnostic-features/writing-robot-agnostic-protocols machine-agnostic-features/tip-spot-generators machine-agnostic-features/logging-and-validation/logging-and-validation @@ -61,14 +63,6 @@ machine-agnostic-features/error-handling-general machine-agnostic-features/sila-discovery ``` -```{toctree} -:maxdepth: 1 -:caption: Capabilities -:hidden: - -capabilities/index -``` - ```{toctree} :maxdepth: 1 :caption: Configuration @@ -88,95 +82,4 @@ The guide is divided into three parts: - **Machine-Agnostic Features** – Discover powerful tools that work across all devices, like the visualizer, trackers, reusable protocol patterns, validation tools, and error handling. -Check out [the list of supported machines](/user_guide/machines). - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Machines
├── Liquid Handling
│ ├── Pipetting Robots
│ ├── Plate Washers
│ └── Reagent Dispensers
├── ⚙️ Material Handling
│ ├── Transport Systems
│ │ ├── Conveyors
│ │ ├── Robotic Arms
│ │ └── Smart Storage (e.g. carousels)
│ ├── Consumable Manipulation
│ │ ├── Cappers & Decappers
│ │ └── Sealers & Peelers
│ └── Identification
│ └── Barcode Labellers And Readers
├── Environmental Control
│ ├── Temperature And Motion Control
│ │ ├── Automated Freezers/Fridges
│ │ ├── Automated Incubators
│ │ ├── Heated Cooled Blocks
│ │ ├── Incubated Shakers
│ │ ├── Shakers
│ │ ├── Thermal Shakers
│ │ └── Thermocyclers
│ └── Airflow & Contamination Control
│ ├── Air Circulation Fans
│ ├── HEPA Filtration Modules
│ └── UV-C Decontamination Units
├── Sample Preparation And Processing
│ ├── Automated Centrifuges
│ ├── Chromatography Systems
│ ├── Colony Pickers
│ └── Tissue Homogenizers
└── 🔬 Analytical And Detection
├── Balances / Scales
├── Optical Detection
│ ├── Automated Microscopes
│ ├── Flow Cytometers
│ ├── Plate Readers
│ └── Spectrophotometers
└── pH Meters
- -
+Check out [the list of supported machines](/user_guide/machines.md). diff --git a/docs/user_guide/machines.md b/docs/user_guide/machines.md index db5a5fbcebb..863413bd36f 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -1,226 +1,44 @@ # Supported Machines -```{raw} html - -``` - - -## Liquid Handling - -### Liquid Handling Workstations - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Hamilton | STAR(let) | arm | Full | [PLR](00_liquid-handling/hamilton-star/_hamilton-star.rst) / [OEM](https://www.hamiltoncompany.com/microlab-star) | -| Hamilton | Vantage | arm | Mostly | [PLR](00_liquid-handling/hamilton-vantage/_hamilton-vantage.rst) / [OEM](https://www.hamiltoncompany.com/microlab-vantage) | -| Hamilton | Prep | | WIP | [OEM](https://www.hamiltoncompany.com/microlab-prep) | -| Hamilton | Nimbus | arm | WIP | [OEM](https://www.hamiltoncompany.com/microlab-nimbus) | -| Tecan | EVO | arm | Basic | [PLR](00_liquid-handling/tecan-evo/_tecan-evo.rst) / [OEM](https://lifesciences.tecan.com/freedom-evo-platform) | -| Opentrons | OT-2 | | Mostly | [PLR](00_liquid-handling/opentrons/ot2/hello-world.ipynb) / [OEM](https://opentrons.com/products/ot-2-robot) | - -### Pumps - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Cole Parmer | Masterflex L/S 07522-20 07522-30 07551-20 07551-30 07575-30 07575-40 | Full | [PLR](00_liquid-handling/pumps/cole-parmer-masterflex.md) / [OEM](https://www.masterflex.nl/assets/uploads/2017/09/07551-xx.pdf) | -| Agrowtek | Pump Array | Full | [OEM](https://www.agrowtek.com/index.php/products/dosing_systems/dosing-pumps/agrowdose-adi-digital-persitaltic-dosing-pumps-detail) | - -### Plate Washers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Agilent (BioTek) | EL406 | Mostly | [PLR](00_liquid-handling/plate-washing/biotek-el406.ipynb) / [OEM](https://www.agilent.com/en/product/microplate-instrumentation/microplate-washers-dispensers/biotek-el406-washer-dispenser-795212) | - ---- - -## Material Handling - -### Arms - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Brooks Automation | PreciseFlex PF400 | Full | [PLR](01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb) / [OEM](https://www.brooks.com/laboratory-automation/collaborative-robots/preciseflex-400/) | -| Brooks Automation | PreciseFlex PF3400 | Full | [PLR](01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb) / [OEM](https://www.brooks.com/laboratory-automation/collaborative-robots/preciseflex-400/) | -| PAA | KX2 | WIP | | - -### Centrifuges - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Agilent | VSpin | Mostly | [PLR](01_material-handling/centrifuge/agilent_vspin.ipynb) / [OEM](https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge) | -| Agilent | VSpin Access2 Loader | Full | [PLR](01_material-handling/centrifuge/agilent_vspin.ipynb#loader) / [OEM](https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge) | - -### Fans - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Hamilton | HEPA Fan | air filtration | Full | [PLR](01_material-handling/fans/fans.md) | - -### Heater Shakers - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Inheco | Thermoshake RM | heating | Full | [PLR](01_material-handling/heating_shaking/inheco.ipynb) / [OEM](https://www.inheco.com/thermoshake-classic.html) | -| Inheco | Thermoshake | heatingactive cooling | Full | [PLR](01_material-handling/heating_shaking/inheco.ipynb) / [OEM](https://www.inheco.com/thermoshake.html) | -| Inheco | Thermoshake AC | heatingactive cooling | Full | [PLR](01_material-handling/heating_shaking/inheco.ipynb) / [OEM](https://www.inheco.com/thermoshake-ac.html) | -| Opentrons | Thermoshake | heating | Full | [OEM](https://opentrons.com/products/heater-shaker-module) | -| Hamilton | Heater Shaker | heating | Full | [PLR](01_material-handling/heating_shaking/hamilton.ipynb) / [OEM](https://www.hamiltoncompany.com/temperature-control/hamilton-heater-shaker) | -| QInstruments | BioShake | heatingactive cooling | Full | [PLR](01_material-handling/heating_shaking/qinstruments.ipynb) / [OEM](https://www.qinstruments.com/automation/) | - -### Storage - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Thermo Fisher Scientific | Cytomat 6000 | heating | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / [OEM](https://assets.thermofisher.com/TFS-Assets/CMD/brochures/br-90468-cytomat-2-c-lin-br90468-en.pdf) | -| Thermo Fisher Scientific | Cytomat 6002 | heating | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / [OEM](https://www.thermofisher.com/order/catalog/product/50075279) | -| Thermo Fisher Scientific | Cytomat 2 C_50 | heating | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / OEM? | -| Thermo Fisher Scientific | Cytomat 2 C425 | heatingactive cooling | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / [OEM](https://www.thermofisher.com/order/catalog/product/51033032) | -| Thermo Fisher Scientific | Cytomat 2 C450_SHAKE | heatingshaking | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / [OEM](https://www.thermofisher.com/order/catalog/product/51033035) | -| Thermo Fisher Scientific | Cytomat 5C | heating | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / [OEM](https://www.thermofisher.com/order/catalog/product/51031526) | -| Thermo/Liconic | Heraeus Cytomat | heating | Full | [PLR](01_material-handling/storage/cytomat.ipynb) / OEM? | -| Inheco | Incubator Shaker (MTP/DWP) | heatingshaking | Mostly | [OEM](https://www.inheco.com/incubator-shaker.html) | -| Inheco | SCILA | heatingshaking | Mostly | [OEM](https://www.inheco.com/scila.html) | +Every machine PyLabRobot supports, and how complete each driver is. Some are still work in +progress (WIP) — if you have one of those, or a machine that is not listed at all, get in touch on +the [forum](https://discuss.pylabrobot.org). -### Peelers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Azenta Life Sciences | XPeel | Full | [OEM](https://www.azenta.com/products/automated-plate-seal-remover-formerly-xpeel) | - -### Sealers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Azenta Life Sciences | a4S | Full | [PLR](01_material-handling/sealers/a4s.ipynb) / [OEM](https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s) | - -### Thermocyclers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Opentrons | Thermocycler | Full | [OEM](https://opentrons.com/products/thermocycler-module-1) | -| Thermo Fisher Scientific | ATC | Full | [OEM](https://www.thermofisher.com/us/en/home/life-science/pcr/thermal-cyclers-realtime-instruments/thermal-cyclers/automated-thermal-cycler-atc.html) | -| Thermo Fisher Scientific | ProFlex | Full | [OEM](https://www.thermofisher.com/us/en/home/life-science/pcr/thermal-cyclers-realtime-instruments/thermal-cyclers/proflex-pcr-system.html) | -| Inheco | ODTC | WIP | [OEM](https://www.inheco.com/odtc.html) | - -### Temperature Controllers - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Opentrons | Temperature Module | heatingactive cooling | Mostly | [PLR](01_material-handling/temperature-controllers/ot-temperature-controller.ipynb) / [OEM](https://opentrons.com/products/temperature-module-gen2) | -| Inheco | CPAC | heatingactive cooling | Full | [OEM](https://www.inheco.com/cpac.html) | - -### Tilting - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Hamilton | Tilt Module | Full | [PLR](01_material-handling/tilting.md) / [OEM](https://www.hamiltoncompany.com/other-robotics/188061) | - ---- - -## Analytical Machines - -### Plate Readers - -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| BMG Labtech | CLARIOstar (Plus) | absorbancefluorescenceluminescence | Full | [PLR](02_analytical/plate-reading/bmg-clariostar.ipynb) / [OEM](https://www.bmglabtech.com/en/clariostar-plus/) | -| Agilent (BioTek) | Cytation 1 | absorbancefluorescenceluminescencemicroscopy | Full | [PLR](02_analytical/plate-reading/cytation.ipynb) / [OEM](https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-1-cell-imaging-multimode-reader-1623200) | -| Agilent (BioTek) | Cytation 5 | absorbancefluorescenceluminescencemicroscopy | Full | [PLR](02_analytical/plate-reading/cytation.ipynb) / [OEM](https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-5-cell-imaging-multimode-reader-1623202) | -| Agilent (BioTek) | Synergy H1 | absorbancefluorescenceluminescence | Full | [PLR](02_analytical/plate-reading/synergyh1.ipynb) / [OEM](https://www.agilent.com/en/product/microplate-instrumentation/microplate-readers/multimode-microplate-readers/biotek-synergy-h1-multimode-reader-1623193) | -| Byonoy | Absorbance 96 Automate | absorbance | Full | [PLR](02_analytical/plate-reading/byonoy/absorbance.ipynb) / [OEM](https://byonoy.com/absorbance-96-automate/) | -| Byonoy | Luminescence 96 | luminescence | Full | [PLR](02_analytical/plate-reading/byonoy/luminescence.ipynb) / [OEM](https://byonoy.com/luminescence-96/) | -| Byonoy | Luminescence 96 Automate | luminescence | Full | [PLR](02_analytical/plate-reading/byonoy/luminescence.ipynb) / [OEM](https://byonoy.com/luminescence-96-automate/) | -| Molecular Devices | SpectraMax M5e | absorbancefluorescence time-resolved fluorescencefluorescence polarization | Full | [OEM](https://www.moleculardevices.com/products/microplate-readers/multi-mode-readers/spectramax-m-series-readers) | -| Molecular Devices | SpectraMax 384plus | absorbance | Full | [OEM](https://www.moleculardevices.com/products/microplate-readers/absorbance-readers/spectramax-abs-plate-readers) | -| Molecular Devices | ImageXpress Pico | microscopy | Basics | [PLR](02_analytical/plate-reading/pico.ipynb) / [OEM](https://www.moleculardevices.com/products/cellular-imaging-systems/high-content-imaging/imagexpress-pico) | -| Tecan | Infinite 200 PRO | absorbancefluorescenceluminescence | Mostly | [PLR](tecan/infinite/hello-world.ipynb) / [OEM](https://lifesciences.tecan.com/infinite-200-pro) | - - -### Flow Cytometers - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Beckman Coulter | CytoFLEX S | WIP | [OEM](https://www.beckman.com/flow-cytometry/research-flow-cytometers/cytoflex-s) | - -### qPCR Machines - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Thermo Fisher Scientific | QuantStudio 5 | WIP | [OEM](https://www.thermofisher.com/order/catalog/product/A34322) | - -### Scales - -| Manufacturer | Machine | PLR-Support | Links | -|--------------|---------|-------------|--------| -| Mettler Toledo | WXS205SDU | Full | [PLR](02_analytical/scales/mettler-toledo-WXS205SDU.ipynb) / [OEM](https://www.mt.com/us/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html) | +```{device-table} +``` -### Rack Readers +An asterisk after a device name marks a note about the models that entry covers; hover it to read +the note. -| Manufacturer | Machine | Features | PLR-Support | Links | -|--------------|---------|----------|-------------|--------| -| Micronic | Direct local scanner + serial control | rack reading | Basics | [PLR](https://docs.pylabrobot.org/user_guide/micronic/index.html) / [OEM](https://www.micronic.com/products/code-reader/) | +## Reading the table ---- +Classifying lab automation equipment is hard. Many machines have overlapping capabilities (the +Thermo Fisher Cytomat 2 C470 is a fridge, heated chamber, oven, plate hotel *and* shaker in one), +different user groups refer to the same machine by whichever capability they happen to use, naming +sits somewhere between user intuition and historical legacy (a thermocycler is really just a fast +heater/cooler), and there is no widely accepted standard. -## Understanding the Tables +PyLabRobot does not solve that. **Type** is the one label that names what a machine is, and +**capabilities** are the core functions it provides — a machine typically has several. Both are +filterable, so a machine you think of as a shaker is still findable by someone who thinks of it as +a heater. -Classifying lab automation equipment can be challenging. -There are many reasons for this, including (but not limited to): -- many machines have overlapping capabilities (the TFS Cytomat 2 C470 can be a fridge, heated chamber, oven, smart storage/plate hotel and shaker all in one machine!), -- different user groups require varying levels of software integration but tend to only refer to the capabilities they use in that moment (e.g. what is a shaker to one group might be a heater to another), -- balancing naming/classification based on user intuition/historic legacy and first principles, (e.g. a thermocycler is just a speedy heater/cooler but scientists are used to the term thermocycler) -- there is no widely accepted naming standard. +**Support** is how complete the PyLabRobot integration is: -PyLabRobot does not solve these human classification issues. -But to provide *some* structure PyLabRobot classifies machines into three purposefully broad categories: +- **WIP** — work in progress. +- **Basic** — core functionality is available, integrated into `pylabrobot:main`. +- **Mostly** — most capabilities are available, but some known commands are still missing. +- **Full** — comprehensive support (≥90% of capabilities), with documentation. -- **Liquid handling**: Machines directly manipulating liquids. -- **Material handling**: Machines handling materials other than liquids. -- **Analytical**: Machines primarily responsible for performing measurements. +The `v0`/`v1` marker next to the support level is the API generation a machine's driver belongs to. +`v0` drivers live under `pylabrobot.legacy` and are being migrated. -**Table Columns Explained:** +PyLabRobot aims to expose every hardware/firmware capability of integrated equipment, including +capabilities the OEM software does not surface. That lets you *choose* which functions you need. -- **Features**: Core capabilities provided by the machine. -- **PLR-Support**: Indicates PyLabRobot integration status: - - **WIP**: Work in progress. - - **Basics**: Core functionalities available. - Code integrated into `pylabrobot:main`. Documentation pages in `docs.pylabrobot.org`. - - **Mostly**: Most capabilities available, but some known commands still missing. - - **Full**: Comprehensive capabilities (≥90%) fully supported, extensive documentation available. +## Adding or updating a machine -Note: PyLabRobot aims to provide access to all hardware-firmware capabilities available on integrated equipment, even beyond OEM software. -This allows users to *choose* the machine functionalities they require. +The table is generated from `docs/_static/devices.json`. Adding a machine means adding one JSON +object — there is no table markup to keep in sync. See +{doc}`/contributor_guide/device-registry` for the schema, and for how to put a device card on a +machine's own documentation page. diff --git a/docs/user_guide/micronic/code_reader/hello-world.ipynb b/docs/user_guide/micronic/code_reader/hello-world.ipynb index 314f1cb2da7..47f7bd07bdf 100644 --- a/docs/user_guide/micronic/code_reader/hello-world.ipynb +++ b/docs/user_guide/micronic/code_reader/hello-world.ipynb @@ -3,31 +3,36 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Micronic\n\nPyLabRobot includes `v1b1` Micronic integrations built on the generic\n`rack_reading` capability.\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or a custom `Scanner` subclass for any other acquisition stack). The driver\nhandles image acquisition and side-rack serial barcode reads; the rack-reading\nbackend decodes tube DataMatrix codes locally and returns a standard\n`RackScanResult`. It does not call Micronic Code Reader or IO Monitor, and\nPyLabRobot does not package any scanner helper executable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The device\n currently supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or custom scanner integration), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment." + "source": "# Micronic\n\n```{device-card} micronic-code-reader\n```\n\nPyLabRobot includes `v1b1` Micronic integrations built on the generic\n`rack_reading` capability.\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or a custom `Scanner` subclass for any other acquisition stack). The driver\nhandles image acquisition and side-rack serial barcode reads; the rack-reading\nbackend decodes tube DataMatrix codes locally and returns a standard\n`RackScanResult`. It does not call Micronic Code Reader or IO Monitor, and\nPyLabRobot does not package any scanner helper executable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The device\n currently supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or custom scanner integration), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment.", + "id": "micronic-overview" }, { "cell_type": "code", "metadata": {}, - "source": "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\nfrom pylabrobot.resources import ResourceHolder, TubeRack, create_ordered_items_2d\n\nrack = TubeRack(\n name=\"micronic_96_well_rack\",\n size_x=85.0,\n size_y=127.0,\n size_z=20.0,\n ordered_items=create_ordered_items_2d(\n ResourceHolder,\n num_items_x=12,\n num_items_y=8,\n dx=0,\n dy=0,\n dz=0,\n item_dx=9.0,\n item_dy=9.0,\n size_x=9.0,\n size_y=9.0,\n size_z=20.0,\n ),\n)\n\nreader = MicronicCodeReader(\n scanner=TwainScanner(\n twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n twain_source=\"AVA6PlusG\",\n ),\n serial_port=\"COM4\",\n image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n keep_images=True,\n)\nawait reader.setup()\n\ntry:\n rack_result = await reader.rack_reading.scan_rack(\n rack=rack, timeout=90.0, poll_interval=1.0\n )\n print(rack_result.rack_id)\n print(len([entry for entry in rack_result.entries if entry.tube_id]))\n\n rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n print(rack_id)\nfinally:\n await reader.stop()", + "source": "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\nfrom pylabrobot.resources import ResourceHolder, TubeRack, create_ordered_items_2d\n\nrack = TubeRack(\n name=\"micronic_96_well_rack\",\n size_x=85.0,\n size_y=127.0,\n size_z=20.0,\n ordered_items=create_ordered_items_2d(\n ResourceHolder,\n num_items_x=12,\n num_items_y=8,\n dx=0,\n dy=0,\n dz=0,\n item_dx=9.0,\n item_dy=9.0,\n size_x=9.0,\n size_y=9.0,\n size_z=20.0,\n ),\n)\n\nreader = MicronicCodeReader(\n scanner=TwainScanner(\n twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n twain_source=\"AVA6PlusG\",\n ),\n serial_port=\"COM4\",\n image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n keep_images=True,\n)\nawait reader.setup()\n\ntry:\n rack_result = await reader.rack_reading.scan_rack(\n rack=rack, timeout=90.0, poll_interval=1.0\n )\n print(rack_result.rack_id)\n print(len([entry for entry in rack_result.entries if entry.tube_id]))\n\n rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n print(rack_id)\nfinally:\n await reader.stop()", "execution_count": null, - "outputs": [] + "outputs": [], + "id": "windows-example" }, { "cell_type": "markdown", "metadata": {}, - "source": "On Ubuntu/Linux, use `SaneScanner` if the scanner is exposed by a SANE backend:" + "source": "On Ubuntu/Linux, use `SaneScanner` if the scanner is exposed by a SANE backend:", + "id": "linux-intro" }, { "cell_type": "code", "metadata": {}, "source": "from pylabrobot.micronic import MicronicCodeReader, SaneScanner\n\nreader = MicronicCodeReader(\n scanner=SaneScanner(sane_device=\"avision:libusb:001:004\"),\n serial_port=\"/dev/ttyUSB0\",\n)", "execution_count": null, - "outputs": [] + "outputs": [], + "id": "linux-example" }, { "cell_type": "markdown", "metadata": {}, - "source": "For any other acquisition stack, subclass `Scanner` and implement `acquire` to\nwrite a rack image to the given path.\n\n## Notes\n\n- `scan_rack` reads the rack ID and every tube barcode, so it typically takes\n tens of seconds. `scan_rack_id` only reads the rack barcode and completes in a\n few seconds.\n- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n bridge binary and does not install one for you; pass `twain_scanner_path` to\n `TwainScanner`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n named `twain_scan` / `twain_scan.exe` on `PATH`.\n- Ubuntu/Linux scanner control uses SANE `scanimage` (via `SaneScanner`).\n PyLabRobot does not install SANE or vendor scanner drivers. Rack-ID reads use\n `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]`\n extra.\n- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot." + "source": "For any other acquisition stack, subclass `Scanner` and implement `acquire` to\nwrite a rack image to the given path.\n\n## Notes\n\n- `scan_rack` reads the rack ID and every tube barcode, so it typically takes\n tens of seconds. `scan_rack_id` only reads the rack barcode and completes in a\n few seconds.\n- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n bridge binary and does not install one for you; pass `twain_scanner_path` to\n `TwainScanner`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n named `twain_scan` / `twain_scan.exe` on `PATH`.\n- Ubuntu/Linux scanner control uses SANE `scanimage` (via `SaneScanner`).\n PyLabRobot does not install SANE or vendor scanner drivers. Rack-ID reads use\n `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]`\n extra.\n- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot.", + "id": "scanner-notes" } ], "metadata": { diff --git a/pylabrobot/hamilton/liquid_handlers/star/star.py b/pylabrobot/hamilton/liquid_handlers/star/star.py deleted file mode 100644 index 4b106de1375..00000000000 --- a/pylabrobot/hamilton/liquid_handlers/star/star.py +++ /dev/null @@ -1,223 +0,0 @@ -"""STAR device: wires STARDriver backends to PIP/Head96/iSWAP capability frontends.""" - -import asyncio -from contextlib import asynccontextmanager -from typing import AsyncIterator, Optional - -from pylabrobot.capabilities.arms.arm import FixedAxisGripperArm -from pylabrobot.capabilities.arms.orientable_arm import OrientableGripperArm -from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.capabilities.liquid_handling.head96 import Head96 -from pylabrobot.capabilities.liquid_handling.pip import PIP -from pylabrobot.device import Device -from pylabrobot.resources import Coordinate -from pylabrobot.resources.hamilton import HamiltonDeck, STARDeck, STARLetDeck -from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers - -from .chatterbox import STARChatterboxDriver -from .core import CoreGripper -from .driver import STARDriver - - -class _HamiltonSTAR(Device): - """Base class for Hamilton STAR/STARLet liquid handlers. - - Wires capability frontends (PIP, Head96, iSWAP) to the STARDriver's backends - after hardware discovery during setup(). - """ - - def __init__(self, deck: HamiltonDeck, chatterbox: bool = False): - driver = STARChatterboxDriver(deck=deck) if chatterbox else STARDriver(deck=deck) - super().__init__(driver=driver) - self.driver: STARDriver = driver - self.deck = deck - self.pip: PIP # set in setup() - self.head96: Optional[Head96] = None # set in setup() if installed - self.iswap: Optional[OrientableGripperArm] = None # set in setup() if installed - - async def setup( - self, - backend_params: Optional[BackendParams] = None, - *, - skip_pip: bool = False, - skip_iswap: bool = False, - skip_core96_head: bool = False, - skip_autoload: bool = False, - ): - """Set up the STAR. Optional skip flags mirror the legacy backend. - - Args: - backend_params: forwarded to the driver setup. - skip_pip: if True, do not initialize the PIP capability. - skip_iswap: if True, do not initialize the iSWAP capability. - skip_core96_head: if True, do not initialize the 96-head capability. - skip_autoload: if True, do not initialize the autoload. - """ - await self.driver.setup(backend_params=backend_params) - - # PIP is always present. - self.pip = PIP(backend=self.driver.pip, deck=self.deck) - self._capabilities = [self.pip] - - # Head96 only if the hardware has a 96-head installed and the user did not opt out. - if self.driver.head96 is not None and not skip_core96_head: - self.head96 = Head96(backend=self.driver.head96, deck=self.deck) - self._capabilities.append(self.head96) - - # iSWAP only if installed and the user did not opt out. - if self.driver.iswap is not None and not skip_iswap: - self.iswap = OrientableGripperArm(backend=self.driver.iswap, reference_resource=self.deck) - self._capabilities.append(self.iswap) - - # Matches legacy: autoload runs in parallel with arm modules. - # Arm modules run sequentially (pip → iswap → head96) because they share the left x-arm. - async def setup_arm_modules(): - if not skip_pip: - await self.pip._on_setup() - if self.iswap is not None and not skip_iswap: - await self.iswap._on_setup() - if self.head96 is not None and not skip_core96_head: - await self.head96._on_setup() - - async def setup_autoload(): - if self.driver.autoload is not None and not skip_autoload: - await self.driver.autoload._on_setup() - - await asyncio.gather(setup_autoload(), setup_arm_modules()) - self._setup_finished = True - - async def stop(self): - for cap in reversed(self._capabilities): - await cap._on_stop() - await self.driver.stop() - self._setup_finished = False - self.head96 = None - self.iswap = None - - # -- traversal-height convenience aliases ---------------------------------- - # - # Mirror the top-level API from the legacy STARBackend: `set_minimum_channel_ - # traversal_height` updates the PIP backend's traversal height, and - # `set_minimum_iswap_traversal_height` / `iswap_minimum_traversal_height` - # update / context-scope the iSWAP backend's traversal height. - - def set_minimum_channel_traversal_height(self, traversal_height: float) -> None: - """Set the minimum traversal height (mm) for all PIP-channel operations.""" - if not 0 < traversal_height < 285: - raise ValueError("Traversal height must be between 0 and 285 mm") - pip = getattr(self.driver, "pip", None) - if pip is None: - raise RuntimeError("STAR has not been set up yet; call setup() first.") - pip.traversal_height = traversal_height - if self.driver.head96 is not None: - self.driver.head96.traversal_height = traversal_height - - def set_minimum_iswap_traversal_height(self, traversal_height: float) -> None: - """Set the minimum traversal height (mm) for iSWAP plate movements.""" - if self.driver.iswap is None: - raise RuntimeError( - "iSWAP is not installed or STAR has not been set up; cannot set its traversal height." - ) - if not 0 < traversal_height < 285: - raise ValueError("Traversal height must be between 0 and 285 mm") - self.driver.iswap.traversal_height = traversal_height - - def set_minimum_traversal_height(self, traversal_height: float) -> None: - """Deprecated: use ``set_minimum_channel_traversal_height`` / - ``set_minimum_iswap_traversal_height`` instead.""" - raise NotImplementedError( - "set_minimum_traversal_height is deprecated. Use " - "set_minimum_channel_traversal_height or set_minimum_iswap_traversal_height instead." - ) - - @asynccontextmanager - async def iswap_minimum_traversal_height(self, traversal_height: float): - """Context-manage the iSWAP traversal height for the duration of the block.""" - if self.driver.iswap is None: - raise RuntimeError("iSWAP is not installed; cannot set its traversal height.") - with self.driver.iswap.use_traversal_height(traversal_height): - yield - - # -- CoRe grippers --------------------------------------------------------- - - @asynccontextmanager - async def core_grippers( - self, - front_channel: int = 7, - front_offset: Coordinate = Coordinate.zero(), - back_offset: Coordinate = Coordinate.zero(), - traversal_height: float = 280.0, - ) -> AsyncIterator[FixedAxisGripperArm]: - """Context manager that picks up CoRe gripper tools on enter and returns them on exit. - - Usage:: - - async with star.core_grippers(front_channel=7) as arm: - await arm.move_resource(plate, destination) - """ - - # Park iSWAP first if it's out — the arms share the X drive. - if self.iswap is not None and not self.iswap.backend.parked: # type: ignore[attr-defined] - await self.iswap.backend.park() - - core_grippers_resource = self.deck.get_resource("core_grippers") - if not isinstance(core_grippers_resource, HamiltonCoreGrippers): - raise TypeError("core_grippers resource must be HamiltonCoreGrippers") - - back_channel = front_channel - 1 - loc = core_grippers_resource.get_absolute_location() - xs = loc.x + front_offset.x - back_y = int(loc.y + core_grippers_resource.back_channel_y_center + back_offset.y) - front_y = int(loc.y + core_grippers_resource.front_channel_y_center + front_offset.y) - z_offset = front_offset.z - - if back_y <= front_y: - raise ValueError( - f"back_channel_y ({back_y}) must be > front_channel_y ({front_y}) for CoRe gripper pickup" - ) - ext = self.driver.extended_conf - if front_y < ext.left_arm_min_y_position: - raise ValueError( - f"front_channel_y ({front_y}) is below the left arm's minimum y " - f"position ({ext.left_arm_min_y_position})" - ) - - await self.driver.pick_up_core_gripper_tools( - x_position=xs, - back_channel_y=back_y, - front_channel_y=front_y, - back_channel=back_channel, - front_channel=front_channel, - begin_z=235.0 + z_offset, - end_z=225.0 + z_offset, - traversal_height=traversal_height, - ) - - backend = CoreGripper(driver=self.driver) - arm = FixedAxisGripperArm(backend=backend, reference_resource=self.deck, grip_axis="y") - - try: - yield arm - finally: - await self.driver.return_core_gripper_tools( - x_position=xs, - back_channel_y=back_y, - front_channel_y=front_y, - begin_z=215.0 + z_offset, - end_z=205.0 + z_offset, - traversal_height=traversal_height, - ) - - -class STAR(_HamiltonSTAR): - """Hamilton STAR liquid handler.""" - - def __init__(self, chatterbox: bool = False): - super().__init__(deck=STARDeck(), chatterbox=chatterbox) - - -class STARLet(_HamiltonSTAR): - """Hamilton STARLet liquid handler.""" - - def __init__(self, chatterbox: bool = False): - super().__init__(deck=STARLetDeck(), chatterbox=chatterbox) diff --git a/pylabrobot/hamilton/star/__init__.py b/pylabrobot/hamilton/star/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/star/lock.py b/pylabrobot/hamilton/star/lock.py new file mode 100644 index 00000000000..9fec9ad836e --- /dev/null +++ b/pylabrobot/hamilton/star/lock.py @@ -0,0 +1,76 @@ +import asyncio +import logging +from contextlib import AsyncExitStack, asynccontextmanager +from typing import Optional + +logger = logging.getLogger(__name__) + + +class _FirmwareLock: + """Coordinates firmware commands across modules. + + Two layers, both handled by ``slave_module(module)`` / ``c0()`` so callers never touch the + internals: + + * A readers/writer gate between the C0 master and the slave modules. A slave-module + command blocks the C0 master while in flight; a C0 master command (``c0()``) is + *exclusive*: it waits for all slave-module commands to drain, then runs alone. So no + slave-module command ever overlaps a C0 master command. + + * A per-module mutex so at most one command per module is in flight. Different modules + still overlap — an X0 arm move can run alongside an H0 head move and Px channel commands + — but you never have two X0, two H0, etc. at once. Modules without a dedicated mutex are + gated but not per-module serialized. + + Read-only request (``R*``) and query (``Q*``) commands are not coordinated here at all — + they take no lock and run fully in parallel. + + The first slave-module command acquires the exclusive lock and the last one releases it, + so a C0 command simply takes the same lock and automatically waits the slaves out. + """ + + # Slave modules that serialize against themselves: H0 (96-head), X0 (X-drives), + # R0 (iSWAP), I0 (autoload). All Px pipetting channels (P1..PG) share one mutex. + _SERIALIZED_MODULES = ("H0", "X0", "R0", "I0") + + def __init__(self): + # Per-module mutexes: at most one in-flight command per module. + self._px_lock = asyncio.Lock() # shared by all P1..PG pipetting channels + self._module_locks = {module: asyncio.Lock() for module in self._SERIALIZED_MODULES} + + # Readers/writer gate: slave-module commands vs the exclusive C0 master. + self._slave_count = 0 + self._slave_count_lock = asyncio.Lock() + self._exclusive_lock = asyncio.Lock() + + def _module_lock(self, module: str) -> Optional[asyncio.Lock]: + """The per-module mutex for ``module``, or None if it needs no per-module serialization.""" + if module.startswith("P"): + return self._px_lock # P1..PG pipetting channels + return self._module_locks.get(module) + + @asynccontextmanager + async def slave_module(self, module: str): + """Run a slave-module command: serialize on its module mutex and block the C0 master.""" + module_lock = self._module_lock(module) + async with AsyncExitStack() as stack: + if module_lock is not None: + await stack.enter_async_context(module_lock) + # Join the slave group; first in acquires the exclusive lock, last out releases it. + async with self._slave_count_lock: + self._slave_count += 1 + if self._slave_count == 1: + await self._exclusive_lock.acquire() + try: + yield + finally: + async with self._slave_count_lock: + self._slave_count -= 1 + if self._slave_count == 0: + self._exclusive_lock.release() + + @asynccontextmanager + async def c0(self): + """Run an exclusive C0 master command. Waits for all slave commands, then runs alone.""" + async with self._exclusive_lock: + yield From 85b998cec9078173adcb9b8fd6773429206692cf Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 17 Aug 2026 16:08:16 -0700 Subject: [PATCH 50/54] Refactor Micronic code reader integration --- docs/_static/devices.json | 2 +- docs/api/pylabrobot.micronic.rst | 36 +-- .../micronic/code_reader/hello-world.ipynb | 4 +- pylabrobot/micronic/__init__.py | 4 +- pylabrobot/micronic/code_reader/__init__.py | 10 +- .../micronic/code_reader/code_reader.py | 41 ---- pylabrobot/micronic/code_reader/driver.py | 173 ++++++++++++--- .../micronic/code_reader/micronic_tests.py | 209 +++++++----------- .../code_reader/rack_reading_backend.py | 118 ---------- 9 files changed, 241 insertions(+), 356 deletions(-) delete mode 100644 pylabrobot/micronic/code_reader/code_reader.py delete mode 100644 pylabrobot/micronic/code_reader/rack_reading_backend.py diff --git a/docs/_static/devices.json b/docs/_static/devices.json index 0ce671cf4d6..29758faef64 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -813,7 +813,7 @@ "capabilities": [ "barcode reading" ], - "status": "basic", + "status": "full", "api": "pylabrobot.micronic.MicronicCodeReader", "api_version": "v1", "code_slug": "micronic/code_reader", diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index 712b80f98aa..5d02902cefa 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -3,23 +3,10 @@ pylabrobot.micronic package =========================== -Micronic integrations built on the rack-reading capability. +Direct Micronic code-reader integration. -Device ------- - -.. currentmodule:: pylabrobot.micronic.code_reader.code_reader - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - MicronicCodeReader - - -Driver ------- +Code reader +----------- .. currentmodule:: pylabrobot.micronic.code_reader.driver @@ -28,7 +15,9 @@ Driver :nosignatures: :recursive: - MicronicCodeReaderDriver + MicronicCodeReader + RackScanEntry + RackScanResult .. currentmodule:: pylabrobot.micronic.code_reader.errors @@ -53,16 +42,3 @@ Scanners Scanner TwainScanner SaneScanner - - -Capabilities ------------- - -.. currentmodule:: pylabrobot.micronic.code_reader.rack_reading_backend - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - MicronicCodeReaderRackReadingBackend diff --git a/docs/user_guide/micronic/code_reader/hello-world.ipynb b/docs/user_guide/micronic/code_reader/hello-world.ipynb index 47f7bd07bdf..fe0fa097ca5 100644 --- a/docs/user_guide/micronic/code_reader/hello-world.ipynb +++ b/docs/user_guide/micronic/code_reader/hello-world.ipynb @@ -3,13 +3,13 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Micronic\n\n```{device-card} micronic-code-reader\n```\n\nPyLabRobot includes `v1b1` Micronic integrations built on the generic\n`rack_reading` capability.\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or a custom `Scanner` subclass for any other acquisition stack). The driver\nhandles image acquisition and side-rack serial barcode reads; the rack-reading\nbackend decodes tube DataMatrix codes locally and returns a standard\n`RackScanResult`. It does not call Micronic Code Reader or IO Monitor, and\nPyLabRobot does not package any scanner helper executable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `rack_reading.scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The device\n currently supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `rack_reading.scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or custom scanner integration), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment.", + "source": "# Micronic\n\n```{device-card} micronic-code-reader\n```\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or a custom `Scanner` subclass for any other acquisition stack). The code reader handles image acquisition and side-rack serial barcode reads, decodes\ntube DataMatrix codes locally, and returns a `RackScanResult`. It does not call Micronic Code Reader or IO Monitor, and\nPyLabRobot does not package any scanner helper executable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The device\n currently supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or custom scanner integration), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment.", "id": "micronic-overview" }, { "cell_type": "code", "metadata": {}, - "source": "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\nfrom pylabrobot.resources import ResourceHolder, TubeRack, create_ordered_items_2d\n\nrack = TubeRack(\n name=\"micronic_96_well_rack\",\n size_x=85.0,\n size_y=127.0,\n size_z=20.0,\n ordered_items=create_ordered_items_2d(\n ResourceHolder,\n num_items_x=12,\n num_items_y=8,\n dx=0,\n dy=0,\n dz=0,\n item_dx=9.0,\n item_dy=9.0,\n size_x=9.0,\n size_y=9.0,\n size_z=20.0,\n ),\n)\n\nreader = MicronicCodeReader(\n scanner=TwainScanner(\n twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n twain_source=\"AVA6PlusG\",\n ),\n serial_port=\"COM4\",\n image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n keep_images=True,\n)\nawait reader.setup()\n\ntry:\n rack_result = await reader.rack_reading.scan_rack(\n rack=rack, timeout=90.0, poll_interval=1.0\n )\n print(rack_result.rack_id)\n print(len([entry for entry in rack_result.entries if entry.tube_id]))\n\n rack_id = await reader.rack_reading.scan_rack_id(timeout=5.0, poll_interval=0.5)\n print(rack_id)\nfinally:\n await reader.stop()", + "source": "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\nfrom pylabrobot.resources import ResourceHolder, TubeRack, create_ordered_items_2d\n\nrack = TubeRack(\n name=\"micronic_96_well_rack\",\n size_x=85.0,\n size_y=127.0,\n size_z=20.0,\n ordered_items=create_ordered_items_2d(\n ResourceHolder,\n num_items_x=12,\n num_items_y=8,\n dx=0,\n dy=0,\n dz=0,\n item_dx=9.0,\n item_dy=9.0,\n size_x=9.0,\n size_y=9.0,\n size_z=20.0,\n ),\n)\n\nreader = MicronicCodeReader(\n scanner=TwainScanner(\n twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n twain_source=\"AVA6PlusG\",\n ),\n serial_port=\"COM4\",\n image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n keep_images=True,\n)\nawait reader.setup()\n\ntry:\n rack_result = await reader.scan_rack(rack=rack, timeout=90.0)\n print(rack_result.rack_id)\n print(len([entry for entry in rack_result.entries if entry.tube_id]))\n\n rack_id = await reader.scan_rack_id(timeout=5.0)\n print(rack_id)\nfinally:\n await reader.stop()", "execution_count": null, "outputs": [], "id": "windows-example" diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index 2c37b8b19b9..5c0f7a81e3e 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -1,8 +1,8 @@ from pylabrobot.micronic.code_reader import ( MicronicCodeReader, - MicronicCodeReaderDriver, - MicronicCodeReaderRackReadingBackend, MicronicError, + RackScanEntry, + RackScanResult, SaneScanner, Scanner, TwainScanner, diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index 0f67135b25a..46f6d66a66f 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -1,7 +1,7 @@ -from pylabrobot.micronic.code_reader.code_reader import MicronicCodeReader -from pylabrobot.micronic.code_reader.driver import MicronicCodeReaderDriver -from pylabrobot.micronic.code_reader.errors import MicronicError -from pylabrobot.micronic.code_reader.rack_reading_backend import ( - MicronicCodeReaderRackReadingBackend, +from pylabrobot.micronic.code_reader.driver import ( + MicronicCodeReader, + RackScanEntry, + RackScanResult, ) +from pylabrobot.micronic.code_reader.errors import MicronicError from pylabrobot.micronic.code_reader.scanner import SaneScanner, Scanner, TwainScanner diff --git a/pylabrobot/micronic/code_reader/code_reader.py b/pylabrobot/micronic/code_reader/code_reader.py deleted file mode 100644 index 1ecd7dfb507..00000000000 --- a/pylabrobot/micronic/code_reader/code_reader.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Micronic Code Reader device.""" - -from __future__ import annotations - -from typing import Optional - -from pylabrobot.capabilities.rack_reading import RackReader -from pylabrobot.device import Device - -from .driver import MicronicCodeReaderDriver -from .rack_reading_backend import MicronicCodeReaderRackReadingBackend -from .scanner import Scanner - - -class MicronicCodeReader(Device): - """Micronic rack reader device. - - The rack-reading capability is driven by ``MicronicCodeReaderDriver``. - """ - - def __init__( - self, - scanner: Scanner, - serial_port: str, - image_dir: Optional[str] = None, - scanner_timeout: float = 90.0, - serial_timeout_ms: int = 2500, - keep_images: bool = False, - ): - driver = MicronicCodeReaderDriver( - scanner=scanner, - serial_port=serial_port, - image_dir=image_dir, - scanner_timeout_ms=int(scanner_timeout * 1000), - serial_timeout_ms=serial_timeout_ms, - keep_images=keep_images, - ) - super().__init__(driver=driver) - self.driver: MicronicCodeReaderDriver = driver - self.rack_reading = RackReader(backend=MicronicCodeReaderRackReadingBackend(driver)) - self._capabilities = [self.rack_reading] diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 5b6dc3f1dd7..9fd637a25e4 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -1,30 +1,34 @@ -"""Hardware driver for the Micronic rack scanner. +"""Direct integration for the Micronic rack scanner. This driver does not call Micronic Code Reader or IO Monitor. It owns the local scanner path directly: - acquire a rack image through a caller-supplied :class:`Scanner`, - read barcodes through the side serial barcode reader, and -- expose acquisition metadata for the rack-reading backend. +- decode tube barcodes and return position-indexed rack results. """ from __future__ import annotations +import asyncio +import logging import re import tempfile import time from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Iterable, Optional +from typing import Iterable, Literal, Optional -from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.device import Driver from pylabrobot.io.serial import Serial +from pylabrobot.resources.barcode import Barcode +from pylabrobot.resources.tube_rack import TubeRack from .errors import MicronicError from .scanner import Scanner +logger = logging.getLogger(__name__) + ROWS = "ABCDEFGH" COLS = 12 RACK_ROWS = 8 @@ -37,25 +41,43 @@ class DecodeResult: method: str -class MicronicCodeReaderDriver(Driver): - """Driver that controls the Micronic scanner without the OEM app.""" +@dataclass +class RackScanEntry: + """One decoded rack position.""" + + position: str + tube_id: Optional[str] + status: Literal["OK", "NOREAD"] + barcode: Optional[Barcode] = None + + +@dataclass +class RackScanResult: + """The rack identifier and position-indexed tube scan results.""" + + rack_id: str + entries: list[RackScanEntry] + rack_barcode: Optional[Barcode] = None + + +class MicronicCodeReader: + """Control a Micronic rack scanner without the OEM application.""" def __init__( self, scanner: Scanner, serial_port: str, image_dir: Optional[str] = None, - scanner_timeout_ms: int = 90000, - serial_timeout_ms: int = 2500, + scanner_timeout: float = 90.0, + serial_timeout: float = 2.5, keep_images: bool = False, - ): - super().__init__() + ) -> None: self.scanner = scanner self.image_dir = ( Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "pylabrobot-micronic" ) - self.scanner_timeout_ms = scanner_timeout_ms - self.serial_timeout_ms = serial_timeout_ms + self.scanner_timeout = scanner_timeout + self.serial_timeout = serial_timeout self.keep_images = keep_images self.io = Serial( human_readable_device_name="Micronic rack ID reader", @@ -70,26 +92,21 @@ def __init__( self.last_image_path: Optional[Path] = None self.last_scan_metadata: dict[str, object] = {} self.last_decode_metadata: dict[str, object] = {} + self._scan_lock = asyncio.Lock() - async def setup(self, backend_params: Optional[BackendParams] = None): - del backend_params + async def setup(self) -> None: + """Create the image directory and connect to the side barcode reader.""" self.image_dir.mkdir(parents=True, exist_ok=True) await self.io.setup() + logger.info("Set up Micronic code reader") - async def stop(self): + async def stop(self) -> None: + """Disconnect from the side barcode reader.""" await self.io.stop() + logger.info("Stopped Micronic code reader") - def serialize(self) -> dict: - return { - **super().serialize(), - "image_dir": str(self.image_dir), - "scanner_timeout_ms": self.scanner_timeout_ms, - "serial_timeout_ms": self.serial_timeout_ms, - "keep_images": self.keep_images, - } - - async def read_barcode(self) -> str: - deadline = time.monotonic() + self.serial_timeout_ms / 1000 + async def _read_barcode(self) -> str: + deadline = time.monotonic() + self.serial_timeout chunks: list[bytes] = [] try: await self.io.reset_input_buffer() @@ -110,17 +127,19 @@ async def read_barcode(self) -> str: match = re.search(r"\d{6,}", text) return match.group(0) if match else "NOREAD" - def acquire_image(self) -> Path: + def _acquire_image(self) -> Path: self.image_dir.mkdir(parents=True, exist_ok=True) image_path = ( self.image_dir / f"micronic_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.{self.scanner.image_extension}" ) - self.last_scan_metadata = self.scanner.acquire(image_path, self.scanner_timeout_ms) + self.last_scan_metadata = self.scanner.acquire( + image_path, max(1, int(self.scanner_timeout * 1000)) + ) self.last_image_path = image_path return image_path - def release_image(self, image_path: Path) -> None: + def _release_image(self, image_path: Path) -> None: if not self.keep_images: try: image_path.unlink() @@ -128,6 +147,100 @@ def release_image(self, image_path: Path) -> None: except OSError: pass + @staticmethod + def _validate_rack(rack: TubeRack) -> None: + if rack.num_items_x != RACK_COLS or rack.num_items_y != RACK_ROWS: + raise MicronicError( + f"Micronic code reader only supports {RACK_ROWS}x{RACK_COLS} racks; " + f"got {rack.num_items_y}x{rack.num_items_x}." + ) + + async def scan_rack(self, rack: TubeRack, timeout: float = 90.0) -> RackScanResult: + """Scan the rack ID and all tube positions in an 8x12 rack.""" + return await asyncio.wait_for(self._scan_rack(rack), timeout=timeout) + + async def scan_rack_id(self, timeout: float = 5.0) -> str: + """Read and return the side rack barcode, or ``"NOREAD"`` if none is decoded.""" + return await asyncio.wait_for(self._read_barcode(), timeout=timeout) + + async def _scan_rack(self, rack: TubeRack) -> RackScanResult: + self._validate_rack(rack) + if self._scan_lock.locked(): + raise MicronicError("Micronic rack scan is already in progress.") + await self._scan_lock.acquire() + release_lock = True + try: + rack_id = await self._read_barcode() + loop = asyncio.get_running_loop() + scan_future = loop.run_in_executor(None, self._scan_rack_blocking, rack_id, rack.num_items) + try: + return await asyncio.shield(scan_future) + except asyncio.CancelledError: + release_lock = False + scan_future.add_done_callback(self._finish_cancelled_scan) + raise + finally: + if release_lock: + self._release_scan_lock() + + def _finish_cancelled_scan(self, future: asyncio.Future[RackScanResult]) -> None: + try: + future.exception() + except asyncio.CancelledError: + pass + self._release_scan_lock() + + def _release_scan_lock(self) -> None: + if self._scan_lock.locked(): + self._scan_lock.release() + + def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackScanResult: + image_path = self._acquire_image() + + try: + decoded, self.last_decode_metadata = decode_image(image_path) + if len(decoded) < expected_well_count: + missing = ", ".join(position for position in iter_positions() if position not in decoded) + raise MicronicError( + f"Micronic decode found {len(decoded)} wells; expected at least " + f"{expected_well_count}. Missing: {missing}" + ) + + for position, result in decoded.items(): + logger.debug("Micronic decoded %s via %s", position, result.method) + + entries = [ + RackScanEntry( + position=position, + tube_id=decoded[position].tube_id if position in decoded else None, + status="OK" if position in decoded else "NOREAD", + barcode=( + Barcode( + data=decoded[position].tube_id, + symbology="DataMatrix", + position_on_resource="bottom", + ) + if position in decoded + else None + ), + ) + for position in iter_positions() + ] + + return RackScanResult( + rack_id=rack_id, + entries=entries, + rack_barcode=Barcode( + data=rack_id, + symbology="Code 128 (Subset B and C)", + position_on_resource="right", + ) + if rack_id != "NOREAD" + else None, + ) + finally: + self._release_image(image_path) + def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, object]]: cv2, np, zxingcpp, Image, ImageOps = import_decode_dependencies() diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index f98c11e0ff3..a49582a00e6 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -1,3 +1,4 @@ +import asyncio import os import tempfile import unittest @@ -5,15 +6,8 @@ from typing import cast from unittest.mock import AsyncMock, MagicMock, patch -from pylabrobot.micronic import MicronicCodeReader, SaneScanner, TwainScanner -from pylabrobot.micronic.code_reader.driver import ( - DecodeResult, - MicronicCodeReaderDriver, - MicronicError, -) -from pylabrobot.micronic.code_reader.rack_reading_backend import ( - MicronicCodeReaderRackReadingBackend, -) +from pylabrobot.micronic import MicronicCodeReader, MicronicError, SaneScanner, TwainScanner +from pylabrobot.micronic.code_reader.driver import DecodeResult from pylabrobot.resources.tube_rack import TubeRack @@ -32,7 +26,16 @@ def _mock_scanner(image_extension: str = "bmp") -> MagicMock: return scanner -class TestScannerClasses(unittest.IsolatedAsyncioTestCase): +def _run_inline(_executor, function, *args): + future = asyncio.get_running_loop().create_future() + try: + future.set_result(function(*args)) + except Exception as exc: + future.set_exception(exc) + return future + + +class TestScannerClasses(unittest.TestCase): def test_sane_scanner_invokes_scanimage(self): with tempfile.TemporaryDirectory() as image_dir: output_path = Path(image_dir) / "rack.tiff" @@ -104,25 +107,26 @@ def test_twain_scanner_acquire_runs_helper(self): ) -class TestMicronicCodeReaderDriver(unittest.IsolatedAsyncioTestCase): +class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): def test_acquire_image_runs_scanner_and_tracks_metadata(self): with tempfile.TemporaryDirectory() as image_dir: scanner = _mock_scanner() - driver = MicronicCodeReaderDriver( + reader = MicronicCodeReader( scanner=scanner, serial_port="/dev/ttyUSB0", image_dir=image_dir, + scanner_timeout=1.25, keep_images=True, ) - image_path = driver.acquire_image() + image_path = reader._acquire_image() - self.assertEqual(driver.last_image_path, image_path) - self.assertEqual(driver.last_scan_metadata, {"source": "test"}) - scanner.acquire.assert_called_once() + self.assertEqual(reader.last_image_path, image_path) + self.assertEqual(reader.last_scan_metadata, {"source": "test"}) + scanner.acquire.assert_called_once_with(image_path, 1250) self.assertTrue(image_path.name.startswith("micronic_")) self.assertEqual(image_path.suffix, ".bmp") - async def test_read_barcode_uses_plr_serial(self): + async def test_scan_rack_id_uses_plr_serial(self): instances: list[object] = [] class FakeSerial: @@ -149,12 +153,12 @@ async def stop(self): self.calls.append("stop") with patch("pylabrobot.micronic.code_reader.driver.Serial", FakeSerial): - driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") - await driver.setup() + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + await reader.setup() try: - rack_id = await driver.read_barcode() + rack_id = await reader.scan_rack_id(timeout=1.0) finally: - await driver.stop() + await reader.stop() self.assertEqual(len(instances), 1) fake_serial = cast(FakeSerial, instances[0]) @@ -167,43 +171,44 @@ async def stop(self): self.assertIn("write:b'\\r\\n'", fake_serial.calls) self.assertEqual(fake_serial.calls[-1], "stop") - -class TestMicronicCodeReaderRackReadingBackend(unittest.IsolatedAsyncioTestCase): - async def test_scan_rack_populates_standard_rack_result(self): + async def test_scan_rack_populates_result(self): with tempfile.TemporaryDirectory() as image_dir: scanner = _mock_scanner() - driver = MicronicCodeReaderDriver( + reader = MicronicCodeReader( scanner=scanner, serial_port="/dev/ttyUSB0", image_dir=image_dir, keep_images=True, ) - backend = MicronicCodeReaderRackReadingBackend(driver=driver) decoded = { "A1": DecodeResult(tube_id="1111111111", method="test"), "A2": DecodeResult(tube_id="2222222222", method="test"), } + loop = asyncio.get_running_loop() with ( - patch.object(driver, "read_barcode", AsyncMock(return_value="9500017722")) as read_barcode, + patch.object(reader, "_read_barcode", AsyncMock(return_value="9500017722")) as read_barcode, + patch.object(loop, "run_in_executor", side_effect=_run_inline), patch( - "pylabrobot.micronic.code_reader.rack_reading_backend.decode_image", + "pylabrobot.micronic.code_reader.driver.decode_image", return_value=(decoded, {"decodedWells": 2}), ) as decode_image_mock, ): - result = await backend.scan_rack(_rack(num_items=2), timeout=1.0, poll_interval=0.0) + result = await reader.scan_rack(_rack(num_items=2), timeout=1.0) self.assertEqual(result.rack_id, "9500017722") - self.assertIsNotNone(result.rack_barcode) - self.assertEqual(result.rack_barcode.data, "9500017722") - self.assertEqual(result.rack_barcode.symbology, "Code 128 (Subset B and C)") + rack_barcode = result.rack_barcode + assert rack_barcode is not None + self.assertEqual(rack_barcode.data, "9500017722") + self.assertEqual(rack_barcode.symbology, "Code 128 (Subset B and C)") self.assertEqual(result.entries[0].position, "A1") self.assertEqual(result.entries[0].tube_id, "1111111111") - self.assertIsNotNone(result.entries[0].barcode) - self.assertEqual(result.entries[0].barcode.data, "1111111111") - self.assertEqual(result.entries[0].barcode.symbology, "DataMatrix") + tube_barcode = result.entries[0].barcode + assert tube_barcode is not None + self.assertEqual(tube_barcode.data, "1111111111") + self.assertEqual(tube_barcode.symbology, "DataMatrix") self.assertEqual(result.entries[1].tube_id, "2222222222") - self.assertEqual(driver.last_scan_metadata, {"source": "test"}) - self.assertEqual(driver.last_decode_metadata, {"decodedWells": 2}) + self.assertEqual(reader.last_scan_metadata, {"source": "test"}) + self.assertEqual(reader.last_decode_metadata, {"decodedWells": 2}) scanner.acquire.assert_called_once() read_barcode.assert_awaited_once() decode_image_mock.assert_called_once() @@ -218,140 +223,90 @@ async def test_reader_can_scan_twice(self): keep_images=True, ) decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} + loop = asyncio.get_running_loop() with ( - patch.object(reader.driver.io, "setup", AsyncMock()), - patch.object(reader.driver.io, "stop", AsyncMock()), - patch.object(reader.driver, "read_barcode", AsyncMock(return_value="9500017722")), + patch.object(reader.io, "setup", AsyncMock()), + patch.object(reader.io, "stop", AsyncMock()), + patch.object(reader, "_read_barcode", AsyncMock(return_value="9500017722")), + patch.object(loop, "run_in_executor", side_effect=_run_inline), patch( - "pylabrobot.micronic.code_reader.rack_reading_backend.decode_image", + "pylabrobot.micronic.code_reader.driver.decode_image", return_value=(decoded, {"decodedWells": 1}), ), ): await reader.setup() - first = await reader.rack_reading.scan_rack( - rack=_rack(num_items=1), timeout=1.0, poll_interval=0.0 - ) - second = await reader.rack_reading.scan_rack( - rack=_rack(num_items=1), timeout=1.0, poll_interval=0.0 - ) + try: + first = await reader.scan_rack(rack=_rack(num_items=1), timeout=1.0) + second = await reader.scan_rack(rack=_rack(num_items=1), timeout=1.0) + finally: + await reader.stop() self.assertEqual(first.rack_id, "9500017722") self.assertEqual(second.rack_id, "9500017722") self.assertEqual(scanner.acquire.call_count, 2) - async def test_backend_rejects_mismatched_rack_shape(self): - driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") - backend = MicronicCodeReaderRackReadingBackend(driver=driver) + async def test_rejects_mismatched_rack_shape(self): + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with self.assertRaises(MicronicError): - await backend.scan_rack(_rack(num_items_x=6, num_items_y=4), timeout=1.0, poll_interval=0.0) + await reader.scan_rack(_rack(num_items_x=6, num_items_y=4), timeout=1.0) - async def test_backend_rejects_concurrent_scan(self): - driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") - backend = MicronicCodeReaderRackReadingBackend(driver=driver) - await backend._scan_lock.acquire() + async def test_rejects_concurrent_scan(self): + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + await reader._scan_lock.acquire() try: with self.assertRaises(MicronicError): - await backend.scan_rack(_rack(), timeout=1.0, poll_interval=0.0) + await reader.scan_rack(_rack(), timeout=1.0) finally: - backend._scan_lock.release() + reader._scan_lock.release() async def test_scan_rack_times_out(self): - driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") - backend = MicronicCodeReaderRackReadingBackend(driver=driver) + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") async def slow(rack): del rack - import asyncio - await asyncio.sleep(1) return MagicMock() - with patch.object(backend, "_scan_rack", slow): + with patch.object(reader, "_scan_rack", slow): with self.assertRaises(TimeoutError): - await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) + await reader.scan_rack(rack=_rack(), timeout=0.01) async def test_timeout_keeps_scan_lock_until_blocking_scan_finishes(self): - driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") - backend = MicronicCodeReaderRackReadingBackend(driver=driver) - - def slow_blocking_scan(rack_id, expected_well_count): - del rack_id, expected_well_count - import time - - time.sleep(0.05) - return MagicMock() + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + loop = asyncio.get_running_loop() + scan_future = loop.create_future() + loop.call_later(0.05, scan_future.set_result, MagicMock()) with ( - patch.object(driver, "read_barcode", AsyncMock(return_value="9500017722")), - patch.object(backend, "_scan_rack_blocking", side_effect=slow_blocking_scan), + patch.object(reader, "_read_barcode", AsyncMock(return_value="9500017722")), + patch.object(loop, "run_in_executor", return_value=scan_future), ): with self.assertRaises(TimeoutError): - await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) + await reader.scan_rack(rack=_rack(), timeout=0.01) with self.assertRaises(MicronicError): - await backend.scan_rack(rack=_rack(), timeout=0.01, poll_interval=0.0) - - import asyncio + await reader.scan_rack(rack=_rack(), timeout=0.01) await asyncio.sleep(0.08) - self.assertFalse(backend._scan_lock.locked()) + self.assertFalse(reader._scan_lock.locked()) async def test_scan_rack_propagates_micronic_error(self): - driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") - backend = MicronicCodeReaderRackReadingBackend(driver=driver) + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with self.assertRaises(MicronicError): - await backend.scan_rack( - rack=_rack(num_items_x=6, num_items_y=4), timeout=1.0, poll_interval=0.0 + await reader.scan_rack( + rack=_rack(num_items_x=6, num_items_y=4), + timeout=1.0, ) async def test_scan_rack_id_reads_barcode(self): - driver = MicronicCodeReaderDriver(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") - backend = MicronicCodeReaderRackReadingBackend(driver=driver) - with patch.object(driver, "read_barcode", AsyncMock(return_value="9500017722")) as read_barcode: - rack_id = await backend.scan_rack_id(timeout=5.0, poll_interval=0.5) + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + with patch.object( + reader, "_read_barcode", AsyncMock(return_value="9500017722") + ) as read_barcode: + rack_id = await reader.scan_rack_id(timeout=5.0) self.assertEqual(rack_id, "9500017722") read_barcode.assert_awaited_once_with() -class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): - async def test_device_exposes_rack_reading_only(self): - reader = MicronicCodeReader( - scanner=_mock_scanner(), - serial_port="/dev/ttyUSB0", - scanner_timeout=12.0, - ) - with ( - patch.object(reader.driver.io, "setup", AsyncMock()), - patch.object(reader.driver.io, "stop", AsyncMock()), - ): - await reader.setup() - try: - self.assertIn(reader.rack_reading, reader._capabilities) - self.assertFalse(hasattr(reader, "barcode_scanning")) - with patch.object( - reader.rack_reading, - "scan_rack", - return_value=MagicMock(rack_id="9500017722"), - ) as scan_rack: - result = await reader.rack_reading.scan_rack( - rack=_rack(), - timeout=12.0, - poll_interval=1.0, - ) - finally: - await reader.stop() - - self.assertEqual(result.rack_id, "9500017722") - scan_rack.assert_called_once() - - async def test_frontend_uses_driver(self): - reader = MicronicCodeReader( - scanner=_mock_scanner(), - serial_port="/dev/ttyUSB0", - ) - self.assertIsInstance(reader.driver, MicronicCodeReaderDriver) - self.assertFalse(hasattr(reader, "barcode_scanning")) - - if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/micronic/code_reader/rack_reading_backend.py b/pylabrobot/micronic/code_reader/rack_reading_backend.py deleted file mode 100644 index 6c02d453b94..00000000000 --- a/pylabrobot/micronic/code_reader/rack_reading_backend.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Rack-reading backend for the Micronic driver.""" - -from __future__ import annotations - -import asyncio -import logging - -from pylabrobot.capabilities.rack_reading import RackReaderBackend, RackScanEntry, RackScanResult -from pylabrobot.resources.barcode import Barcode -from pylabrobot.resources.tube_rack import TubeRack - -from .driver import RACK_COLS, RACK_ROWS, MicronicCodeReaderDriver, decode_image, iter_positions -from .errors import MicronicError - -logger = logging.getLogger(__name__) - - -class MicronicCodeReaderRackReadingBackend(RackReaderBackend): - """Rack-reading backend for the Micronic code reader.""" - - def __init__(self, driver: MicronicCodeReaderDriver): - super().__init__() - self.driver = driver - self._scan_lock = asyncio.Lock() - - @staticmethod - def _validate_rack(rack: TubeRack) -> None: - if rack.num_items_x != RACK_COLS or rack.num_items_y != RACK_ROWS: - raise MicronicError( - f"Micronic code reader only supports {RACK_ROWS}x{RACK_COLS} racks; " - f"got {rack.num_items_y}x{rack.num_items_x}." - ) - - async def scan_rack(self, rack: TubeRack, timeout: float, poll_interval: float) -> RackScanResult: - del poll_interval - return await asyncio.wait_for(self._scan_rack(rack), timeout=timeout) - - async def scan_rack_id(self, timeout: float, poll_interval: float) -> str: - del poll_interval - return await asyncio.wait_for(self.driver.read_barcode(), timeout=timeout) - - async def _scan_rack(self, rack: TubeRack) -> RackScanResult: - self._validate_rack(rack) - if self._scan_lock.locked(): - raise MicronicError("Micronic rack scan is already in progress.") - await self._scan_lock.acquire() - release_lock = True - try: - rack_id = await self.driver.read_barcode() - loop = asyncio.get_running_loop() - scan_future = loop.run_in_executor(None, self._scan_rack_blocking, rack_id, rack.num_items) - try: - return await asyncio.shield(scan_future) - except asyncio.CancelledError: - release_lock = False - scan_future.add_done_callback(self._finish_cancelled_scan) - raise - finally: - if release_lock: - self._release_scan_lock() - - def _finish_cancelled_scan(self, future) -> None: - try: - future.exception() - except asyncio.CancelledError: - pass - self._release_scan_lock() - - def _release_scan_lock(self) -> None: - if self._scan_lock.locked(): - self._scan_lock.release() - - def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackScanResult: - image_path = self.driver.acquire_image() - - try: - decoded, self.driver.last_decode_metadata = decode_image(image_path) - if len(decoded) < expected_well_count: - missing = ", ".join(position for position in iter_positions() if position not in decoded) - raise MicronicError( - f"Micronic decode found {len(decoded)} wells; expected at least " - f"{expected_well_count}. Missing: {missing}" - ) - - for position, result in decoded.items(): - logger.debug("Micronic decoded %s via %s", position, result.method) - - entries = [ - RackScanEntry( - position=position, - tube_id=decoded[position].tube_id if position in decoded else None, - status="OK" if position in decoded else "NOREAD", - barcode=( - Barcode( - data=decoded[position].tube_id, - symbology="DataMatrix", - position_on_resource="bottom", - ) - if position in decoded - else None - ), - ) - for position in iter_positions() - ] - - return RackScanResult( - rack_id=rack_id, - entries=entries, - rack_barcode=Barcode( - data=rack_id, - symbology="Code 128 (Subset B and C)", - position_on_resource="right", - ) - if rack_id != "NOREAD" - else None, - ) - finally: - self.driver.release_image(image_path) From d681e615acbe7af84fa61a7e284a277afcbd5730 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 18 Aug 2026 10:32:27 -0700 Subject: [PATCH 51/54] Refine Micronic code reader integration --- .../micronic/code_reader/hello-world.ipynb | 193 +++++++++- pylabrobot/micronic/code_reader/driver.py | 347 ++++++++++++++---- .../micronic/code_reader/micronic_tests.py | 303 +++++++++++++-- pylabrobot/micronic/code_reader/scanner.py | 193 ++++++++-- 4 files changed, 875 insertions(+), 161 deletions(-) diff --git a/docs/user_guide/micronic/code_reader/hello-world.ipynb b/docs/user_guide/micronic/code_reader/hello-world.ipynb index fe0fa097ca5..fedd48e2875 100644 --- a/docs/user_guide/micronic/code_reader/hello-world.ipynb +++ b/docs/user_guide/micronic/code_reader/hello-world.ipynb @@ -2,37 +2,207 @@ "cells": [ { "cell_type": "markdown", + "id": "micronic-intro", "metadata": {}, - "source": "# Micronic\n\n```{device-card} micronic-code-reader\n```\n\n`MicronicCodeReader` controls the local hardware directly. The caller picks a\n`Scanner` to acquire the rack image (`TwainScanner` on Windows, `SaneScanner` on\nLinux, or a custom `Scanner` subclass for any other acquisition stack). The code reader handles image acquisition and side-rack serial barcode reads, decodes\ntube DataMatrix codes locally, and returns a `RackScanResult`. It does not call Micronic Code Reader or IO Monitor, and\nPyLabRobot does not package any scanner helper executable.\n\n## Supported operations\n\nRack reading (large scanner that decodes 96 tubes plus the side rack barcode):\n\n- `scan_rack(rack)` to trigger image acquisition, decode all 96 tube\n positions, read the side rack barcode, and return a `RackScanResult`. The device\n currently supports 8x12 racks; passing a different shape raises `MicronicError`.\n- `scan_rack_id()` for a rack-barcode-only read on the side reader\n\n## Hardware example\n\nThe operator is responsible for installing any OS-level scanner bridge\n(`twain_scan`, `scanimage`, or custom scanner integration), the PLR serial extra\n(`pylabrobot[serial]`), and the local Python decode dependencies in the runtime\nenvironment.", - "id": "micronic-overview" + "source": [ + "# Micronic Code Reader\n", + "\n", + "The Micronic Code Reader scans the side barcode of an 8x12 tube rack, acquires an image of\n", + "the rack, and decodes the 96 tube DataMatrix codes locally. It does not require Micronic Code\n", + "Reader or IO Monitor.\n", + "\n", + "| Property | Value |\n", + "|---|---|\n", + "| Rack-ID communication | Serial trigger and response |\n", + "| Serial settings | 9600 baud, 7 data bits, even parity, 1 stop bit |\n", + "| Rack image | TWAIN helper on Windows, SANE `scanimage` on Linux, or a custom `Scanner` |\n", + "| Rack layout | 8 rows by 12 columns |\n", + "| Tube barcode | Ten-digit DataMatrix |\n", + "| Rack barcode | Code 128 |\n", + "\n", + "[OEM link](https://www.micronic.com/products/code-reader/)" + ] + }, + { + "cell_type": "markdown", + "id": "device-card", + "metadata": {}, + "source": [ + "```{device-card} micronic-code-reader\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "micronic-communication", + "metadata": {}, + "source": [ + "## How it talks\n", + "\n", + "The side reader uses `pylabrobot.io.Serial`. A rack-ID scan clears pending input, sends\n", + "`` followed by CRLF, reads through the response terminator, and returns the first sequence\n", + "of at least six digits. It returns `NOREAD` when the response contains no rack identifier.\n", + "\n", + "Rack-image acquisition runs through PyLabRobot's command-line transport. `TwainScanner` calls\n", + "an operator-installed TWAIN helper; `SaneScanner` calls `scanimage`. PyLabRobot then performs\n", + "grid fitting and DataMatrix decoding locally." + ] + }, + { + "cell_type": "markdown", + "id": "micronic-physical-setup", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "1. Connect the rack scanner to the computer and install its operating-system driver.\n", + "2. Connect the side rack-ID reader and identify its serial port.\n", + "3. On Windows, install the local TWAIN helper and note its path and TWAIN source name. On\n", + " Linux, install SANE and confirm that `scanimage --list-devices` lists the scanner.\n", + "4. Install the serial and decoding dependencies: `pylabrobot[serial]`, `pillow`,\n", + " `opencv-python-headless`, `numpy`, and `zxing-cpp`.\n", + "5. Place an 8x12 Micronic tube rack in the scanner with the orientation expected by the\n", + " instrument." + ] + }, + { + "cell_type": "markdown", + "id": "micronic-connect", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "Choose the scanner implementation for the host. This example uses a Windows TWAIN helper. On\n", + "Linux, replace it with `SaneScanner(sane_device=\"\")`.\n", + "`setup()` resolves the scanner executable, creates the image directory, and opens the side\n", + "reader's serial port." + ] }, { "cell_type": "code", + "execution_count": null, + "id": "micronic-connect-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\n", + "\n", + "reader = MicronicCodeReader(\n", + " scanner=TwainScanner(\n", + " twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n", + " twain_source=\"AVA6PlusG\",\n", + " ),\n", + " serial_port=\"COM4\",\n", + " image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n", + ")\n", + "await reader.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "micronic-rack-id", "metadata": {}, - "source": "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\nfrom pylabrobot.resources import ResourceHolder, TubeRack, create_ordered_items_2d\n\nrack = TubeRack(\n name=\"micronic_96_well_rack\",\n size_x=85.0,\n size_y=127.0,\n size_z=20.0,\n ordered_items=create_ordered_items_2d(\n ResourceHolder,\n num_items_x=12,\n num_items_y=8,\n dx=0,\n dy=0,\n dz=0,\n item_dx=9.0,\n item_dy=9.0,\n size_x=9.0,\n size_y=9.0,\n size_z=20.0,\n ),\n)\n\nreader = MicronicCodeReader(\n scanner=TwainScanner(\n twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n twain_source=\"AVA6PlusG\",\n ),\n serial_port=\"COM4\",\n image_dir=r\"C:\\ProgramData\\PyLabRobot\\micronic-images\",\n keep_images=True,\n)\nawait reader.setup()\n\ntry:\n rack_result = await reader.scan_rack(rack=rack, timeout=90.0)\n print(rack_result.rack_id)\n print(len([entry for entry in rack_result.entries if entry.tube_id]))\n\n rack_id = await reader.scan_rack_id(timeout=5.0)\n print(rack_id)\nfinally:\n await reader.stop()", + "source": [ + "## Read the rack ID\n", + "\n", + "`scan_rack_id()` triggers only the side barcode reader. It returns the decoded identifier or\n", + "`NOREAD`." + ] + }, + { + "cell_type": "code", "execution_count": null, + "id": "micronic-rack-id-code", + "metadata": {}, "outputs": [], - "id": "windows-example" + "source": [ + "rack_id = await reader.scan_rack_id(timeout=5.0)\n", + "print(rack_id)" + ] }, { "cell_type": "markdown", + "id": "micronic-rack-resource", "metadata": {}, - "source": "On Ubuntu/Linux, use `SaneScanner` if the scanner is exposed by a SANE backend:", - "id": "linux-intro" + "source": [ + "## Represent the rack\n", + "\n", + "`scan_rack()` accepts a `TubeRack` with exactly 8 rows and 12 columns. Define the rack layout\n", + "before scanning it." + ] }, { "cell_type": "code", + "execution_count": null, + "id": "micronic-rack-resource-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import ResourceHolder, TubeRack, create_ordered_items_2d\n", + "\n", + "rack = TubeRack(\n", + " name=\"micronic_96_tube_rack\",\n", + " size_x=85.0,\n", + " size_y=127.0,\n", + " size_z=20.0,\n", + " ordered_items=create_ordered_items_2d(\n", + " ResourceHolder,\n", + " num_items_x=12,\n", + " num_items_y=8,\n", + " dx=0,\n", + " dy=0,\n", + " dz=0,\n", + " item_dx=9.0,\n", + " item_dy=9.0,\n", + " size_x=9.0,\n", + " size_y=9.0,\n", + " size_z=20.0,\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "micronic-scan-rack", "metadata": {}, - "source": "from pylabrobot.micronic import MicronicCodeReader, SaneScanner\n\nreader = MicronicCodeReader(\n scanner=SaneScanner(sane_device=\"avision:libusb:001:004\"),\n serial_port=\"/dev/ttyUSB0\",\n)", + "source": [ + "## Scan the rack\n", + "\n", + "`scan_rack()` reads the side rack barcode, acquires the rack image, and returns one entry for\n", + "each tube position. A position reports `NOREAD` when no tube barcode was decoded." + ] + }, + { + "cell_type": "code", "execution_count": null, + "id": "micronic-scan-rack-code", + "metadata": {}, "outputs": [], - "id": "linux-example" + "source": [ + "result = await reader.scan_rack(rack=rack, timeout=90.0)\n", + "print(result.rack_id)\n", + "print(len([entry for entry in result.entries if entry.status == \"OK\"]))" + ] }, { "cell_type": "markdown", + "id": "micronic-disconnect", + "metadata": {}, + "source": [ + "## Disconnect\n", + "\n", + "`stop()` closes the side reader's serial connection." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "micronic-disconnect-code", "metadata": {}, - "source": "For any other acquisition stack, subclass `Scanner` and implement `acquire` to\nwrite a rack image to the given path.\n\n## Notes\n\n- `scan_rack` reads the rack ID and every tube barcode, so it typically takes\n tens of seconds. `scan_rack_id` only reads the rack barcode and completes in a\n few seconds.\n- TWAIN is a Windows scanner-driver API. PyLabRobot does not ship a TWAIN\n bridge binary and does not install one for you; pass `twain_scanner_path` to\n `TwainScanner`, set `MICRONIC_TWAIN_SCANNER_PATH`, or put a local helper\n named `twain_scan` / `twain_scan.exe` on `PATH`.\n- Ubuntu/Linux scanner control uses SANE `scanimage` (via `SaneScanner`).\n PyLabRobot does not install SANE or vendor scanner drivers. Rack-ID reads use\n `pylabrobot.io.Serial`, which is installed through the `pylabrobot[serial]`\n extra.\n- Image decoding imports `pillow`, `opencv-python-headless`, `numpy`, and\n `zxing-cpp` at runtime. Install them in the environment that runs PyLabRobot.", - "id": "scanner-notes" + "outputs": [], + "source": [ + "await reader.stop()" + ] } ], "metadata": { @@ -42,8 +212,7 @@ "name": "python3" }, "language_info": { - "name": "python", - "version": "3.12.0" + "name": "python" } }, "nbformat": 4, diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 9fd637a25e4..1a15f78e3c7 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -17,8 +17,9 @@ import time from dataclasses import dataclass from datetime import datetime +from functools import partial from pathlib import Path -from typing import Iterable, Literal, Optional +from typing import Any, Iterable, Iterator, Literal, Optional from pylabrobot.io.serial import Serial from pylabrobot.resources.barcode import Barcode @@ -37,13 +38,27 @@ @dataclass(frozen=True) class DecodeResult: + """A tube barcode decoded from a rack image. + + Attributes: + tube_id: Ten-digit tube identifier. + method: Image-decoding strategy that produced the result. + """ + tube_id: str method: str @dataclass class RackScanEntry: - """One decoded rack position.""" + """One decoded rack position. + + Attributes: + position: Rack position such as ``"A1"``. + tube_id: Decoded tube identifier, or ``None`` when no code was read. + status: ``"OK"`` when a tube code was read, otherwise ``"NOREAD"``. + barcode: Structured representation of the decoded tube barcode. + """ position: str tube_id: Optional[str] @@ -53,7 +68,13 @@ class RackScanEntry: @dataclass class RackScanResult: - """The rack identifier and position-indexed tube scan results.""" + """The rack identifier and position-indexed tube scan results. + + Attributes: + rack_id: Side barcode value, or ``"NOREAD"`` when no rack code was read. + entries: Results for all 96 positions in row-major order. + rack_barcode: Structured representation of the rack barcode. + """ rack_id: str entries: list[RackScanEntry] @@ -61,7 +82,16 @@ class RackScanResult: class MicronicCodeReader: - """Control a Micronic rack scanner without the OEM application.""" + """Control a Micronic rack scanner without the OEM application. + + Args: + scanner: Image acquisition implementation for the flatbed scanner. + serial_port: Port for the side rack-barcode reader. + image_dir: Directory for temporary or retained rack images. + scanner_timeout: Image acquisition timeout in seconds. + serial_timeout: Side barcode read timeout in seconds. + keep_images: Preserve acquired images after decoding when ``True``. + """ def __init__( self, @@ -72,6 +102,15 @@ def __init__( serial_timeout: float = 2.5, keep_images: bool = False, ) -> None: + """Initialize the code reader and its serial transport. + + Raises: + ValueError: If either device timeout is not positive. + """ + if scanner_timeout <= 0: + raise ValueError("scanner_timeout must be positive") + if serial_timeout <= 0: + raise ValueError("serial_timeout must be positive") self.scanner = scanner self.image_dir = ( Path(image_dir) if image_dir else Path(tempfile.gettempdir()) / "pylabrobot-micronic" @@ -97,15 +136,34 @@ def __init__( async def setup(self) -> None: """Create the image directory and connect to the side barcode reader.""" self.image_dir.mkdir(parents=True, exist_ok=True) - await self.io.setup() + await self.scanner.setup() + try: + await self.io.setup() + except BaseException: + try: + await self.scanner.stop() + except Exception: + logger.warning("Failed to stop Micronic scanner after setup failure", exc_info=True) + raise logger.info("Set up Micronic code reader") async def stop(self) -> None: - """Disconnect from the side barcode reader.""" - await self.io.stop() + """Disconnect from the side barcode reader and image scanner.""" + try: + await self.io.stop() + finally: + await self.scanner.stop() logger.info("Stopped Micronic code reader") async def _read_barcode(self) -> str: + """Trigger and parse one side rack-barcode read. + + Returns: + The first sequence of at least six digits, or ``"NOREAD"``. + + Raises: + MicronicError: If serial communication fails. + """ deadline = time.monotonic() + self.serial_timeout chunks: list[bytes] = [] try: @@ -118,6 +176,7 @@ async def _read_barcode(self) -> str: if value in {b"\r", b"\n"}: break except Exception as exc: + logger.exception("Micronic rack ID serial read failed") raise MicronicError( "Rack ID serial read failed. Install the PLR serial extra with " "`pip install pylabrobot[serial]` and verify the serial port: " @@ -127,28 +186,38 @@ async def _read_barcode(self) -> str: match = re.search(r"\d{6,}", text) return match.group(0) if match else "NOREAD" - def _acquire_image(self) -> Path: + async def _acquire_image(self) -> Path: + """Acquire one rack image and retain its scanner metadata.""" self.image_dir.mkdir(parents=True, exist_ok=True) image_path = ( self.image_dir / f"micronic_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.{self.scanner.image_extension}" ) - self.last_scan_metadata = self.scanner.acquire( - image_path, max(1, int(self.scanner_timeout * 1000)) - ) + try: + self.last_scan_metadata = await self.scanner.acquire(image_path, self.scanner_timeout) + except asyncio.CancelledError: + self._release_image(image_path) + raise + except Exception: + self._release_image(image_path) + raise self.last_image_path = image_path return image_path def _release_image(self, image_path: Path) -> None: + """Delete an acquired image unless image retention is enabled.""" if not self.keep_images: try: image_path.unlink() self.last_image_path = None + except FileNotFoundError: + self.last_image_path = None except OSError: - pass + logger.warning("Could not remove Micronic rack image %s", image_path, exc_info=True) @staticmethod def _validate_rack(rack: TubeRack) -> None: + """Reject rack resources that do not represent an 8x12 layout.""" if rack.num_items_x != RACK_COLS or rack.num_items_y != RACK_ROWS: raise MicronicError( f"Micronic code reader only supports {RACK_ROWS}x{RACK_COLS} racks; " @@ -156,93 +225,169 @@ def _validate_rack(rack: TubeRack) -> None: ) async def scan_rack(self, rack: TubeRack, timeout: float = 90.0) -> RackScanResult: - """Scan the rack ID and all tube positions in an 8x12 rack.""" - return await asyncio.wait_for(self._scan_rack(rack), timeout=timeout) + """Scan the rack ID and all tube positions in an 8x12 rack. + + Args: + rack: Rack resource whose 96 positions correspond to the scanner layout. + timeout: Overall operation timeout in seconds. + + Returns: + Rack and tube barcode results. + + Raises: + MicronicError: If the rack shape is unsupported, a scan is already running, image + acquisition fails, or decoding fails. + asyncio.TimeoutError: If the operation exceeds ``timeout``. + """ + logger.info("Starting Micronic rack scan") + try: + result = await asyncio.wait_for(self._scan_rack(rack), timeout=timeout) + except Exception: + logger.exception("Micronic rack scan failed") + raise + logger.info( + "Completed Micronic rack scan: rack_id=%s decoded=%d", + result.rack_id, + sum(entry.status == "OK" for entry in result.entries), + ) + return result async def scan_rack_id(self, timeout: float = 5.0) -> str: - """Read and return the side rack barcode, or ``"NOREAD"`` if none is decoded.""" - return await asyncio.wait_for(self._read_barcode(), timeout=timeout) + """Read the side rack barcode. + + Args: + timeout: Overall operation timeout in seconds. + + Returns: + The decoded rack identifier, or ``"NOREAD"``. + + Raises: + MicronicError: If serial communication fails. + asyncio.TimeoutError: If the operation exceeds ``timeout``. + """ + try: + rack_id = await asyncio.wait_for(self._read_barcode(), timeout=timeout) + except Exception: + logger.exception("Micronic rack ID scan failed") + raise + logger.info("Completed Micronic rack ID scan: rack_id=%s", rack_id) + return rack_id async def _scan_rack(self, rack: TubeRack) -> RackScanResult: + """Coordinate one cancellation-safe rack scan.""" self._validate_rack(rack) if self._scan_lock.locked(): raise MicronicError("Micronic rack scan is already in progress.") await self._scan_lock.acquire() release_lock = True + image_path: Optional[Path] = None try: rack_id = await self._read_barcode() + image_path = await self._acquire_image() loop = asyncio.get_running_loop() - scan_future = loop.run_in_executor(None, self._scan_rack_blocking, rack_id, rack.num_items) + scan_future = loop.run_in_executor( + None, + self._decode_rack_image, + image_path, + rack_id, + rack.num_items, + ) try: return await asyncio.shield(scan_future) except asyncio.CancelledError: release_lock = False - scan_future.add_done_callback(self._finish_cancelled_scan) + scan_future.add_done_callback(partial(self._finish_cancelled_scan, image_path=image_path)) + image_path = None raise finally: + if image_path is not None: + self._release_image(image_path) if release_lock: self._release_scan_lock() - def _finish_cancelled_scan(self, future: asyncio.Future[RackScanResult]) -> None: + def _finish_cancelled_scan( + self, + future: asyncio.Future[RackScanResult], + *, + image_path: Path, + ) -> None: + """Release scan resources after a cancelled caller's decode finishes.""" try: - future.exception() + exception = future.exception() except asyncio.CancelledError: - pass + exception = None + if exception is not None: + logger.error("Cancelled Micronic rack scan later failed: %s", exception) + self._release_image(image_path) self._release_scan_lock() def _release_scan_lock(self) -> None: + """Release the scan lock if it is currently held.""" if self._scan_lock.locked(): self._scan_lock.release() - def _scan_rack_blocking(self, rack_id: str, expected_well_count: int) -> RackScanResult: - image_path = self._acquire_image() + def _decode_rack_image( + self, + image_path: Path, + rack_id: str, + expected_well_count: int, + ) -> RackScanResult: + """Decode an acquired rack image in a worker thread.""" + decoded, self.last_decode_metadata = decode_image(image_path) + if len(decoded) < expected_well_count: + missing = ", ".join(position for position in iter_positions() if position not in decoded) + raise MicronicError( + f"Micronic decode found {len(decoded)} wells; expected at least " + f"{expected_well_count}. Missing: {missing}" + ) - try: - decoded, self.last_decode_metadata = decode_image(image_path) - if len(decoded) < expected_well_count: - missing = ", ".join(position for position in iter_positions() if position not in decoded) - raise MicronicError( - f"Micronic decode found {len(decoded)} wells; expected at least " - f"{expected_well_count}. Missing: {missing}" - ) - - for position, result in decoded.items(): - logger.debug("Micronic decoded %s via %s", position, result.method) - - entries = [ - RackScanEntry( - position=position, - tube_id=decoded[position].tube_id if position in decoded else None, - status="OK" if position in decoded else "NOREAD", - barcode=( - Barcode( - data=decoded[position].tube_id, - symbology="DataMatrix", - position_on_resource="bottom", - ) - if position in decoded - else None - ), - ) - for position in iter_positions() - ] - - return RackScanResult( - rack_id=rack_id, - entries=entries, - rack_barcode=Barcode( - data=rack_id, - symbology="Code 128 (Subset B and C)", - position_on_resource="right", - ) - if rack_id != "NOREAD" - else None, + for position, result in decoded.items(): + logger.debug("Micronic decoded %s via %s", position, result.method) + + entries = [ + RackScanEntry( + position=position, + tube_id=decoded[position].tube_id if position in decoded else None, + status="OK" if position in decoded else "NOREAD", + barcode=( + Barcode( + data=decoded[position].tube_id, + symbology="DataMatrix", + position_on_resource="bottom", + ) + if position in decoded + else None + ), ) - finally: - self._release_image(image_path) + for position in iter_positions() + ] + + return RackScanResult( + rack_id=rack_id, + entries=entries, + rack_barcode=Barcode( + data=rack_id, + symbology="Code 128 (Subset B and C)", + position_on_resource="right", + ) + if rack_id != "NOREAD" + else None, + ) def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, object]]: + """Decode all tube barcodes in a Micronic rack image. + + Args: + image_path: Path to the acquired rack image. + + Returns: + Position-indexed decode results and diagnostic metadata. + + Raises: + MicronicError: If dependencies are missing, the grid cannot be calibrated, or duplicate tube + identifiers are found. + """ cv2, np, zxingcpp, Image, ImageOps = import_decode_dependencies() with Image.open(image_path) as loaded_image: image = loaded_image.convert("L") @@ -323,7 +468,15 @@ def decode_image(image_path: Path) -> tuple[dict[str, DecodeResult], dict[str, o return decoded, metadata -def import_decode_dependencies(): +def import_decode_dependencies() -> tuple[Any, Any, Any, Any, Any]: + """Import optional image-decoding dependencies on demand. + + Returns: + The OpenCV, NumPy, zxing-cpp, Pillow Image, and Pillow ImageOps modules. + + Raises: + MicronicError: If any decoding dependency is unavailable. + """ try: import cv2 # type: ignore import numpy as np # type: ignore @@ -338,6 +491,19 @@ def import_decode_dependencies(): def cluster_axis(values: list[float], expected_count: int, tolerance: float) -> list[float]: + """Cluster detected coordinates along one scanner axis. + + Args: + values: Detected barcode coordinates. + expected_count: Number of rack rows or columns expected on the axis. + tolerance: Maximum gap within one cluster, in image pixels. + + Returns: + Cluster centers, interpolated to ``expected_count`` when necessary. + + Raises: + MicronicError: If fewer than two usable clusters can be found. + """ if not values: raise MicronicError("No decoded barcode positions are available for grid calibration.") @@ -366,6 +532,7 @@ def cluster_axis(values: list[float], expected_count: int, tolerance: float) -> def fitted_axis(means: list[float], expected_count: int) -> list[float]: + """Fit evenly spaced coordinates between the first and last cluster centers.""" return [ means[0] + index * (means[-1] - means[0]) / (expected_count - 1) for index in range(expected_count) @@ -373,22 +540,33 @@ def fitted_axis(means: list[float], expected_count: int) -> list[float]: def rack_position(scan_row: int, scan_col: int) -> str: + """Map scanner-oriented grid coordinates to a rack position name.""" return f"{ROWS[RACK_ROWS - 1 - scan_col]}{RACK_COLS - scan_row}" def iter_positions() -> Iterable[str]: + """Yield every 8x12 rack position in row-major order.""" for row in ROWS: for column in range(1, COLS + 1): yield f"{row}{column}" def is_tube_id(value: object) -> bool: + """Return whether a decoded value is a ten-digit Micronic tube identifier.""" return isinstance(value, str) and value.isdigit() and len(value) == 10 def decode_well_crop( - image, center_x, center_y, cv2, np, zxingcpp, Image, ImageOps + image: Any, + center_x: float, + center_y: float, + cv2: Any, + np: Any, + zxingcpp: Any, + Image: Any, + ImageOps: Any, ) -> Optional[DecodeResult]: + """Decode one well using progressively larger direct and perspective-corrected crops.""" for size in [150, 160, 180, 200, 220, 240]: crop = centered_crop(image, center_x, center_y, size) decoded = decode_pil_variants(crop, zxingcpp, ImageOps) @@ -404,7 +582,8 @@ def decode_well_crop( return None -def centered_crop(image, center_x: float, center_y: float, size: int): +def centered_crop(image: Any, center_x: float, center_y: float, size: int) -> Any: + """Return a square image crop centered on scanner coordinates.""" half = size / 2 return image.crop( ( @@ -416,7 +595,8 @@ def centered_crop(image, center_x: float, center_y: float, size: int): ) -def decode_pil_variants(crop, zxingcpp, ImageOps) -> Optional[str]: +def decode_pil_variants(crop: Any, zxingcpp: Any, ImageOps: Any) -> Optional[str]: + """Try original, autocontrasted, and equalized variants of an image crop.""" for variant in [crop, ImageOps.autocontrast(crop), ImageOps.equalize(crop)]: decoded = decode_with_zxing(variant, zxingcpp, ImageOps) if decoded: @@ -424,7 +604,8 @@ def decode_pil_variants(crop, zxingcpp, ImageOps) -> Optional[str]: return None -def decode_with_zxing(image, zxingcpp, ImageOps) -> Optional[str]: +def decode_with_zxing(image: Any, zxingcpp: Any, ImageOps: Any) -> Optional[str]: + """Search image scales, polarity, borders, and binarizers for a tube identifier.""" binarizers = [ zxingcpp.Binarizer.LocalAverage, zxingcpp.Binarizer.GlobalHistogram, @@ -453,7 +634,8 @@ def decode_with_zxing(image, zxingcpp, ImageOps) -> Optional[str]: return None -def order_box(points, np): +def order_box(points: Any, np: Any) -> Any: + """Order four rectangle corners clockwise from the top-left corner.""" points = np.array(points, dtype=np.float32) sums = points.sum(axis=1) diffs = np.diff(points, axis=1).ravel() @@ -468,7 +650,15 @@ def order_box(points, np): ) -def decode_perspective_crop(crop, cv2, np, zxingcpp, Image, ImageOps) -> Optional[str]: +def decode_perspective_crop( + crop: Any, + cv2: Any, + np: Any, + zxingcpp: Any, + Image: Any, + ImageOps: Any, +) -> Optional[str]: + """Locate, rectify, and decode a DataMatrix candidate within one well crop.""" crop_array = np.array(crop) for threshold in [30, 40, 50, 60, 70, 80, 90, 100, 120, 140]: mask = (crop_array < threshold).astype(np.uint8) * 255 @@ -510,7 +700,8 @@ def decode_perspective_crop(crop, cv2, np, zxingcpp, Image, ImageOps) -> Optiona return None -def candidate_masks(mask, cv2, np): +def candidate_masks(mask: Any, cv2: Any, np: Any) -> Iterator[Any]: + """Yield the raw threshold mask and its centered connected components.""" yield mask number, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, 8) combined = np.zeros_like(mask) @@ -528,7 +719,14 @@ def candidate_masks(mask, cv2, np): yield combined -def perspective_variants(warped, threshold: int, cv2, Image, ImageOps): +def perspective_variants( + warped: Any, + threshold: int, + cv2: Any, + Image: Any, + ImageOps: Any, +) -> Iterator[Any]: + """Yield grayscale and binary variants of a rectified DataMatrix candidate.""" yield Image.fromarray(warped) yield ImageOps.autocontrast(Image.fromarray(warped)) _, binary = cv2.threshold(warped, min(220, threshold + 70), 255, cv2.THRESH_BINARY) @@ -537,6 +735,7 @@ def perspective_variants(warped, threshold: int, cv2, Image, ImageOps): def find_duplicate_ids(decoded: dict[str, DecodeResult]) -> list[str]: + """Return tube identifiers assigned to more than one rack position.""" seen: dict[str, str] = {} duplicates: list[str] = [] for position, result in decoded.items(): diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index a49582a00e6..0ca0d42d7b6 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -6,8 +6,17 @@ from typing import cast from unittest.mock import AsyncMock, MagicMock, patch +from pylabrobot.io.command_line import CommandLineResult, CommandLineTransport from pylabrobot.micronic import MicronicCodeReader, MicronicError, SaneScanner, TwainScanner -from pylabrobot.micronic.code_reader.driver import DecodeResult +from pylabrobot.micronic.code_reader.driver import ( + DecodeResult, + cluster_axis, + decode_image, + find_duplicate_ids, + is_tube_id, + iter_positions, + rack_position, +) from pylabrobot.resources.tube_rack import TubeRack @@ -20,13 +29,30 @@ def _rack(num_items_x: int = 12, num_items_y: int = 8, num_items: int = 96) -> T def _mock_scanner(image_extension: str = "bmp") -> MagicMock: + """Return a scanner mock that succeeds without accessing hardware.""" scanner = MagicMock() scanner.image_extension = image_extension - scanner.acquire = MagicMock(return_value={"source": "test"}) + scanner.setup = AsyncMock() + scanner.stop = AsyncMock() + scanner.acquire = AsyncMock(return_value={"source": "test"}) return scanner +def _mock_command_line( + executable: str, + result: CommandLineResult = CommandLineResult(0, "", ""), +) -> MagicMock: + """Return a configured command-line I/O mock.""" + command_line = MagicMock(spec=CommandLineTransport) + command_line.executable = executable + command_line.setup = AsyncMock() + command_line.stop = AsyncMock() + command_line.run = AsyncMock(return_value=result) + return command_line + + def _run_inline(_executor, function, *args): + """Execute an executor callback inline and return its result as a future.""" future = asyncio.get_running_loop().create_future() try: future.set_result(function(*args)) @@ -35,37 +61,35 @@ def _run_inline(_executor, function, *args): return future -class TestScannerClasses(unittest.TestCase): - def test_sane_scanner_invokes_scanimage(self): +class TestScannerClasses(unittest.IsolatedAsyncioTestCase): + """Tests for scanner command construction and error handling.""" + + async def test_sane_scanner_invokes_scanimage(self): with tempfile.TemporaryDirectory() as image_dir: output_path = Path(image_dir) / "rack.tiff" - with ( - patch( - "pylabrobot.micronic.code_reader.scanner.shutil.which", - return_value="/usr/bin/scanimage", - ), - patch( - "pylabrobot.micronic.code_reader.scanner._run_scan_command", - return_value={"source": "sane"}, - ) as run_scan_command, - ): - scanner = SaneScanner(sane_device="avision:libusb:001:004") - metadata = scanner.acquire(output_path, timeout_ms=1000) + output_path.touch() + command_line = _mock_command_line("/usr/bin/scanimage") + scanner = SaneScanner( + sane_device="avision:libusb:001:004", + command_line=command_line, + ) + await scanner.setup() + metadata = await scanner.acquire(output_path, timeout=1.0) + await scanner.stop() self.assertEqual(metadata["source"], "sane") self.assertEqual(scanner.image_extension, "tiff") - run_scan_command.assert_called_once_with( + command_line.setup.assert_awaited_once_with() + command_line.stop.assert_awaited_once_with() + command_line.run.assert_awaited_once_with( [ - "/usr/bin/scanimage", "--device-name", "avision:libusb:001:004", "--format=tiff", "--output-file", str(output_path), ], - output_path, - 1000, - source="sane", + timeout=16.0, ) def test_sane_scanner_raises_when_scanimage_missing(self): @@ -89,26 +113,77 @@ def test_twain_scanner_raises_when_helper_missing(self): with self.assertRaises(MicronicError): TwainScanner() - def test_twain_scanner_acquire_runs_helper(self): + async def test_twain_scanner_acquire_runs_helper(self): with tempfile.TemporaryDirectory() as image_dir: output_path = Path(image_dir) / "rack.bmp" - with patch( - "pylabrobot.micronic.code_reader.scanner._run_scan_command", - return_value={"source": "twain"}, - ) as run_scan_command: - scanner = TwainScanner(twain_scanner_path="/opt/twain_scan", twain_source="AVA6PlusG") - scanner.acquire(output_path, timeout_ms=1000) - - run_scan_command.assert_called_once_with( - ["/opt/twain_scan", str(output_path), "AVA6PlusG", "1000"], - output_path, - 1000, - source="twain", + output_path.touch() + command_line = _mock_command_line("/opt/twain_scan") + scanner = TwainScanner( + twain_source="AVA6PlusG", + command_line=command_line, + ) + await scanner.setup() + await scanner.acquire(output_path, timeout=1.25) + await scanner.stop() + + command_line.run.assert_awaited_once_with( + [str(output_path), "AVA6PlusG", "1250"], + timeout=16.25, ) + async def test_scanner_raises_on_helper_failure(self): + command_line = _mock_command_line( + "/opt/twain_scan", + result=CommandLineResult(2, "", "scanner fault"), + ) + scanner = TwainScanner(command_line=command_line) + + with self.assertRaisesRegex(MicronicError, "scanner fault"): + await scanner.acquire(Path("rack.bmp"), timeout=1.0) + + async def test_scanner_raises_when_helper_creates_no_image(self): + command_line = _mock_command_line("/opt/twain_scan") + scanner = TwainScanner(command_line=command_line) + + with tempfile.TemporaryDirectory() as image_dir: + with self.assertRaisesRegex(MicronicError, "did not create image"): + await scanner.acquire(Path(image_dir) / "rack.bmp", timeout=1.0) + + async def test_scanner_wraps_helper_timeout(self): + command_line = _mock_command_line("/opt/twain_scan") + command_line.run = AsyncMock(side_effect=asyncio.TimeoutError) + scanner = TwainScanner(command_line=command_line) + + with self.assertRaisesRegex(MicronicError, "timed out"): + await scanner.acquire(Path("rack.bmp"), timeout=1.0) + + async def test_scanner_wraps_missing_helper(self): + command_line = _mock_command_line("/missing/twain_scan") + command_line.run = AsyncMock(side_effect=FileNotFoundError) + scanner = TwainScanner(command_line=command_line) + + with self.assertRaisesRegex(MicronicError, "was not found"): + await scanner.acquire(Path("rack.bmp"), timeout=1.0) + class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): - def test_acquire_image_runs_scanner_and_tracks_metadata(self): + """Tests for the public Micronic code-reader operations.""" + + def test_rejects_non_positive_device_timeouts(self): + with self.assertRaisesRegex(ValueError, "scanner_timeout"): + MicronicCodeReader( + scanner=_mock_scanner(), + serial_port="/dev/ttyUSB0", + scanner_timeout=0, + ) + with self.assertRaisesRegex(ValueError, "serial_timeout"): + MicronicCodeReader( + scanner=_mock_scanner(), + serial_port="/dev/ttyUSB0", + serial_timeout=0, + ) + + async def test_acquire_image_runs_scanner_and_tracks_metadata(self): with tempfile.TemporaryDirectory() as image_dir: scanner = _mock_scanner() reader = MicronicCodeReader( @@ -118,14 +193,35 @@ def test_acquire_image_runs_scanner_and_tracks_metadata(self): scanner_timeout=1.25, keep_images=True, ) - image_path = reader._acquire_image() + image_path = await reader._acquire_image() self.assertEqual(reader.last_image_path, image_path) self.assertEqual(reader.last_scan_metadata, {"source": "test"}) - scanner.acquire.assert_called_once_with(image_path, 1250) + scanner.acquire.assert_awaited_once_with(image_path, 1.25) self.assertTrue(image_path.name.startswith("micronic_")) self.assertEqual(image_path.suffix, ".bmp") + async def test_acquire_image_removes_partial_image_after_failure(self): + async def fail_after_writing(output_path: Path, timeout: float) -> None: + """Create a partial output image before reporting acquisition failure.""" + del timeout + output_path.touch() + raise MicronicError("acquisition failed") + + with tempfile.TemporaryDirectory() as image_dir: + scanner = _mock_scanner() + scanner.acquire = AsyncMock(side_effect=fail_after_writing) + reader = MicronicCodeReader( + scanner=scanner, + serial_port="/dev/ttyUSB0", + image_dir=image_dir, + ) + + with self.assertRaisesRegex(MicronicError, "acquisition failed"): + await reader._acquire_image() + + self.assertEqual(list(Path(image_dir).iterdir()), []) + async def test_scan_rack_id_uses_plr_serial(self): instances: list[object] = [] @@ -153,7 +249,8 @@ async def stop(self): self.calls.append("stop") with patch("pylabrobot.micronic.code_reader.driver.Serial", FakeSerial): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + scanner = _mock_scanner() + reader = MicronicCodeReader(scanner=scanner, serial_port="/dev/ttyUSB0") await reader.setup() try: rack_id = await reader.scan_rack_id(timeout=1.0) @@ -170,6 +267,29 @@ async def stop(self): self.assertIn("reset_input_buffer", fake_serial.calls) self.assertIn("write:b'\\r\\n'", fake_serial.calls) self.assertEqual(fake_serial.calls[-1], "stop") + scanner.setup.assert_awaited_once_with() + scanner.stop.assert_awaited_once_with() + + async def test_setup_stops_scanner_when_serial_setup_fails(self): + scanner = _mock_scanner() + reader = MicronicCodeReader(scanner=scanner, serial_port="/dev/ttyUSB0") + + with patch.object(reader.io, "setup", AsyncMock(side_effect=OSError("serial failed"))): + with self.assertRaisesRegex(OSError, "serial failed"): + await reader.setup() + + scanner.setup.assert_awaited_once_with() + scanner.stop.assert_awaited_once_with() + + async def test_stop_releases_scanner_when_serial_stop_fails(self): + scanner = _mock_scanner() + reader = MicronicCodeReader(scanner=scanner, serial_port="/dev/ttyUSB0") + + with patch.object(reader.io, "stop", AsyncMock(side_effect=OSError("serial failed"))): + with self.assertRaisesRegex(OSError, "serial failed"): + await reader.stop() + + scanner.stop.assert_awaited_once_with() async def test_scan_rack_populates_result(self): with tempfile.TemporaryDirectory() as image_dir: @@ -209,7 +329,7 @@ async def test_scan_rack_populates_result(self): self.assertEqual(result.entries[1].tube_id, "2222222222") self.assertEqual(reader.last_scan_metadata, {"source": "test"}) self.assertEqual(reader.last_decode_metadata, {"decodedWells": 2}) - scanner.acquire.assert_called_once() + scanner.acquire.assert_awaited_once() read_barcode.assert_awaited_once() decode_image_mock.assert_called_once() @@ -243,7 +363,7 @@ async def test_reader_can_scan_twice(self): self.assertEqual(first.rack_id, "9500017722") self.assertEqual(second.rack_id, "9500017722") - self.assertEqual(scanner.acquire.call_count, 2) + self.assertEqual(scanner.acquire.await_count, 2) async def test_rejects_mismatched_rack_shape(self): reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") @@ -307,6 +427,109 @@ async def test_scan_rack_id_reads_barcode(self): self.assertEqual(rack_id, "9500017722") read_barcode.assert_awaited_once_with() + async def test_scan_rack_id_returns_noread_for_unrecognized_response(self): + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + with ( + patch.object(reader.io, "reset_input_buffer", AsyncMock()), + patch.object(reader.io, "write", AsyncMock()), + patch.object(reader.io, "read", AsyncMock(side_effect=[b"?", b"\r"])), + ): + rack_id = await reader.scan_rack_id(timeout=1.0) + + self.assertEqual(rack_id, "NOREAD") + + async def test_scan_rack_id_wraps_serial_error(self): + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + with patch.object( + reader.io, + "reset_input_buffer", + AsyncMock(side_effect=OSError("port disconnected")), + ): + with self.assertRaisesRegex(MicronicError, "port disconnected"): + await reader.scan_rack_id(timeout=1.0) + + def test_decode_rack_rejects_missing_wells(self): + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} + + with patch( + "pylabrobot.micronic.code_reader.driver.decode_image", + return_value=(decoded, {"decodedWells": 1}), + ): + with self.assertRaisesRegex(MicronicError, "expected at least 2"): + reader._decode_rack_image(Path("rack.bmp"), "9500017722", expected_well_count=2) + + def test_decode_rack_represents_missing_rack_id(self): + reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} + + with patch( + "pylabrobot.micronic.code_reader.driver.decode_image", + return_value=(decoded, {"decodedWells": 1}), + ): + result = reader._decode_rack_image(Path("rack.bmp"), "NOREAD", expected_well_count=1) + + self.assertIsNone(result.rack_barcode) + self.assertEqual(result.entries[0].status, "OK") + self.assertEqual(result.entries[1].status, "NOREAD") + + +class TestDecodeHelpers(unittest.TestCase): + """Tests for image-decoding validation and rack-coordinate parsing.""" + + def test_tube_id_validation(self): + self.assertTrue(is_tube_id("0123456789")) + self.assertFalse(is_tube_id("123456789")) + self.assertFalse(is_tube_id("12345A7890")) + self.assertFalse(is_tube_id(1234567890)) + + def test_rack_position_maps_scanner_orientation(self): + self.assertEqual(rack_position(scan_row=0, scan_col=0), "H12") + self.assertEqual(rack_position(scan_row=11, scan_col=7), "A1") + + def test_iter_positions_is_row_major(self): + positions = list(iter_positions()) + self.assertEqual(len(positions), 96) + self.assertEqual(positions[:2], ["A1", "A2"]) + self.assertEqual(positions[-2:], ["H11", "H12"]) + + def test_cluster_axis_uses_detected_centers(self): + self.assertEqual(cluster_axis([0.0, 2.0, 100.0, 102.0], 2, 10.0), [1.0, 101.0]) + + def test_cluster_axis_interpolates_missing_centers(self): + self.assertEqual(cluster_axis([0.0, 50.0, 100.0], 5, 10.0), [0.0, 25.0, 50.0, 75.0, 100.0]) + + def test_cluster_axis_rejects_insufficient_data(self): + with self.assertRaisesRegex(MicronicError, "Could not fit"): + cluster_axis([1.0, 2.0], 8, 10.0) + + def test_duplicate_ids_are_sorted_and_unique(self): + decoded = { + "A1": DecodeResult("2222222222", "test"), + "A2": DecodeResult("1111111111", "test"), + "A3": DecodeResult("2222222222", "test"), + "A4": DecodeResult("1111111111", "test"), + "A5": DecodeResult("1111111111", "test"), + } + self.assertEqual(find_duplicate_ids(decoded), ["1111111111", "2222222222"]) + + def test_decode_image_rejects_too_few_full_image_codes(self): + image = MagicMock() + image.size = (100, 100) + image_context = MagicMock() + image_context.__enter__.return_value.convert.return_value = image + image_module = MagicMock() + image_module.open.return_value = image_context + zxingcpp = MagicMock() + zxingcpp.read_barcodes.return_value = [] + + with patch( + "pylabrobot.micronic.code_reader.driver.import_decode_dependencies", + return_value=(MagicMock(), MagicMock(), zxingcpp, image_module, MagicMock()), + ): + with self.assertRaisesRegex(MicronicError, "Only 0 DataMatrix"): + decode_image(Path("rack.bmp")) + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/micronic/code_reader/scanner.py b/pylabrobot/micronic/code_reader/scanner.py index 5a4f79224bc..d52e256b1e0 100644 --- a/pylabrobot/micronic/code_reader/scanner.py +++ b/pylabrobot/micronic/code_reader/scanner.py @@ -2,13 +2,15 @@ from __future__ import annotations +import asyncio import os import shutil -import subprocess # nosec B404 - local scanner helper execution is the interface. from abc import ABC, abstractmethod from pathlib import Path from typing import Optional, Sequence +from pylabrobot.io.command_line import CommandLineTransport + from .errors import MicronicError @@ -17,9 +19,23 @@ class Scanner(ABC): image_extension: str + async def setup(self) -> None: + """Prepare the scanner for image acquisition.""" + + async def stop(self) -> None: + """Release scanner acquisition resources.""" + @abstractmethod - def acquire(self, output_path: Path, timeout_ms: int) -> dict[str, object]: - """Write a rack image to ``output_path`` and return acquisition metadata.""" + async def acquire(self, output_path: Path, timeout: float) -> dict[str, object]: + """Write a rack image to ``output_path``. + + Args: + output_path: Destination for the acquired image. + timeout: Scanner acquisition timeout in seconds. + + Returns: + Metadata describing the scanner command. + """ class TwainScanner(Scanner): @@ -36,19 +52,66 @@ def __init__( self, twain_scanner_path: Optional[str] = None, twain_source: str = "AVA6PlusG", - ): - resolved = twain_scanner_path or _resolve_twain_scanner_path() - if resolved is None: - raise MicronicError( - "No TWAIN helper was found. Pass twain_scanner_path, set " - "MICRONIC_TWAIN_SCANNER_PATH, or put twain_scan on PATH." + command_line: Optional[CommandLineTransport] = None, + ) -> None: + """Initialize a TWAIN scanner. + + Args: + twain_scanner_path: Path to the operator-installed TWAIN helper. When omitted, resolve it + from ``MICRONIC_TWAIN_SCANNER_PATH`` or ``PATH``. + twain_source: TWAIN source name passed to the helper. + command_line: Configured command-line transport. When supplied, its executable takes + precedence over ``twain_scanner_path``. + + Raises: + MicronicError: If no TWAIN helper can be resolved. + """ + if command_line is None: + resolved = twain_scanner_path or _resolve_twain_scanner_path() + if resolved is None: + raise MicronicError( + "No TWAIN helper was found. Pass twain_scanner_path, set " + "MICRONIC_TWAIN_SCANNER_PATH, or put twain_scan on PATH." + ) + command_line = CommandLineTransport( + human_readable_device_name="Micronic TWAIN rack scanner", + executable=resolved, ) - self.twain_scanner_path = resolved + self.command_line = command_line + self.twain_scanner_path = command_line.executable self.twain_source = twain_source - def acquire(self, output_path: Path, timeout_ms: int) -> dict[str, object]: - command = [self.twain_scanner_path, str(output_path), self.twain_source, str(timeout_ms)] - return _run_scan_command(command, output_path, timeout_ms, source="twain") + async def setup(self) -> None: + """Resolve and prepare the TWAIN helper executable.""" + try: + await self.command_line.setup() + except FileNotFoundError as exc: + raise MicronicError(str(exc)) from exc + self.twain_scanner_path = self.command_line.executable + + async def stop(self) -> None: + """Stop the TWAIN helper transport.""" + await self.command_line.stop() + + async def acquire(self, output_path: Path, timeout: float) -> dict[str, object]: + """Acquire a BMP image through the configured TWAIN helper. + + Args: + output_path: Destination for the acquired image. + timeout: Scanner acquisition timeout in seconds. + + Returns: + Metadata describing the scanner command. + """ + timeout_ms = max(1, int(timeout * 1000)) + arguments = [str(output_path), self.twain_source, str(timeout_ms)] + return await _run_scan_command( + self.command_line, + arguments, + output_path, + timeout, + source="twain", + ) class SaneScanner(Scanner): @@ -60,37 +123,96 @@ def __init__( self, sane_device: Optional[str] = None, scanimage_path: Optional[str] = None, - ): - resolved = scanimage_path or shutil.which("scanimage") - if resolved is None: - raise MicronicError("scanimage was not found on PATH. Install SANE or pass scanimage_path.") - self.scanimage_path = resolved + command_line: Optional[CommandLineTransport] = None, + ) -> None: + """Initialize a SANE scanner. + + Args: + sane_device: Optional SANE device identifier passed to ``scanimage``. + scanimage_path: Path to ``scanimage``. When omitted, resolve it from ``PATH``. + command_line: Configured command-line transport. When supplied, its executable takes + precedence over ``scanimage_path``. + + Raises: + MicronicError: If ``scanimage`` cannot be resolved. + """ + if command_line is None: + resolved = scanimage_path or shutil.which("scanimage") + if resolved is None: + raise MicronicError("scanimage was not found on PATH. Install SANE or pass scanimage_path.") + command_line = CommandLineTransport( + human_readable_device_name="Micronic SANE rack scanner", + executable=resolved, + ) + self.command_line = command_line + self.scanimage_path = command_line.executable self.sane_device = sane_device - def acquire(self, output_path: Path, timeout_ms: int) -> dict[str, object]: - command = [self.scanimage_path] + async def setup(self) -> None: + """Resolve and prepare the ``scanimage`` executable.""" + try: + await self.command_line.setup() + except FileNotFoundError as exc: + raise MicronicError(str(exc)) from exc + self.scanimage_path = self.command_line.executable + + async def stop(self) -> None: + """Stop the SANE command-line transport.""" + await self.command_line.stop() + + async def acquire(self, output_path: Path, timeout: float) -> dict[str, object]: + """Acquire a TIFF image through ``scanimage``. + + Args: + output_path: Destination for the acquired image. + timeout: Scanner acquisition timeout in seconds. + + Returns: + Metadata describing the scanner command. + """ + arguments: list[str] = [] if self.sane_device: - command.extend(["--device-name", self.sane_device]) - command.extend(["--format=tiff", "--output-file", str(output_path)]) - return _run_scan_command(command, output_path, timeout_ms, source="sane") + arguments.extend(["--device-name", self.sane_device]) + arguments.extend(["--format=tiff", "--output-file", str(output_path)]) + return await _run_scan_command( + self.command_line, + arguments, + output_path, + timeout, + source="sane", + ) -def _run_scan_command( - command: Sequence[str], +async def _run_scan_command( + command_line: CommandLineTransport, + arguments: Sequence[str], output_path: Path, - timeout_ms: int, + timeout: float, source: str, ) -> dict[str, object]: + """Run a scanner helper and validate its output image. + + Args: + command_line: Transport used to execute the scanner helper. + arguments: Arguments for the configured helper executable. + output_path: Image path the helper must create. + timeout: Scanner acquisition timeout in seconds. + source: Scanner backend name stored in the returned metadata. + + Returns: + Scanner command metadata. + + Raises: + MicronicError: If the helper is missing, times out, fails, or creates no image. + """ try: - completed = subprocess.run( # nosec B603 - operator-configured command, shell=False. - list(command), - check=False, - capture_output=True, - text=True, - timeout=(timeout_ms / 1000) + 15, - ) + completed = await command_line.run(arguments, timeout=timeout + 15) except FileNotFoundError as exc: - raise MicronicError(f"Scan command was not found: {command[0]}") from exc + raise MicronicError(f"Scan command was not found: {command_line.executable}") from exc + except asyncio.TimeoutError as exc: + raise MicronicError( + f"Scan command timed out after {timeout:g} seconds: {command_line.executable}" + ) from exc if completed.returncode != 0: raise MicronicError( @@ -103,11 +225,12 @@ def _run_scan_command( "stdout": completed.stdout.strip(), "stderr": completed.stderr.strip(), "source": source, - "command": list(command), + "command": [command_line.executable, *arguments], } def _resolve_twain_scanner_path() -> Optional[str]: + """Resolve the operator-installed TWAIN helper path, if available.""" return ( os.environ.get("MICRONIC_TWAIN_SCANNER_PATH") or shutil.which("twain_scan.exe") From 0ed217c9e45e1581e2a77cf7d8129ab585b92726 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 18 Aug 2026 11:18:10 -0700 Subject: [PATCH 52/54] Allow partial Micronic rack scans --- pylabrobot/micronic/code_reader/driver.py | 13 +---------- .../micronic/code_reader/micronic_tests.py | 22 +++++++++++-------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 1a15f78e3c7..6a6ef1f4a73 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -290,7 +290,6 @@ async def _scan_rack(self, rack: TubeRack) -> RackScanResult: self._decode_rack_image, image_path, rack_id, - rack.num_items, ) try: return await asyncio.shield(scan_future) @@ -330,16 +329,9 @@ def _decode_rack_image( self, image_path: Path, rack_id: str, - expected_well_count: int, ) -> RackScanResult: """Decode an acquired rack image in a worker thread.""" decoded, self.last_decode_metadata = decode_image(image_path) - if len(decoded) < expected_well_count: - missing = ", ".join(position for position in iter_positions() if position not in decoded) - raise MicronicError( - f"Micronic decode found {len(decoded)} wells; expected at least " - f"{expected_well_count}. Missing: {missing}" - ) for position, result in decoded.items(): logger.debug("Micronic decoded %s via %s", position, result.method) @@ -522,10 +514,7 @@ def cluster_axis(values: list[float], expected_count: int, tolerance: float) -> if len(means) == expected_count: return means if len(means) >= 2: - return [ - means[0] + index * (means[-1] - means[0]) / (expected_count - 1) - for index in range(expected_count) - ] + return fitted_axis(means, expected_count) raise MicronicError( f"Could not fit {expected_count} grid clusters from {len(values)} decoded positions." ) diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 0ca0d42d7b6..57653f7400b 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -20,11 +20,10 @@ from pylabrobot.resources.tube_rack import TubeRack -def _rack(num_items_x: int = 12, num_items_y: int = 8, num_items: int = 96) -> TubeRack: +def _rack(num_items_x: int = 12, num_items_y: int = 8) -> TubeRack: rack = MagicMock(spec=TubeRack) rack.num_items_x = num_items_x rack.num_items_y = num_items_y - rack.num_items = num_items return rack @@ -313,7 +312,7 @@ async def test_scan_rack_populates_result(self): return_value=(decoded, {"decodedWells": 2}), ) as decode_image_mock, ): - result = await reader.scan_rack(_rack(num_items=2), timeout=1.0) + result = await reader.scan_rack(_rack(), timeout=1.0) self.assertEqual(result.rack_id, "9500017722") rack_barcode = result.rack_barcode @@ -356,8 +355,8 @@ async def test_reader_can_scan_twice(self): ): await reader.setup() try: - first = await reader.scan_rack(rack=_rack(num_items=1), timeout=1.0) - second = await reader.scan_rack(rack=_rack(num_items=1), timeout=1.0) + first = await reader.scan_rack(rack=_rack(), timeout=1.0) + second = await reader.scan_rack(rack=_rack(), timeout=1.0) finally: await reader.stop() @@ -448,7 +447,7 @@ async def test_scan_rack_id_wraps_serial_error(self): with self.assertRaisesRegex(MicronicError, "port disconnected"): await reader.scan_rack_id(timeout=1.0) - def test_decode_rack_rejects_missing_wells(self): + def test_decode_rack_returns_noread_for_missing_wells(self): reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} @@ -456,8 +455,13 @@ def test_decode_rack_rejects_missing_wells(self): "pylabrobot.micronic.code_reader.driver.decode_image", return_value=(decoded, {"decodedWells": 1}), ): - with self.assertRaisesRegex(MicronicError, "expected at least 2"): - reader._decode_rack_image(Path("rack.bmp"), "9500017722", expected_well_count=2) + result = reader._decode_rack_image(Path("rack.bmp"), "9500017722") + + self.assertEqual(len(result.entries), 96) + self.assertEqual(result.entries[0].status, "OK") + self.assertEqual(result.entries[1].status, "NOREAD") + self.assertIsNone(result.entries[1].tube_id) + self.assertIsNone(result.entries[1].barcode) def test_decode_rack_represents_missing_rack_id(self): reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") @@ -467,7 +471,7 @@ def test_decode_rack_represents_missing_rack_id(self): "pylabrobot.micronic.code_reader.driver.decode_image", return_value=(decoded, {"decodedWells": 1}), ): - result = reader._decode_rack_image(Path("rack.bmp"), "NOREAD", expected_well_count=1) + result = reader._decode_rack_image(Path("rack.bmp"), "NOREAD") self.assertIsNone(result.rack_barcode) self.assertEqual(result.entries[0].status, "OK") From 932417785637294791ad5ee367cb112375c60353 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Wed, 19 Aug 2026 13:46:12 -0700 Subject: [PATCH 53/54] Document Micronic RD235 model --- docs/_static/devices.json | 4 +- docs/api/pylabrobot.micronic.rst | 4 +- .../micronic/code_reader/hello-world.ipynb | 9 ++-- pylabrobot/micronic/__init__.py | 2 +- pylabrobot/micronic/code_reader/__init__.py | 2 +- pylabrobot/micronic/code_reader/driver.py | 6 +-- .../micronic/code_reader/micronic_tests.py | 42 +++++++++---------- 7 files changed, 35 insertions(+), 34 deletions(-) diff --git a/docs/_static/devices.json b/docs/_static/devices.json index 29758faef64..c6ec378ff72 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -808,13 +808,13 @@ { "id": "micronic-code-reader", "vendor": "Micronic", - "name": "Code Reader", + "name": "RD235", "kind": "barcode scanner", "capabilities": [ "barcode reading" ], "status": "full", - "api": "pylabrobot.micronic.MicronicCodeReader", + "api": "pylabrobot.micronic.MicronicRD235", "api_version": "v1", "code_slug": "micronic/code_reader", "doc_slug": "micronic/code_reader/hello-world", diff --git a/docs/api/pylabrobot.micronic.rst b/docs/api/pylabrobot.micronic.rst index 5d02902cefa..62cc8f70dc7 100644 --- a/docs/api/pylabrobot.micronic.rst +++ b/docs/api/pylabrobot.micronic.rst @@ -3,7 +3,7 @@ pylabrobot.micronic package =========================== -Direct Micronic code-reader integration. +Direct Micronic RD235 code-reader integration. Code reader ----------- @@ -15,7 +15,7 @@ Code reader :nosignatures: :recursive: - MicronicCodeReader + MicronicRD235 RackScanEntry RackScanResult diff --git a/docs/user_guide/micronic/code_reader/hello-world.ipynb b/docs/user_guide/micronic/code_reader/hello-world.ipynb index fedd48e2875..ca361bd570b 100644 --- a/docs/user_guide/micronic/code_reader/hello-world.ipynb +++ b/docs/user_guide/micronic/code_reader/hello-world.ipynb @@ -5,14 +5,15 @@ "id": "micronic-intro", "metadata": {}, "source": [ - "# Micronic Code Reader\n", + "# Micronic RD235 Code Reader\n", "\n", - "The Micronic Code Reader scans the side barcode of an 8x12 tube rack, acquires an image of\n", + "The Micronic RD235 Code Reader scans the side barcode of an 8x12 tube rack, acquires an image of\n", "the rack, and decodes the 96 tube DataMatrix codes locally. It does not require Micronic Code\n", "Reader or IO Monitor.\n", "\n", "| Property | Value |\n", "|---|---|\n", + "| Model | RD235 |\n", "| Rack-ID communication | Serial trigger and response |\n", "| Serial settings | 9600 baud, 7 data bits, even parity, 1 stop bit |\n", "| Rack image | TWAIN helper on Windows, SANE `scanimage` on Linux, or a custom `Scanner` |\n", @@ -85,9 +86,9 @@ "metadata": {}, "outputs": [], "source": [ - "from pylabrobot.micronic import MicronicCodeReader, TwainScanner\n", + "from pylabrobot.micronic import MicronicRD235, TwainScanner\n", "\n", - "reader = MicronicCodeReader(\n", + "reader = MicronicRD235(\n", " scanner=TwainScanner(\n", " twain_scanner_path=r\"C:\\Tools\\twain_scan.exe\",\n", " twain_source=\"AVA6PlusG\",\n", diff --git a/pylabrobot/micronic/__init__.py b/pylabrobot/micronic/__init__.py index 5c0f7a81e3e..c5fa4371e2b 100644 --- a/pylabrobot/micronic/__init__.py +++ b/pylabrobot/micronic/__init__.py @@ -1,6 +1,6 @@ from pylabrobot.micronic.code_reader import ( - MicronicCodeReader, MicronicError, + MicronicRD235, RackScanEntry, RackScanResult, SaneScanner, diff --git a/pylabrobot/micronic/code_reader/__init__.py b/pylabrobot/micronic/code_reader/__init__.py index 46f6d66a66f..055eb0cc1e7 100644 --- a/pylabrobot/micronic/code_reader/__init__.py +++ b/pylabrobot/micronic/code_reader/__init__.py @@ -1,5 +1,5 @@ from pylabrobot.micronic.code_reader.driver import ( - MicronicCodeReader, + MicronicRD235, RackScanEntry, RackScanResult, ) diff --git a/pylabrobot/micronic/code_reader/driver.py b/pylabrobot/micronic/code_reader/driver.py index 6a6ef1f4a73..4b6f76000fa 100644 --- a/pylabrobot/micronic/code_reader/driver.py +++ b/pylabrobot/micronic/code_reader/driver.py @@ -1,4 +1,4 @@ -"""Direct integration for the Micronic rack scanner. +"""Direct integration for the Micronic RD235 rack scanner. This driver does not call Micronic Code Reader or IO Monitor. It owns the local scanner path directly: @@ -81,8 +81,8 @@ class RackScanResult: rack_barcode: Optional[Barcode] = None -class MicronicCodeReader: - """Control a Micronic rack scanner without the OEM application. +class MicronicRD235: + """Control a Micronic RD235 rack scanner without the OEM application. Args: scanner: Image acquisition implementation for the flatbed scanner. diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 57653f7400b..68066375d74 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from pylabrobot.io.command_line import CommandLineResult, CommandLineTransport -from pylabrobot.micronic import MicronicCodeReader, MicronicError, SaneScanner, TwainScanner +from pylabrobot.micronic import MicronicError, MicronicRD235, SaneScanner, TwainScanner from pylabrobot.micronic.code_reader.driver import ( DecodeResult, cluster_axis, @@ -165,18 +165,18 @@ async def test_scanner_wraps_missing_helper(self): await scanner.acquire(Path("rack.bmp"), timeout=1.0) -class TestMicronicCodeReader(unittest.IsolatedAsyncioTestCase): +class TestMicronicRD235(unittest.IsolatedAsyncioTestCase): """Tests for the public Micronic code-reader operations.""" def test_rejects_non_positive_device_timeouts(self): with self.assertRaisesRegex(ValueError, "scanner_timeout"): - MicronicCodeReader( + MicronicRD235( scanner=_mock_scanner(), serial_port="/dev/ttyUSB0", scanner_timeout=0, ) with self.assertRaisesRegex(ValueError, "serial_timeout"): - MicronicCodeReader( + MicronicRD235( scanner=_mock_scanner(), serial_port="/dev/ttyUSB0", serial_timeout=0, @@ -185,7 +185,7 @@ def test_rejects_non_positive_device_timeouts(self): async def test_acquire_image_runs_scanner_and_tracks_metadata(self): with tempfile.TemporaryDirectory() as image_dir: scanner = _mock_scanner() - reader = MicronicCodeReader( + reader = MicronicRD235( scanner=scanner, serial_port="/dev/ttyUSB0", image_dir=image_dir, @@ -210,7 +210,7 @@ async def fail_after_writing(output_path: Path, timeout: float) -> None: with tempfile.TemporaryDirectory() as image_dir: scanner = _mock_scanner() scanner.acquire = AsyncMock(side_effect=fail_after_writing) - reader = MicronicCodeReader( + reader = MicronicRD235( scanner=scanner, serial_port="/dev/ttyUSB0", image_dir=image_dir, @@ -249,7 +249,7 @@ async def stop(self): with patch("pylabrobot.micronic.code_reader.driver.Serial", FakeSerial): scanner = _mock_scanner() - reader = MicronicCodeReader(scanner=scanner, serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=scanner, serial_port="/dev/ttyUSB0") await reader.setup() try: rack_id = await reader.scan_rack_id(timeout=1.0) @@ -271,7 +271,7 @@ async def stop(self): async def test_setup_stops_scanner_when_serial_setup_fails(self): scanner = _mock_scanner() - reader = MicronicCodeReader(scanner=scanner, serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=scanner, serial_port="/dev/ttyUSB0") with patch.object(reader.io, "setup", AsyncMock(side_effect=OSError("serial failed"))): with self.assertRaisesRegex(OSError, "serial failed"): @@ -282,7 +282,7 @@ async def test_setup_stops_scanner_when_serial_setup_fails(self): async def test_stop_releases_scanner_when_serial_stop_fails(self): scanner = _mock_scanner() - reader = MicronicCodeReader(scanner=scanner, serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=scanner, serial_port="/dev/ttyUSB0") with patch.object(reader.io, "stop", AsyncMock(side_effect=OSError("serial failed"))): with self.assertRaisesRegex(OSError, "serial failed"): @@ -293,7 +293,7 @@ async def test_stop_releases_scanner_when_serial_stop_fails(self): async def test_scan_rack_populates_result(self): with tempfile.TemporaryDirectory() as image_dir: scanner = _mock_scanner() - reader = MicronicCodeReader( + reader = MicronicRD235( scanner=scanner, serial_port="/dev/ttyUSB0", image_dir=image_dir, @@ -335,7 +335,7 @@ async def test_scan_rack_populates_result(self): async def test_reader_can_scan_twice(self): with tempfile.TemporaryDirectory() as image_dir: scanner = _mock_scanner() - reader = MicronicCodeReader( + reader = MicronicRD235( scanner=scanner, serial_port="/dev/ttyUSB0", image_dir=image_dir, @@ -365,12 +365,12 @@ async def test_reader_can_scan_twice(self): self.assertEqual(scanner.acquire.await_count, 2) async def test_rejects_mismatched_rack_shape(self): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with self.assertRaises(MicronicError): await reader.scan_rack(_rack(num_items_x=6, num_items_y=4), timeout=1.0) async def test_rejects_concurrent_scan(self): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") await reader._scan_lock.acquire() try: with self.assertRaises(MicronicError): @@ -379,7 +379,7 @@ async def test_rejects_concurrent_scan(self): reader._scan_lock.release() async def test_scan_rack_times_out(self): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") async def slow(rack): del rack @@ -391,7 +391,7 @@ async def slow(rack): await reader.scan_rack(rack=_rack(), timeout=0.01) async def test_timeout_keeps_scan_lock_until_blocking_scan_finishes(self): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") loop = asyncio.get_running_loop() scan_future = loop.create_future() loop.call_later(0.05, scan_future.set_result, MagicMock()) @@ -409,7 +409,7 @@ async def test_timeout_keeps_scan_lock_until_blocking_scan_finishes(self): self.assertFalse(reader._scan_lock.locked()) async def test_scan_rack_propagates_micronic_error(self): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with self.assertRaises(MicronicError): await reader.scan_rack( rack=_rack(num_items_x=6, num_items_y=4), @@ -417,7 +417,7 @@ async def test_scan_rack_propagates_micronic_error(self): ) async def test_scan_rack_id_reads_barcode(self): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with patch.object( reader, "_read_barcode", AsyncMock(return_value="9500017722") ) as read_barcode: @@ -427,7 +427,7 @@ async def test_scan_rack_id_reads_barcode(self): read_barcode.assert_awaited_once_with() async def test_scan_rack_id_returns_noread_for_unrecognized_response(self): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with ( patch.object(reader.io, "reset_input_buffer", AsyncMock()), patch.object(reader.io, "write", AsyncMock()), @@ -438,7 +438,7 @@ async def test_scan_rack_id_returns_noread_for_unrecognized_response(self): self.assertEqual(rack_id, "NOREAD") async def test_scan_rack_id_wraps_serial_error(self): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") with patch.object( reader.io, "reset_input_buffer", @@ -448,7 +448,7 @@ async def test_scan_rack_id_wraps_serial_error(self): await reader.scan_rack_id(timeout=1.0) def test_decode_rack_returns_noread_for_missing_wells(self): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} with patch( @@ -464,7 +464,7 @@ def test_decode_rack_returns_noread_for_missing_wells(self): self.assertIsNone(result.entries[1].barcode) def test_decode_rack_represents_missing_rack_id(self): - reader = MicronicCodeReader(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") + reader = MicronicRD235(scanner=_mock_scanner(), serial_port="/dev/ttyUSB0") decoded = {"A1": DecodeResult(tube_id="1111111111", method="test")} with patch( From c9ad73f2175b158434eca6499a29e4a4198f3781 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Wed, 19 Aug 2026 14:07:31 -0700 Subject: [PATCH 54/54] Fix timeout tests on Python 3.9 --- pylabrobot/micronic/code_reader/micronic_tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylabrobot/micronic/code_reader/micronic_tests.py b/pylabrobot/micronic/code_reader/micronic_tests.py index 68066375d74..fbdc647f3be 100644 --- a/pylabrobot/micronic/code_reader/micronic_tests.py +++ b/pylabrobot/micronic/code_reader/micronic_tests.py @@ -387,7 +387,7 @@ async def slow(rack): return MagicMock() with patch.object(reader, "_scan_rack", slow): - with self.assertRaises(TimeoutError): + with self.assertRaises(asyncio.TimeoutError): await reader.scan_rack(rack=_rack(), timeout=0.01) async def test_timeout_keeps_scan_lock_until_blocking_scan_finishes(self): @@ -400,7 +400,7 @@ async def test_timeout_keeps_scan_lock_until_blocking_scan_finishes(self): patch.object(reader, "_read_barcode", AsyncMock(return_value="9500017722")), patch.object(loop, "run_in_executor", return_value=scan_future), ): - with self.assertRaises(TimeoutError): + with self.assertRaises(asyncio.TimeoutError): await reader.scan_rack(rack=_rack(), timeout=0.01) with self.assertRaises(MicronicError): await reader.scan_rack(rack=_rack(), timeout=0.01)