From 34de0ee7c81456b5315cb7ee7627a6a3551c1f72 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 3 Apr 2026 16:54:24 -0700 Subject: [PATCH 01/23] Add v1b1 Micronic rack reading support --- docs/api/pylabrobot.capabilities.rst | 19 +++ docs/api/pylabrobot.micronic.rst | 16 ++ docs/api/pylabrobot.rst | 1 + docs/user_guide/capabilities/index.md | 1 + docs/user_guide/capabilities/rack-reading.md | 45 ++++++ docs/user_guide/index.md | 1 + docs/user_guide/machines.md | 6 + docs/user_guide/micronic/index.md | 41 +++++ pylabrobot/capabilities/__init__.py | 10 ++ .../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 +++++++++++++++++ 20 files changed, 922 insertions(+) create mode 100644 docs/api/pylabrobot.micronic.rst create mode 100644 docs/user_guide/capabilities/rack-reading.md create mode 100644 docs/user_guide/micronic/index.md 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 index 666ce292e20..448546c918d 100644 --- a/docs/api/pylabrobot.capabilities.rst +++ b/docs/api/pylabrobot.capabilities.rst @@ -192,6 +192,25 @@ Barcode Scanning BarcodeScannerBackend +Rack Reading +------------ + +.. currentmodule:: pylabrobot.capabilities.rack_reading + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + RackReader + RackReaderBackend + RackReaderState + RackReaderTimeoutError + RackScanEntry + RackScanResult + LayoutInfo + + Microscopy ---------- 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 53a04635937..5f46dd6be3f 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -41,6 +41,7 @@ Manufacturers pylabrobot.inheco pylabrobot.liconic pylabrobot.mettler_toledo + pylabrobot.micronic pylabrobot.molecular_devices pylabrobot.opentrons pylabrobot.qinstruments diff --git a/docs/user_guide/capabilities/index.md b/docs/user_guide/capabilities/index.md index 2a091c83f64..fb4e0f7ddad 100644 --- a/docs/user_guide/capabilities/index.md +++ b/docs/user_guide/capabilities/index.md @@ -55,6 +55,7 @@ loading-tray pumping weighing barcode-scanning +rack-reading microscopy automated-retrieval absorbance 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 bb273f350cf..3f1ad03ea17 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -40,6 +40,7 @@ inheco/index liconic/index mettler_toledo/index molecular_devices/index +micronic/index opentrons/index qinstruments/index thermo_fisher/index diff --git a/docs/user_guide/machines.md b/docs/user_guide/machines.md index dc057d79b15..c1474c15072 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -188,6 +188,12 @@ tr > td:nth-child(5) { width: 15%; } |--------------|---------|-------------|--------| | 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 | Code Reader Software / IO Monitor HTTP server | rack reading | Basics | [PLR](https://docs.pylabrobot.org/user_guide/micronic/index.html) / [OEM](https://www.micronic.com/products/code-reader/) | + --- ## Understanding the Tables 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 index ddf84ebd973..d39489932c5 100644 --- a/pylabrobot/capabilities/__init__.py +++ b/pylabrobot/capabilities/__init__.py @@ -1 +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 ef165aa255b115f1b97daf02d5896b836a74ff0e 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/23] 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 06140130fadd43e891c274c3b2371fb227765e92 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/23] 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 0b77266a09e50bc983cb744240d6970600456b3a 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/23] 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 5360da62f97287b018cc778f3b6ddcea78b13741 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/23] 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 f245c9590aa438dd5577243f7f35161f19dad1ce 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/23] 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 ca6e0f698959f1ad03614bbb00659c55aa25a7e4 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 10:50:01 -0700 Subject: [PATCH 07/23] 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 4d82772ddb8e61447d70d0ff361d863073188f31 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 13:07:49 -0700 Subject: [PATCH 08/23] 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/machines.md | 2 +- 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 +++++-- 15 files changed, 479 insertions(+), 76 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/machines.md b/docs/user_guide/machines.md index c1474c15072..c6e8a3b5e23 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -192,7 +192,7 @@ tr > td:nth-child(5) { width: 15%; } | Manufacturer | Machine | Features | PLR-Support | Links | |--------------|---------|----------|-------------|--------| -| Micronic | Code Reader Software / IO Monitor HTTP server | rack reading | Basics | [PLR](https://docs.pylabrobot.org/user_guide/micronic/index.html) / [OEM](https://www.micronic.com/products/code-reader/) | +| Micronic | Code Reader Software / IO Monitor HTTP server | rack readingbarcode scanning | Basics | [PLR](https://docs.pylabrobot.org/user_guide/micronic/index.html) / [OEM](https://www.micronic.com/products/code-reader/) | --- 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 8c4435e8e70c7db3f07d394ca9130b3c472fe7be Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 13:10:58 -0700 Subject: [PATCH 09/23] 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 0296c428a624a9e93173b34e43c6f1e3008063ed Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 13:22:31 -0700 Subject: [PATCH 10/23] 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 03f14d9922e17a65a5e02cdbd1d0672af36c6dc7 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 14:11:52 -0700 Subject: [PATCH 11/23] 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 320e599696bad7e85dfb7c856c6149c09cc86f70 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 16:15:13 -0700 Subject: [PATCH 12/23] 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 a41f59bfc2d3eb118957e0a457de1275849458ea Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 24 Apr 2026 16:26:37 -0700 Subject: [PATCH 13/23] 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 62d1abf8246e4dbab2b4fab8d73caca3a70fa0f4 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 6 May 2026 09:59:54 -0700 Subject: [PATCH 14/23] Add direct Micronic rack reader driver --- docs/api/pylabrobot.micronic.rst | 16 +- docs/user_guide/machines.md | 2 +- 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 +- 13 files changed, 1498 insertions(+), 165 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/machines.md b/docs/user_guide/machines.md index c6e8a3b5e23..d4cb1ca7f57 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -192,7 +192,7 @@ tr > td:nth-child(5) { width: 15%; } | Manufacturer | Machine | Features | PLR-Support | Links | |--------------|---------|----------|-------------|--------| -| Micronic | Code Reader Software / IO Monitor HTTP server | rack readingbarcode scanning | Basics | [PLR](https://docs.pylabrobot.org/user_guide/micronic/index.html) / [OEM](https://www.micronic.com/products/code-reader/) | +| Micronic | Code Reader Software / IO Monitor HTTP server, or direct local TWAIN + serial control | rack readingbarcode scanning | Basics | [PLR](https://docs.pylabrobot.org/user_guide/micronic/index.html) / [OEM](https://www.micronic.com/products/code-reader/) | --- 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 zcmeFadwdkt-9J8?Y+!*UvudKT!sE6EB^7Dnm9`JD?K-y}h&!ZKJTBb=i=HZ)y}&tV_QHjWe9k8eoJIbH&iM2uRK4WyFH%U)R*JhxjvQwmbf&IIv|AXD7yhi2fh{f5_WVRr{itG zDdEpPO`xU^$7PSVg04*5a_V)d0ky%$IOrGcfQ>${`Egjd&M5G!YjtemxRk4lxn;*V zZYUvkjn8mj!7?B2rLMvcAykSl@Q(e=;<#DE=g#)cMqw`9sK6IAb1%?E?Q5>4 zJ74dY@vt3rZ1`@$H|CQ`^|B{Vp`vv9>BN`l*h&|@pG>OfD`fo=7>)x2sopkr5&g+z z^cEB@ngdWI8_0+w-kHj4)I`0}Kg!SvPN#ZKquz%`h1hy`yV%ulZbO}Pd{gnQ{f>I~ zX0XET_uw!K$PsuIbz?r6)bD-wv63ej;jjVq7UMXGi0S=gQoZ{hUsoV)_JB!_wryO^tA{1#d$ zj!LRPhd!Y(BUDp?hSaw@2-U(Lc`Nwsj{>*z11!G#=n&_BS=zaRs{3x|JVgD_jk&<5 zJ}1>-#c>n;Y@DE%*phIRa#HQMYfYgtYw92z)H#J5*H}}bOh~ng9i5S9E&|fDsd*W# zj{=^2U&FCXZtg}JDdc6=@L-ec6|Q3~d z1-tmQbJ3|X?V?mCx!V=%4U*@GBy>Oya=ch)6>F>#=;Y8wS!SW;A3qH+V=l^%T$EO% z+9WWKD(pt7E5F0PM|QW%o+Fk$^@K7f4Wxe5$I$mi5`&zq0#_*x;;?OGD1f+)gdhQ# zOo}y-v~ZIHKfy*m@Cks($;7eXexF|{=zQ#npU%uzW?zh2K?P&u2OxqYUIy1)ibTMK zTXC)a;b$>i4*omcL4USRolQ8Xuc1bL0?CW;g$#l#P;2#I5~cPGPQ4ZC(*%VO`mXN1 z6?${(pMWO;D!RAhppHgE?M46@PI@X;Yk$Q#5&9TzMD?jYVIE*pd+nKTBN4!(Yx-!j z>6+eGtAk zpoKIjHS(Ew(k9hWNm@+FCawaU@A6$oH&UqyZ0Aqt;}A~XNdk>OB`YjAOH!#(*r?Bj zbT(dxLs9!o*~lP;eEm!aO&bXX;AjV3k>qk6FW6&bNZ@#8ls(>xY05IIRFx*#e@z6_01I?S;}A*Sz!0>N8E>a9Y8cv^b+n~r zas2ivsl&XIgP57T1;v##PQxs{fcT)2lR5&&B=k7cEBV-k6&I<}jMR~Ugi|NFbg(Yr zs|Do@Rn&JtGeh8Y9WSwV0+Wnv ze=oEtlMC0QVjLqU2ZEIfQ|F=6x%1Ez$jvfpYyS=?rgl|SY_y`POp>~bsiMsocOp&f zj*@GYGp$lG)q?ZW8f%RE<`Ip+?<9G!V;zXN5b+HUw)$=~)!$nYNqUvt30wSUf!^X9 zL5B^BFXvwbtR;}GE;p%03|Zu)AXN_T_jiUX_419Gx*qj4^shdF3W#yx08v3>QtZdH zC%f1a@4FskB{o)4?P@!ej_eeYmpu+LxG8XOFuI@jL!e4k8c~Prk?0XeIY4ooEZBYh z>l4^HYKT2?z8hhmzVSdm+J?dXU|1(c-3#>VJI?$o`i_G;ud(W-{=Z$`Mjz!XWWhno zNc{sfo8bt$FaUg4b?+iuZGWCJyki~H%oPkk6*&AP);SN7Nl z7j(`HU8bR~Yc578=6?Vm77lGWIa0oFF;syDxxRq6(T9tSMlob(@{k>_14W4#+vBI% zTnG5)%Se1x7BQ6MI^{nOD^mX(a+m7K;=jQ?)?691t?G+7OS81hp*tY8tv?V-CeZO? z?NZM7?22bE5i^L0fgb{vk>rES9PI=ufO1)AS8^|k$1nJ7N^VFzzT1Djm_VpnqONoa zsnYv2W>;oX3wJZ0Q!RV;#t){hq(^L0o)t_$kH#RJgl?KxHx8KLJCWoyxB%BSfP32q#b}O7VBS+3jVG!i8B=bVE>SHuc%}BMPYyt^jJg!ZHCH>bus646=s01Uv zfhN|Buk{I3O!8ALV4z|xRjff0ZIx=RYPYH*2SA#PT9k2VwQ!BMiS3=6h_1?%|IFm> zq93@qm7C>c{d54oE)f7fpes2ZTnui(rJQZ0(~a!Z#s>d~;M~L-d)Wo5$%}JAr&O|6 zIvW0|iN3c&JZIyBeNd6#_8JAgL)w8xpmlklvOgoA5=lECdk$I-fQJLiE<|u^)k5(? z>5BQX(4=mJTPkOb#5#M~CB2i2s3WhmDys>YBs9r=lnOePOZ9?-@)#Sxtzo3YS0bKs z`b*{fgJ}oid%KpZ_+SVyg00K`!2Ay^Nfr)5W_I++0xZZFq8R!nMDgK8NRjC|g67}` zwA(lcw0@9^AE|1;hLBiSTQl1sgy+ zi09WW#5vFaz3L`(?Ufi1gZAgGbhl)0IG_9(7*vWXdJWdG?)QjIrbPCGgHp*sqEjxm z%0CusZ1IQ{5L>-Kzv@E-fEDO1idy87gK{!6 zb|6gr9&=Xk-EuCua5g-_2oH!S9b&!Z$D;_?ibhjsiNRp*Mbh)xAew9i*9~<2D6aMR zYsAK7Xw@b-+5~tzCp`DimOb!8~NO|o)u5C1Y2wh4>a&=Yo@=(XcPkb zAs;T-@;4Do?c&VX(YC@IU*vq794~XDeuc?CANUglz0@;mFfM$v_|@xiISTPb$e%_Y z8CPWLFf2|LX2Aq#=0PI{*+(l%iQgMb(Hjt-BmpwU)=0aq7o%QeJp@s0)UOHTp%{<^ zT0IR68OZ;@6(GEazUT&se3-@?f>v!c=c5V5k;p@0k9FzV7&tVOQQtD*tYUEZzpou7D;GV#ZaHBytaQQH%0>k@3s<1hxlrbokN`Dzs z%77)JRDJYZB%=9%#uf1b^LG?quK^1q$pPq}JUD=Jgm(yL?w3dHC6&|%Y&o7>{%2Ah zZt7kb0W@5{-v);z-3!%_pRfg6{hvkQRiZH}n;L1lDg)leawa!_Uj#BFM;bqA@B}w7 zLndwzQ`B$sIRvGUP9|wrJmL~5n-sYh<&A`~Wa2GllTXp_5&{_^pg@wW5gQ`mE)xl_ zsgDc|C?z>YRHK=>u0K&I#1_^EaV6xtBcm}q0Db|pOtQ%)jJJnjk^XOjjlMgjM_FVw z4@gJb6BZ0I+V`4Eg_KNqhnq}{4EjMJMo2#JDu`ctC;EHHU=$yCi`bGtY$MJ9q#lSF z_#*7{hmE?v{HJ3iWL`vGBi}XCA{_Hp2b5e?WZZ z$g&ZXw(<3mn(}L4fBU^<%Tfkd^7$95rKTE^*Hn0>JUQPUKY1j-eRLAPy{M0p6PMzJ zB7Q%k(bUsiI|87-@wte8itH@%8H&h9)uT5PJtlwXKhhZ|2N|*F=_kk^*p?PWQ%?S; zu1YFBPyXCL$y@e*3Qh)C{5kygUnb#{-@dGmGQ$CEBEIa5uJCmRK4P6ktQqRMRgeif*9K8I4hdSSSmqS0nZgLu|kkQUl9ufKR=6osdgQ zK>&PCJHLG@%xh&IWy+0Ap+C*&k{`Ca8emrs+i>_EX?68bS2yr!eB!541Eoo}-cGIp z!#CVo-wH8Ia}If&HQ4Cy3u53c4+dLTsq#2G6uqp9s^v=!(Mi;!xuaIi0KI-3c;SvQ0Vk9dW8DRnoL&Ql*1a20) zva1ga#>u^@QjL1oqkv!QBm>jubnq|6{Hm~ZfvqLfTGHI|<$rwM-c@Vy-PToW zCLA!AY6hZ%Fc)zkPD??N&4|xZDl$3s7e6H23NQBuUA39MJ3ov=-<37H;b~tp_}$iA zFEmkHkP*s=gnwxwR3Y_zNW+A8NMx1wuAgMC*Pe#|HSfajd!#CuorLf)NuF%&s>`H- z-08|rUFneHOTTjZ|4q(wmfIaDM1n%fb}}P00eCA2VacAV`lf=!%j)R%!T2d1%9H*~ zSt+4bLC_DuC_*z0@xYAbM!QIC*>+c*RLxI36b#APHh%nRS0}&j9n?_9MC!9`z&j!d zA)4>f`kLuig<51OY85xlC15|9mk1+(r?KocfJKnFmLfaF-K>nXNS}mL^!YKUvR)U( z7>@Y?InN4xj%C3Y{D)&A@&K^G_bHpI0H*ATA=Gur-zb-al(7-G`w_rSD+HElod`d= zvJE*xl*;J{HNnn_qWp`B*lRpEKv%uu@t{#3}J@IF3q&~}F zaz`-22R5OQV!H0|ZO{{NsyxX8U0?$}9BEpQl;^Z`#tR+3Y*fSC1F8w-$#c5$QdhR{ zA5H`3x@w)ik1$~%=BbZP0l++Gco+F)z^k*yIdKhGfE2@nmG=N%EU-5D1_HE~raLfW z#y6`0tjeHUtR2P-1!!g4q-r?}5Ultwb`-vDIOv9MnVReiOLmkA_T~3mPaEbUUnNZ4y0PTA0bP2 zNY&CQ=4mu9VCnLqOiXDWV)oBtgs>$?C zW%{FdzAVRjhR~7Gh>558g;V;J-)8@bcwHAr&za?>~uYrdS=HRxO=V!(f2P zEbyiBE&wGEo-Y9fSx2Emst*4tO26(8ne86LP$GF6B==siXAvLx9h{K-oE__1nDf^1 zLgt zhHf#EClSn0WKV1)Tevv&Sr!59KJX>*mpapWTtbLncLo>K z3$%$i&Icoa?X_FK{trNk`6$?a*{R`x8!1?y9 zTCKqZB!UcjbP9yZA9xxLckDVD9#e5dWW-4t <>ZBKUg(iJ`}O!glmQud zw?K+Sehp|cct&}6+>$-D#E-OUMo3DD5cKY$tt2ZH9b z$6ys~Twtme7y}T*3!=N8Uq?D&Xol4JKIs7wiRi+2JqfRFj3u3wmiJM{#JPm#;!@?= z2$mc@?NNQv($Ff#4+VN)hTm9f9u|LkOy%{g5jk`~fM3rxr>e4QD#iG=%Fz z(HBI>2X8?UjkHid)2Jx!dSfDhyE_zXB4dQsjcSaa2BY}krpvltB^&(IUZ#{E#+N*d z2M!DaMwkKK@Xy~*{pVd4l^kPHiI0Ma=vdI>M(5ei@JE27JbH=Lm*&l;XqZ&A0>DzH zTI%|wldUyjj6S4UsBb-9g}}5uNQmLS@=To8j8k1zwEk!q%YmE0x9{MM}Chl%qflx4{#j?zA{F0OpcCG7*C1S{$7(0_!*g`UgUeLr>P z0lRMu|H5?Dz2nDJ=@X0|h_>1>+-Ee(KebApj$jDU7eBs1th%YI-r*bI6;}>|JYOOE zUHP5f_?fc^WhjBI`>m#~no?g9dX2Qly@=<{@ZRN{Ng9b$y4yZL|0wza0*% z^5eaG_tpzXAWcqcv78e3aoxwHCh_>iDmBhhEj6d@lTJ(S<~xPv9aQxe&yo43>12Kr ze)d`pq4oh}7^=i}i{*@R?=L0)!4EMt;kv(y7n)^%yD|;alXQMdi>178CGz>OkdA#} zS0`KM1>l=|r2Her-;?uE`H1wHl-~{qt^J5>S=SrcT`TABBun=aqU=gCm4uKi@{wAPZuLBu#~jp$|W@MFNT=a`D}p@ADm2LL9iQIdA|(UB~5AP z)0(6)h!9r}>8=R@9v`I57rJeiFU4tBJtil+aH7YzLObNe2WYLej=Kxn8(bw##g9ve zA&9Wk)ERGuJe9@4W0={EaY9D-%TukeK0OXb$(YoY@V|ZKL<)oTj6HAPegDek@m$_Y$tVS=^6xiuUfq(0Oe!TIhCR z4hV>!e7DqbccP(o0!>^V;=ERdQ^q%`9>EnK_!QT5oD<5Z)v{ZWDPw|wBw zFamyiZju)^9(a>1BHJMq927&m5u`od$=rg_j~G9>rKt0lExF z_4rsIiUnU7qYWMki~pp3ig1)nauiaJu(1q&yXUA^%1e^69op#_j?sLK^-~Kj$OQ*A znlzBV;g5NW0C{Ma*`;f=@m8;HKF?EOl(mx=0=_0fTnEnG07Np%Z}5qUv{32 z`+=RPOH`0QVf1dhsWKg~0{eY^U?u#A)zV@0j{tzs&389->pP;y{ePKXM}hw+4?as9 zlYU0~z79S{$Q zGYAHafkBXosp6oGW;kjRI%6xtrad*Q7Fa~sXO$7QN97{q-OPZ7|D@Q-3w3!#@v%Oc(gxyi)4--1tkjCo0^xt6ahG4) zjw9xq{=aC4aEASu!A`W3AF)GGq%U#W-2&$6YV`_%`6GU$D(!V@$CT|+&<)KFPQy_n ze`%@?lMDIB-K4Hlm1iO}CzpgHQYQ?%s^?ZLjKaJtpYnRW-Cs-o_Nq&_O8ctL#igC{ z4gQcy$uBj)I&ilE^{dXuN&C{O{9(CxsCX&jx7TM6g(`C_o#*O?pWxlyQiC!zF0IM6 zj|I{@sB3;A_C_>ATfkNDn^FcZvG{Z-)Cvs20Nd^?+e3aqY!GPc#9=k{WF%rKM)h{g z_&goPj9+W}`_=U6+ew@(IlF8U#%v@$6`@mcpM_(zR}zM;BU9DmC%{*f)s>y2g>>J2 zw0Z&gcdE_c9J&0?8-PHqlTxWB4=d_3?NDUyeVU_$--P~A3#oy`$q*ZV1!9F!#1@?=`?d9MZ-xFC^Nc(vZs>DPJwAc)?O!=ce#D4R z^fcB{%r>r!Pkx>S@57o?d}7i=_DJ-cygr5pIz2`{c^G3bUw#^B^MQjVIqizO<1#)! zs{!ef4)8g`HY@~MP!&=hLK99g^6iQla-QAw*(wh?i#FU#J1^zgK{LtvoOD`Uj@(1| zhAaF<5;3Va@3rc$gzQ5MSv_%P)JLM}UPebII-?XMwrMc1q;=nr?Vw%XtJ{%3RxROA zAudu`=HazlV6q0>STSnxz2(F|sHkfFn+|%*b;LgdCGd~Bn+04buV?3_WVDoN$m7@c z$El?WCZW;B7E|F^Ji|uc8Eo|3EP1vd2jIo>gRNf^AD95_lri6WCE*=0f}Ph}fh@mU zct>_`dEbF^N!UP^ExaxZn`uTxSaQ&K6HY$+Gs zmE5m{e^lXeS7O^0CO}WvvER1*B^uU4>B?i4a*LE7;$9(B6v8UgXN|x?ogAf7%lBZVoY~i;wS@xZX7eY7av-&dNw1aX}xPe{J7BoK*@0I-T z5S0IHI&eaV(u8@Fu$kXoXRySx1zf>=N&6ej-DGzKF>!S_d_*il8C_}T>V*yHf&854rLyi&A9wD-ij0CQJFXe+G{5$GBe?KU{glqP-3> z;1B#El2%D2<;1q54veH;2;l^(P)Wl%7XD!GcBzx!?S3b%$5JgmXXp4yaLHTCjx(Qo z-Xb=imxP`ANT5}nyA|$U_w}-{lMlQIXh;xBb?Bef0n@v+%Z3D1>h#_z?37LeA7lyE zGx=4zn)tQFP>i%DqL&2(^Kr@S(iV*;oIp^$_p2-Ho5lrUYeko^wUiIuh*ksw2R@jA zUleEZ!PjtR1|(;`BmfvXFqVi7)73B7HJfl^oQ(6`s`qMEuhnn&u3I1nml7U@=OMBO z!MHy0f_1@Fzzi!ZchE8l;zF@!xNoM|Gt74w1C$5=;o}squz1AB;Pr-Df9=I3SDi*T zg^&fA{g$#vti*I#*dTje7i(uwc&PP3bZ79RSxq|w$mOR0(}PV#_(XjMP_zZ)ddRQ1 zL)t=eXGVN@wgWdip%O+rwqJ*w%Li1(<`;JopZ8L|hTeKK$HPK3_8l4u&%B7C@?=!? z!e-pYJY)+Uw0AIYv-laxB)K<`uDD;<&H@u1f52O}26Z$Zk%-=}6YbO~)NgJ~zad{L zoP&1UfCv8zj<5S00BLRbF%2}T--w5TDS(Lt7t~Lq>>u{*zl;Mo9mO8@W#A6wA@sG1 z7+}DM{A)&JYF^pNy+BLOPg3uGkf^H#Un4S38t9~6*bBfBDVRg0g58TOBMgMLJ_t1O zaYMLS)Jjk61Se2k0V}D{9Hc90I*Ae#weug+hRDEUkht<3!p-m~Lq4Eg&ObuAgalW9 z`$}H+@5D%r2OI#rJn7%W8o1jd2py>>5{Z-9AN`pk3_IPBDY;xi3v}0>G{W(&f>T2kEFiz%A1+S{#;slritWS z7lge84ft(iEFZ4lt!lJIJR?pPBL#)%215 z_|=aS^5^3D)sNzYMENwpu;5S*5?UA}hR83`nH&dV%a7x>Vl7p2vYX^rsaT5F^^4Gv zE>ViO@MyF~0wjhDHh?H@WXHCM7yLR1tQAZ9un>m%5zJA-W)IedMp1Z~J5&lxuI{@V zz+{{J9^oVkDORcBH{zu@-!0-LjGP_ti+Ym;e?R`iM%;v+qFc3-Vjdm*X{aNSu58MMm!NGbIMPxG<3f(FM(FjHls%t z^(ew-^@Zc0E#IWgPoRX54n%BlZniS_4yNvbuv>>J)9s3|lVa>kXJgxqx@NoDE86`U zVSR6Z3D;~l^on+u;3|9DeGF5N601zN%FGNzHIBANzOWT-aTi>YIW^&n*oH4sLuTuw z`hU0|y64%c9s?7!<+ybf<0*32mH|s<9EE&fJy@vAgblp95wKY$t1*uc{32Qd2EZ9+ zqTLG^I$JT=^SWWvdR}0IfvkHcwgG_>&d|HcWhCG`F2nv1T&V0pykv-3+~bryp{{Bu z`FYfae3seS9uV&kPVkQ6pmXm6JnaoB=eN5n5L+(qQ@W=Vjbf|82wn!#t-F?y4!z2L zUs{24Z`rZPDn6ir3*Is<@(^SXw!tDYNs1+=);t8X485jFAJbz#;6cxZ&GCUgfUjS~ z`3CAI@qFNCD3xJYH%f72EQSSiVlG6EgAz?JG?vw2zCiot3i^riU>_jbhNt4N1tyj# z(Eb4cwCp>;zx?)B7ZX*q={klBZOsO|zy}|YZrwEvgxCa)2rzN5pO+2y0Qkrj)IsOdsmCRZqv4dm%<* z63s8mvC#@pF)DSc697xg2AkPv`vrLO0mY}{xwuO5T6|7}_H%R)BhwgFA;$C$Ev6c- zL#fnElh5nn40psqg*6+_O7~)F)BsUqrV-L6f*r6SJ}{UtYtbCgR*Yl(_E5%tZ7X%T zn}#;+HlW0B3u&*RNDQY--!sIRhHJ&$Kv#eYpm36U$g+ccs@4zp+nU2x+OAJ5{3RHY z5mMK*0h`t6n|X}#IIWS+bFIo^o6Kn=so!m5;@Gf>6-u2rVuZG%`@3$z^>Es6aZ4?R zkJSdDJ#qLG@X+M&i5A>A2I>$6-OC5hV+9*s#2a12$8<3ja0o53QL)a3U_l!~0IT}# z;{#p5^MeGiQ9Eg3@0H@T{&a!JLn}k$wq}XZ`fJcSge!&TfAt6&tcNY(R>S*L4z%=J z&(p0EW^VqGWMNj!neuBj6%emRkn!|A+WV|~T1py>wrVoklL(j-VT?C)H%3cVp;dGX zKH_P%`#1?ZU&V7bth|NI>CNcRTVV+ylvQ$h*Paz;DFw6h76>*4oAAUCwvn6rmuaoR z?Z6&LY>Or+kLj5K#CK?q1y!`)x9TiC;JgCuu?#Ma;I}2>(bMvAR;ee@=)1}6k>A!I z`}~j?E4Kw2{im=yJWh(IeaSs6K&-&#!b-C)&0{<)=3!g7<#889)q3E#1eka>1&@Ge zm(W|ZHrrRs@-wtLjadN_H1aYBk`>rx@tlKS^D;Ca#me*~wkfNjsy$BnTuF+j^<>(5 z)FIX+N%?y%HF8|We)%3{oCVVbsk8eOCJI>Gc74J>PY+fDhc{{Oe(GYp&~$_6Aa+@MDCLolv$U$458UZ)z9s(%3Xpik`uP#8AWIW3 zqC;0nM{$*$Un$=c?D9XXWM6a%dy8GzDIy)d(_<4)++1}cP8oYCtw~ARMcvXPNDWd; zSbr`LsUKIh4-Y5)0<1la?Npc($t4YV;trIPL&7zAZR__bcg~lW0s{6!AxDATq>v|` zr<5%j`|r%YC?4k0I&VHNR#`J5(jh79qWF~s52+WovFZ&<(r@IufuTzoebH4_oCe&K z?1+vYmXZ%&hL262@r#Q<=+xy1wbPbQp;i`Z&0UGK-xm4IvXeUrFNn|&#Y@44d^Wpt zNd8LMpURpfN&dZZI<{;HnCD_kms7&?LtDl6PbF;C>`RHSzF6|-10pO*Iwc;nVjj!~ zzl92b?7)2}mHV?LPl&)B!n0*72V%}g`dp1=pHvtIJSD5 z)<9`Y_xd(rbMugt&6(VhflmAe8;>Lm#gQLh)hbjR8JLdqyY2%~N2@T_CrZMGR$;@D zfg@0Sw5p}#5WihGRJs?lap7Rgf`hG|4J``}wS4{gDSq`EXw=q>9*~u4PeNK%*n-U! z5ZeaI@OajU=WgUzXM&hE>&K-4*XAKyS{gq;)z-|f{uHh2gA@gVpRlu1I$-7JdgdJJ zg*9}I-G`piiu&L(qZ(ja+-oFd1+KOG0h;AC>BSOO|4b}n*JGXx_bR}O={+DxQU1|tmIAIT}(kZbX`$m(|RNadPQjHEF`m6=~*cBb> zqcU}9yS&3SXit8xeyjtKIqd=s)09u5SOPe_g7plnM#`R@irtY`1C7v5p^B-Fpr6>p z5cI4uIkkWJbysl;2`=c8w3nu*!iF@Vv|iXiJt9g863ei`;U!`v;u&Oz`PFlvtYS|B zzwSJlju1%r7EBvOGiUzSAp{Ikkb-qq8xxyZG-*cdz<>59(fM$TfU{?gCbE0+d@!9L zvCd?7B}35CAO+{5F8l)RCtA->YLc30^5W@G??zxE2?1HyDreGKfFx8PC*i4J3rI-V zkFOTLf}6mK2_Lu-3d7{=z2mrq&dH2{ z#$tY(rvjOe#1@rw_~by+;cy%x9HF8))SNH{qHBAVk*yQ|679~`86h=cSvWIZ3R00Q ztS6`AVm%N#^}!h^CXTednoAWJhuRpFHtXM)0WdXq#*S%V$)V;^Gf*JA17u&yqZY%& zjvi}cvj}Etn_+6LNT+WTHe)^DsMgY0ZfQNWWO7@x)`L`jR6n3Q%;|Pu^SiPS0%%E? z0N`+XM;edrX=%I`u+foLu;6WgYN=|zYaE)iG`5~yC70%5+w)yxN4 zz$kv(IOPAZJ-YOq%ZFv%0DNM(O=ff>1L!1WAm_hEt7E3Q^>*S0<%+rK=*DM?F(C@E*y;^>onQfgM%A zp?$8kH9w0bWQv%@1sg1yfD@B}^-M;DD`F9eYy;X}1i>&A#X!|VO14a1d^qi|s zDFN2_HmkBXGvN6~DOEa#+gmkeabv zeI3EWLlNqm$pnv$J8LZ1zrFH$QUu%~(MxpGbew=32R?wS!DGZ#=K0pqhMNHPdr^ok z=dZ`}pDO@>@(CzlZ_)u%*^@f2VAwOL08BL>u@6FxiN-LIAApvU)9}JpdszpSXJHJO zF$u#LjUX7t1gvsZY9C_Qpof<>vz~OFhRcACL#1hq?ZzgHHQH^)IID|G_G35L z0%7I|ESBo{V?}opI58lbmv1f8#1urOet)Z&M+pb!OKh+q-Nl7k(y-bk+ z$5XB@p!%M2M3xkP--_V|T8sG&vKzr?NT0Cc++W&*s8&2~8Q{UNO>1Q^ToKPG1m5ij zZE5A#P6l0u!&0VZkhI;Qat9W0K@ucG@f6|XZXj{J4@V1GkstV$dT?W(tP;JGty#+7|OFFn0T%T!EU;-rQ2TtcNU<~N*?RLS_mIt z4{RV$zwRv>W5yxB(aL2!68wt@1yW{3{)%&l4>IBm?6#0%I+I1SeO$fQ__%%aOPdbeUP%r|tlhefeM1=K5 ztd~}&<)c}zSJ7r6Uk?1W0u0WV&u%DA^uvL4P{&Ajw1k>Zvw)<5g<;5&Au2gjEw|DhVH3VkHMRe=ZpF*%5jsqV_o|%zz8naQwz7k2kQyvRSsefjS&30+hNPl zc|Cw*c_uj@tldhp_y~-ID)VdG*vJa+9QZM0rmu`6;Snx?osvc%&wwYWkY&5dLf-@( zK-hKEyn{qCVbA=tP^-Q`_hV5{#&#yQL|qFtLl`Q#D~T{;1iFKH{7U6v%%WBWDLsVU zA)Saouz=v?@aZ;3Uf(+E*|DX!_AcC^$Xwhgd9tC;l+)kk~xO(0G2^ zt2Bjb2^lkya5TMF0hQ8~Rr;J1m9hXK3evI9sL|IK(Yy5dQ4Jl7Rq5rJGa$n1^#2*p zqUqD9lFfn8;l{R>mIRvdv{W}&wd`p<-FBE?T>~y3Jx&P$e)SY^*NpEd@DR!o+d+!u zTgYEQ`xYm`dzJqdS8WLt2pd75CNR~)l#UPFKsRA|?9D7;Y8MR%$PzPP`s4xg$pySe z0R^ts{vKEQ)C~m@oz6CWRh-Sl^!424fw|~YpB@4|;PyY7Bx>E*S`Igj#TDrMy>L&; zI6DT;F%FDFN>UJ`J9d`YVHfNvZuRFkv(Z}D@Ik3+9GL}1rI%P@nnIp6AWNrfGfu#T z5FNB1Vm-?oF;^4im_SuqAR*HiAXP}iq}EJs0J|!%6~eQX3ce&WXHz)KjtqbzPThfl zA7RPpSbOs{s6IsImuc*;C?fWdv8^pYr%VrnIDu@O0EKZ{H=;y_5p`-_$GJ2&wVRow z-wU>)AKz1`Zt@#Sk&O-+Pj&fY0AoDY%CMy9@7GlgcV0nyqpKO)kfhfzU}z722NlbS zSfr9@njj{A52o*L6|OzDR9~+GV#Y=OcaCp4OsF z`1wT%BZTix-%@GO;LOd)Jr|mXA~#D;i@aT0Ce!MTw14Me;x$+ZD~zaJ#o?) z2WH2C`%yU-f4nDS3miu7e{cuQ#k2!hTqBISv6O;it+-rfalgMk56e>O{S$IF z^Zh2E6GY;@pY$rE@5G1J=we^>WbhZuUD)AHZ!F2Tsr1mAc%k1amu!8ty@g2ht&|<& zC?YpbN@8Vxw@`xtSwIfo$MViCZ1sY@SJuOZ=wgOkM?WuCAprnAt!|O$1_sShe=p#l~y{$+L6Z60^ox%>_@}Ur*csG$^FUP}8t_EIw8Px$> z^4B8xPCGB2NT#J+`%45ynU?JGv@^5dv-e@vAf3iL5Gh}I1T2IboF-&E6bFhf(yrR(LMWOd^{ddwRB#09xJ)fuI!7XJcnFWMQwO9CuTb`mbCBf zZ-OoQgY3tOWhmIXs(;u)aKaX!P0so4MM&rj7OkY|hz6U?<=Ni-0K>2ofV96^Y{yE@ zAy9zTP3g4nad}h-lE*gVz$xO+>8=`!{}$7RycPQ{;6*qO;JLM*NZq*Q`-V;I@CFyG z@T8Ll*O4sVW}NDv9MCP0(7p@OX+OPlQfe~CNtW-RMq!E&9-v1UyL>-1RLZ{zccgA8 z9CO{eQ^WE;%vm<(ovd5rt9QaVV7VB*;-&rQl_b;=-fkjXAzOnq6@lQ*XpCh|{*I`F zp&Cqp6UTOtnK+QYWO%7l2`PgSz8@G^@xiY_;AKbwBdtX*BqLIPf-r*~to7Z3$bj-) zhIMTsp6cj@|5TqqSM))@^ip&aS?b@k2Y4sitbq`G9A4q+z`8YFyDKy+xfZ;w)>XaI zVekR#-Bs8zfc0)T2(WV}Fo&ZeAs~B?Y#P|1EK6EB2ruqMh>sbj&GNZv5NaZ3=BS|e zMaGlqKmr-*W5xc0o^N1*3tkB^M>n_;v@Je>I(nG;MY`ajgkHKJorrcF|4Wj(=pp3_KHMfs5c~q`+FpL zX)zwpb0~R9{QUl{Z|0|*WP64mz z&2NWigj)=>B5{p8x8WZ79K1LtWT}GoxesDLgBy;k3Bw#OR)fO;MuQ-!`0cH`|PbH6vRPDI~Ctx5RwO&xsgzLA549M8y;W??~ z^&Dt-9V(tE=bfdhm;S)UAw5mYNQbxnG8qX=sL=On)}4 z4YMVEJqvFQR+BN+A|F*ooPv`0+oc0lCz7z^m$tgJlr`d?3vt-^g0wj=v|wm>DkFk< z#3itSo}>jUuixP$jE|1X|ZjqD?ycBtG_e!i+|A^XCI$Wx3M;q=WG7LTly42S28PUTeuFo6Yn{S~!X9iK3k9 zw8O4F#R;V&I6s~!z9==}|3k3&4zF8=&jVeK@J$AvQ6Kxp`gz5XS)BhgR7;<3(R_<3 zZa#4zNOS55{J3h09Js~|fKv~%>m@geHBO{9jraQ+a})f357VAd3s9SB>X;yg9OTdD zA;(qrh@C5i3@HXT{P7k2J>>7S%X)6n@fbF#3r# z4s%*yLATNUksD`WDinR3n8i0>{^|rOCMJ_Su+tDbvf>jQ$5A!fQa{0c@#Hw#spfHD z-xFS>;2-2272%f71Bc5kEF9>aU3e=L%(aBGgd5_BL9#$^B2FZJF4%*m- znB5$05e-F>n=u{4a%oMfp_84lSPA4sYms8z%lhQYLKRWBHpI=*^b2UrjDY1B*z)`1d`I!K6lk(HwjZSK z`R#ou)Iij&l50i|yYT~}tasjH;)MC6r%5UKBAjHF0Q!oA8cV0HeX!%BdTYNp( zLu@!pPk{Xfr(($<%aUSB-)*8BAe9~uq3pp{CSV%`JVh+QszTr#DnJF20E?xJz$cn0 zp^by0G13m2;F;}1tbGcx{w)+^0?sx_v5W^BuW-x%7;u7qdL{hJgdV1&z4vW+<*bBV zvzO;D%7wpQ0|{s}2~8`8gkbNNV@gQ>b4-Iv&{iE!@oujkYtNw&c$W96gt#K?&6luG znxC>6-$QO@&JT9*1Dp`-QM!Xm%1I0Bz)RP8<47LZ^SpK^pdB z0EbY`c?INY%qN8;eTsv(F3tlWUJx*|OHv=9E}-#w)Ca`<1@V3WZ3bu|TC1V{&!Mvn zsI70Ngb4f$Y>o^dnZ*Ox7hDquBdC%Re_Y}y9z{Az>*CDt-~h+~fGpvRQNV%6(#alw zjadrJpqiTWhy42J3WY$#*f(t|DBd@WJ~1Qoe?^Ru4oD4H{)5~tSUs}K7%LB2dZ0E* zq&i3sIAjlXWyPr-a~$<9y`z1-kj?R-c0xwv38{zDlxAnlzmPnb1)#q!Ip3ku`~0Lj z^)?)Wt?$nz8Td>Y*!i5pL=YSu5MTmOgf1Ejwqlwm)~1shUglvVLpqLS!d*v(bQ;IA z#<9mZUNnx}alM|^INFS(-8d#0#}wl@$T&KU<51%`%s8eS$9s+A2;(@?IA$8h9OF37 zI8HE*Q;g#b<2cJW&NYtnjANm3Tx=YD#&H=tZd?oo%Dp2L8*fs`=sA2ax{@zYkJ35H zhT}-wr1^NperVu8BRTXQ-KcJic~f+6d_~fu(LD4AdOiP(U?;{>v>gUW@_p-ZjsJrM z;D_iw{$CsUrlFh0Rb-NLfxz&u5b)f2Xk{jMc_AAwsNo4Tq|o5dCL%@$@M+~cXb`T9 z3?V?hBHwrt{efPyBr+uRxGvXR<2aoi>D|oq2i#MDJNX%9Mk@R#{c9$9N~)77xbZ)3 z9(a?KE9D75#ghzp7)fyD-nxSyz|F?Uz>|{AuF5fIaV^w$m z8gQ{eVeLIsG{!0gx4?eH3s?cBWES3|B?aj@Pw@gaqI(BGCXL{Stv`?@qnELohkh;a zKvJ+0^+*UCfd1f}U;@iNlN)~u^@)vrHiC^bM36|>KOCdGeUN9(XjOL6GtN60raEUCuNd`pf}^+n&}%3e?)Nfg(+mI^@pPg?2E^l%6&PCCbN zP=kI<$pq(dP0JXiIMTuf9f8!RRBn41of%tdbxg?HM3PI7Lt927je(()s3-<=@GQ-) zB=BO#9RTwFi)eV}ZT4y$CFn%4SK5_=gZjf?MI!HS#$6>yOE>kw(fGA+{jghd49?=w zSnWe~{+rb)q&8i@#Pv>gy_T-O!nG2l&IotHgK)Q?E#Xe4MYxkv;m9nF7?1t!A*dVa zj#SV7mDp^$JqscOoA&{{x&TsSDgD_0c87!xihobln3yYOS~$%?oyXiD&H2;iRsMz| zzTLNiLMG~-d2Ki4`yIRtE4Ek4ud*7h>cRo zsH6Xa30VZMhZe!BzgLTXlvjTUy^`uomtUa-ZB$_V?<6Jo74-qEz<~JQfdPf^<1!;l zAJVMq_b}su$4H4mPI-?|JM#vcgzI>Mkq*SMq-VXjUWQ|gB;+9BM*qu^VH|jhQ0kN! z#^u4?xJPl;!`L*Tt9QnJ0&fB3*9K3y@+*a0!-PCJM#wi4jpd*yBZN|>IYfOgcjLZ8 zhZtDnAKIm8$AMn2R0$wKQ11ayJfG)(6L6X4DGHgi@)BvO#CC%)eKhuT`YH?@tRzpp zF5uXr^&t<`S0|vJ2~q0Q>k&Py6@v}QS*Yd@jGAn<;952B#PWgKvrdOOJq`?wso9q> zF=3`q%}@R(HE*VxucIcEE=b(jNCqhdzrv})3UsLvJFZ|h&3s_65cK7$9Al_VhWzR$ zQMH%rcbZ(^h3p_b6&ak0`x{?5M*Y%0@JN%c|59)(VL|&p^hVsW>07vl{d zU@y{RHX3NvUwouXm$3*;yatt5OIJ5)-4nCuHOBnFRN}~0&JFL?VE&$3VdzDjsN27$ z$ud2p1~fXBgXWA-o^~5j^dRw}-+@)Du+@eDA0!zm?3cN(t2GroXL!+47y4#PT>xuJ zN`8qNqbn#b3&m@|3>$eLeswjP^lI(|^h-vd#-neqc%cveZw)E%i#0av@MC*3b##K8 z0g<%o*J#fu*i718KbgvQlQpD^m+s(S$f46rBozFK{PszSUit@bFWg3j(~{@`$>1x1 zqvZ5SlwNwBAny0yB~P--OKsAg?!)}Jebk8m<32oMSAA>MiG=PJe%vRxt~ws4H;`E~ zDR>r*Ga8YoRyuL!tq-2SVYC~$>flMb6~Zim;@YHAomCD}YEVj(1TzJaDYF2JQL}<+ z*C4S6D@`9j>Ua;sdjwK?shNH;wNcD7in565*#Ad!5rkgCFepRDICxSkyS4}lEL8a?i$m=gv7@qGF_z(#DRAWx}<0Er5X zK!HT4=Ft9;Rvl%~-mRbQpgh5uw~U}npEkt|E*5xL0J1|mOp8dX(2J=>{C38H_o*MI zOLz%#Phb3}$|Qwi2sY)VS8HRWg9fLdNZp0aQ?^&hK(!o<+$jDSjUxw0OMbIx)E8h=Z6Q&C5;pJ51FGvU&;s=cZaoj04SD0D1eE zDC;fS9L9VONFz}h6NFvly&1N|7)%pZKynfF=o!M7#vsDi0zMPIhB!cQ)Y1IkaY|8_ zgx>x!jr#7_$f2uTQf?)Ux*F-(KNOW?qb{n8*l)!WLfYg1tjRp1Nh6ws|9l1iNM(qB z9eO3=pSl4RO+WijDbgPXh#X?uOGt8iASze_B_hCk-EGLs`^5K!9l}u%6JW;jWi|_I zm4f7%u4${eIxIYf5g5awQ}Q+EHo89swOZh2kJrg$AR*s;-y zp#mJvKDV%tn>n+n;Hmk=zJj8e-lEwH3TDn<=v{=;xdq=<{!hrA zG-aGH8Fj`ME_!lyVOC+`qB&0Q?D>TSbDiErMb0_17dwaeF~QCgrqIQxA%5q4U%`S= zLkj)QqB*066b~KZPwUTRP0hMLx6ofamh&y0J%8cM;yJSy4u5jNVs6T`tnm|P-gEcx zoCy>3i<~?+yWqI&MgGFM&V`G7&ca2r=Q>$cXYnF`(VPP3g4qjaKUGllpc5VS=g?-R zu+UeuJf@9z{=&J==nbQydr@J*9DiX!@f1R1-2Az73l`?iE-F~)D=&AWForrgCUPRbP~0_#}j zO3AJNk1vN6>e%^GD`Se@=%J3r3ijHF|=OlNVbyeT*=9vXGVY z7-ibVyR*ik(v_T<$9=?TvS}(EGl@$UjR#)>wI!~ zRI>_-i=kRZy(#k-;O-*7&k1c`xX9@%n!T{t3$kW=VPK?v&IQH&xjavHwvd;{jma86 z!82KaLJCubiBq_Vlct!bgig++i2|WEY0QN2c~gJ}w;J)BG z(wj0-D~q$@I}5&hP$ymMAJ4+47cN@5&{_2jwRg^4v;dZ%!*CAGndF8&ruE(~2Fv_~vmut{ z&Y^kZ$KoD0X-Pqmw{X!?CwTExf!TD@RAKU%36rL!aZ~0IXN&!XKIi;mr*F|BXYqpB zg@rhCK1p{L<0iTWUx#`o=9sNGZptFx>_XUjaj|oe7kcy5!uj6$Fdmru&?yrp<>jGo zn4@q0HEZP>wK#6X>zUj%e0})7i0@u}JMndco~3s!z5iZkk$<5N!ewgcFDiHtPHOI4 zU{>rLvKR_qOwyje0Ge=DF?4$VqJ^B(Idy)K&p*5HQGY?v^2_v>6bQQKob7YYjzS?R zMcd>SE%E`5*=p&0-#q7>MRNC1*Jqk5(^7Z(@!=Pm-9;A&@6zs|*Ix@gX#LMQlM zOa(*c4ksP{M{S;*?_-u{)*S*2QFJwMU<`Vhf_rs=4s-fu7d=(rBM&oV@i6D|`2~e! zIZh~-6B^_%KrtaObfLen5HcCcE#&-MA^vc|O`JWE8^6$tvo9V-Tp{S+^TzpeWAy8! zM|$t&;@+=~`|sem$2iX)-&@Co^Z)g@)PP~1pu<{%emCQL*yw*>PH!J0jeftYk1noL)P;p zuikjqizDxTe5{AAIj$66XBJ-ak%=e~UpOOA%CV;@@g_9>Ve7+yDNwJ)0|Se_(k{RyMbxIXLa!CD~lV#HSWiy`9aC`o*k{>Bq7& z*SxoE@E? z`IdjGO||++Dn?ZBnxT8m+knTrszVo5K}xK5jTSmst@!`OKMxb7qozSPTa=)9YV22W2ru z+}P~w2c1L5PV}TXGw!(y;m199|Eug@JouL$7`AXNd;8y3PD>N?u{=AOv#hXLZn0af zwEvnGt_R`UK2v{t7S$PwFAWVxP5Tz&t!4ZsAphj|?E(oAEvSB<+6HKU8)S-_DpaqK^Zu zCsCF{=O&JmbR4h6n_;L*Z4aW|E3xgytd4)dDkj90^f!B~yAua3`3bi82AN=yKi3t3Ti|HLFfb!626 zdOe_jlxWQwF#qa88Wi{9d*tItpyEZ1$=W+y9uP{(A_6>d8MLgB)&e} zf1K`{dXppKe=2@d!%P9iB)_@%Zb3c5gFe^l)0FiF)Q#7xUb8OsPw)b$dzo$JGV5~> zn4#mDjyfchS5WWQIL6{RU&nLYfI|Ib!hkspal!-B3C@Pc%KxW$oCJJo6NNweg?P~# zx0sB8F!^UR5BneVMfOQFJ5cvBo@a^iWRD~8oq~JQqIwQ_WgGVRL5w}-3}CXGhx&nn zNaP#xJ*NB&`<)n5eawLPx8q{2|B-$Ye(Z~<9me523O6o?354#A#CHPjGoG0Jj@9LL zEj|%_)b?Jq3&hd~eT$oI3Nmhte)jNyYr!GkL2y>gk3^oqF^Zq*x90Y`Ez&Wfp$T=D zP~Arj8=yWP(|aOb5N?a{9s09Kq@3!RG@;&Pz1r9TaZ^pySp5yCR*w2#UQxd&T7RW} zHhRE6wSn}fV|^;dqUp>AvImUUc}jGU4U9m&F|qv#q@&N7D$4Yy5cg-H-j>*UrVc4F zO7QT2alM?vtOfNq<9iToU!b-th*$1`30W`1j}xuWn4OSqHQ_t5FcP_k>fu8&>A-g$ z%2rYV;~%vZ<9kIbL~WBcPwYh9yQwZd>G;xaKNV$bsle=;+2G>-HQBliW(~+O(O~?T zjy5^?=PZ@f#_ZFeHJ`P@7z^7K2E=6(<=Oh>xB zL?{R@P!$0c6rpU2uPhY-70~=YpL6b=xtXN(_xL>T|9OA?nJ4GY`7USQ&-tG3VSccl zJrDmIqW+^j$~@QQ2u{C`<(_ql?%#m>F?D3(vXFVmaBjv0@>t)1x_~_FS2gKm-)5PG zGQZeYy#U@pds|w`zM}ekr(>~ZD`{47>4tS;)<9hUQgxA|S~}8Si+96vJp$8o_UPyu z{6^aa>O}SVMvvrYCYHPc4xL_ z$G7ag)=D4i$pHA9cHnho!^SVujUXH;4qF&9-Q{4r8EzUNdA<(6(J-uX7~8Kl+1%GM zjv<&g;O?|@M@PTU!NiKA$~OjDXU}X*=RR-q139D8IS+mGTYE-Fp95_7qwYdu74qgG zR%}&0RasFE>$<%-qqOs@F{x4d&wO9w+iEok)>rC=`2cR79`EM+7T;>n?gQWc&y9|5 zohU!PVvGH1U+)-5ADcZa4}m;6^m*vWu$N!7JbrI451AKqQw%q6Uo<+3Y2}!_Si^~@ z$VEI$8#(T#ed);5- z+hL1lov+gd)%zT87&f@-m}4i~0>jL|V|0`+8%4u3zTp;j970;B91GEf4meYxOmg*ga!E9q`?Z2WFF z%a?r=`@)yN^D22{KBC`L{q!YK?@;^LC*KTn;I7fp_9!pQCF) z`a^z>bEh2~9ld%2etoAc4(L_M&$aL2Z;p;GntN%2jKIe|A_Kg@xI#b?`oeOvc(HMDa$bz?w+_05+U|@p&ym)UcYpV zgEPlK>unJHu8q~xW$Jj`1Ls# zylh9C;4ZcnR%7*6vtFjrKm5h-Nzffl1KhH#RD9vK>-b@^p3_|o-1S7=sk*yeRoQCa zHWfH|+0S1L-Yv)CH5<^e;u(NDhIb$Qu-5H_^I;AG$E9&Juh$$u?sP}P_&_c==1wgs zj@d`DZqnTV++A&V*Dl-B$v(Y92F&!Do;8UJ-pjgE4f^Y6d^T{+NnBo-gz_ZSae#ro2Q zyA!xKfZGIP{XGxd-q$Q>?C$~K?}m98=6RU6VLpaQcwLBTFoiHRFs(4{Fat2>!F&R_5+Ho>&RoCPxob1uxKFjvFe2y+|EeK3!}{0QcGn3rMRf_We2 zQyACp5DpCgvhjNoObDh4rW0lu=0X_${X3sjKK!$Mx>!_G(q^?3Ai{O%nEX$bn55N2 zdLqM-YDkwr&ZDQrl(Q(-Aj=gQj7N)mw7S7aq@lZgunm%4Tl7_t;fA)M;aVmBvYYVQ z{!U1DsnF2Ho)?eX$Q2;?AdS&sihE#A?*OK?yJH)KydVfBmO5h!PycSUf-vLvpQ>cc0>j!F9Pu?rx&bF`5Gd?N!iC$QkQ4n#HhLqy9Rs_BNTCf06k>sa zAH^;f8I)~m!&G*eF*H2bu@%Bwm9&|HTp`+fB7Ha@p{=h2a*{R522bm0!$0ztwqXcK ziiok@imupW?C2Phu6LTPZKD)Utle29z9DKNT?&*f0P7!M;7k85g%yWHP50118w3p3 zDmggRzI*hhNMu_KenW3I1x%}Z+J=U}@jXW?<>GoE7-_P}-r)$o|38?MYn#{AHb91y z(y#bSLzw>?P4Ce5j=^CS%Xx_7vCR6G+U8hln4;tV1yVv$hCm=@r|e)z$`3}#|F--) z2mYM{CsGbzp;@B|F*QFge{p^=zdFA@za{_F{BZs``4{D1o&S3Nl!EC6vkK-H6ckhx z)D&zjxS`;tf_n=dD|o8lNP)jFwJ@u2Mqxo=UE$q@4;DUB_~XK73y&1OS@`F|l%o8i zs-o3JEk%){?L}WIy0Pe%qGyX=uyNdS~UsC))@#Dom zF8*clYsEX3Ua|D%r5`MvQZl1Cx~EB!&~3#Gp; zeXaDJ(oaffmo=1quI$pX>&tE~yS?m(Wq&J6DW6}0S%AYNN zq5SpoKa_t^u2-a1R8?%K=&IOJ@pQ%V%5ztIe#ONru32&Sio+|OUGb+CA{S=@s1r-_ zm*$t|hw|4V2Y2RQm4AKyjrq^zznK4iepbP}g1Umu1?>gB1(y|EQ*fZ*)`I5>UM%=x z;dO;K7T#9)&BBKZXBRCgT3WOM`4ukeE!t6Zf6=2wPZm8_^m5ThMXq9h@#Nx##U;fR z#UsUgioZ~NRq+kQ_ZL4>{6z6ji(fB(uh>{xw{+vu)0b{px_jvtmi~C@(WS27qos&c zNUhDkUA(EhyJCH1FE{(KNx*%CyrTI<1w|D_twlE#>^uf|6N=OXH^bXo>Tex%1bN1RCz8tSDPizhd)>Ggb_*IB&(JE55Yi zt1G^>;-M9fuXuHZ*o!k7#OFZ%t@+>0e$4 z3R?Ws8?B zU$%PLcaWM-mi>6Sue7MNx^zwH=F%;t=a!yd`bOzrN)yUvlogg$m8~snFY7M*c9~ut zD8HfnQ2F8V-pnr!OY~ok&&ODU!1=zzZ~_mCVzE)11rD*tdsF`sOX8J9~B)gdZy_4q8Ex@Dmqg1 zM$ubEZx8r&2-EqG?IGq^R_6C4N*2X_X~4ekwI5WF~edGN~M)xqn6`-2C9 zHwSME-Wfa?yf64*@ZsR0;1j_g1rG({~G)>i0Sb% zaa76S82N$x?EGA&`rm)H9AMqhC2S}x=6@d3N`#`HHxo5T(g!Ao#KVeHe$(*P#Z(*t z*c<_;@Y}J)KNfHLhjLK(e9SK$h3CBVb&D7cZ&R5?w@4};r{PC3*eJ|)+cnD$sDZ2iMML)U@ z`DaUqqM!bKi+5%cmb=YhK0sPY;9p6stIB7PP9zckGZpLb}djiVc0i_W{X^4!Pd zQSQI#v*=Br|9xEg9!!bS&j|^t=Cl5k`ze_gom$7tElJ6u=$@Gtop*Yz^9Re1fB&91 zIdBeocBjx}V_1soAi32A!RU>U+{L|p{fqm1yN4I|MB2I*%j>|42l}zEg}aN3dy5M3 zX&dUTXd51eZFrfkx8yEvgo`fA&p7Xh9~y4! z*tQtlyz7P=lag5>JFr)@)gtj0+v2W{E*o6Yhiy9LpsBuAUd`3PozH3S z0`K9?((te_W=~E)3-TK7kv?v$;a(vU2-wLV&j`K)!-Hg8D;YgI=m+8UjMZ>Bt%$C5 z_s~{s3FgN8(bJEcZ}e#T8D#S~B_fJ-GA7O}6~4rd^+5APVHtDh{b2QlYd2NbHnr9_ zt_wHURyQ`+h^Ei@(!%H`wdgi(HTO$T^CkLNwlQ>FPaC#ePgi(DzY*S@;hS*~Kb{zq zwY44gCEf_qh`OX+fP5>R4HXRMst z@Ck;i8`rhgZfX^Wb^R%C8sD4Ns{2uwb`GmpJ?%APTQit14lBUWY6c6$PZZz*P$+(? z06%~t@iPSk0F;Pl6p#iWD1NSh46P2=zlU3}PcEMIW~Crr*;-3v%c>#qoZ`5gVBL^- zUV&EZP3b;+H z?bs<^Qox;BEjG4)tAK-WGaz18zLGDNfe&l7Dvw@O@F9deAYN0z z6Xb0f60a-pM<^6*rgug81j)zZ;U0vMdKctX;g>1pu7W+W)m_nn5w+*+MtdA=kDmrjc&hIO1hGvy}6>iIm zC%k?}GFz)2%wIRKlhKNZClzatNMJjoUB0yUL6p7OIljy*VUKn#%{X5{vF!E*8i6+KVMjgC&-J+vg6iaFMs3Pc zH#)C%=$AxYO8!{{aOZrxSfzk5k!Y@6g{%0@s2Z`(*i(~|MprW@;Ui}D<7)^fJ)KdX zaxH$+)CuFDSd)^?I+>=<;0DFX3Kla?OL>fZb=~N8S;pe@6c6ic+6HxpTbz;Nkv+RF zyskCeTE9|cZ%oPCjHz0zM{a-;53wobBt}J6$Bu0)+lC@ybBgTM4c5?(Z4KQR-^Hm4 z=h0e5+FN!(r9p3Ncf0tE!un@2(gCe%5O=_Nc)A|PYfWkzAkI`gY3PG`x#Vz3`3(=91z6X5HGw@lkrg3GsapjuY>eleO+SV{KvsR83&5aFcc2%od z1nZlA>7>*RUP#Rzk6kIL5ZZB>)>dr_udG^Ev$4LWb+yos%Ujb@-4t%ES|yC*vTdkp zsIM_2>qZ5Ux-h~ujV;)ALFe{f2i3;ZXEhCW2~Z427DboD>RYw*R|uGY>EV>5J?Tu5ABZsQBd~nb}36 z?5P|Ie%gY)!Zj|>b$7O#HOAF65h+vnPytD|imR@^q1Gy|wD#<@XThMYZKJZ3i0Du; z73N&1vC9-U7!sX|qH9$4fyNp^yTXxcBwNmA49#pQt^F;#dfWSZTHD%tBBD!CJu&Yj zaY$@Y1i#jT>1ktEqdb#u>sc+ep@^+2tbkUHIcOVnr3|8xcXtelZbeA7vcaAf{Ven{ zNQ;%unyS{Sa7{g?j;)Q&n}z2q*=a?7B-K3Hw`P9_R7}uyASU9gW^$|m;yls6mhFDW z7Dl6-o>nzj`FZ5l?6f3EJ|aI^`)@OIQ@;>R;2YU~jzYTrCV<-&;LvvixWf!ozXQOX z3h?N>1ap@H{5mCq?^ZxSpANQr6p*H|vuo^6;43))xTymI8A1bK%du z3Yevz3E(~z#d-R30Pa`70zDC<+_x35NPi5J2NZCUUJl?v1?214h!WpXK%xE#C=V&G zi}_5T_^tw$>CK=#tblSIRatyb0U?9}VI$>1jea-S9#ue{{un5S6x(V&4B#;ZH0aj@ zcw7NZ`ttz3uYgwlJ$Ueh0ygQKSv{$M)AUoo_Jizf7R{L=nblw#tg?wETSXmLmbfOb z|1jHq3V5xSH6cfC8+>w-&8m2DY$e6w=!@BDDS>FU__a!;kk#Th_GYVnE!RJAyw097qsRY01o0*4aw-unOz&i?1Rp4C(s4DQD@<3IAqY6+};7=+E zstUZXfF@Z5{;YsavI=~lfHP$k_)vMhRaSwI6wo89z+Y4q7&ummqNw!0X6F{6sGMCW zR;xrYo~r?_Mf^2AC+#X0WecTKhM^NjOwKVEbX3V?_Fz+TGTE;hT3pYTkuzP^yl4z! zUbDhw=1e`vz?b1;)w&pm&}Zk&VBi+1wV@?kUDeRAva0%Ip||8_y$|BFXXwLU@LA}` zrkYv=a2BN&0nQX4b2-@LlA6S6lc%Y*u_@dDZA=ZeMK?NEOgx1{^;l*j1oAK}p8^+PZSy9OrcybUa69!Mxbc}{m{7%Icm zDm-k>8a-EM9j0GD$7kVDe2lmcr%%QVe1wy4usFNHQ$F0br5ZEn`qq_IEwvCoB9r%; zq{388v0{Yq+Vu^s^{uO^<*T*^J}lUPwa>csYvr6Q!K=F-#$K>vLuHo=>N>GX{x-B=CBB+g zDC%m#Y6mXO;OYZ(2|&eUG@0-QIlI8Jfq=>hkt-QDH`mq)5uzR0SlhgT^=dD{`i6$u zRVcL4Q5&?js^w&GC|_GQH`NjWP`Ja;nb+OZ+0lmeo&QX^72ytJ&M1Wg{cR?qyB7k4 zF$qI|cglyT{uPEFtHyL8(xVQ}@vRnb3WWprzhA~L0{Ru9kX#2CV#O$_g}nl z_k?8Zzibo!OESL)s`GJtLY{EbhPn+RF)iaJqzh0zq4X)Td-*;%sv!Aj%65v}q?;0| zaAz)Eic2QltdgAGhaN>F-I79*kq)WM0+Dp9|5Ex#s8A$*T|$9$r3FqT-6mnI^KjH5 zTwyfRs%DY=Q%B%k=2KW|J`1;F1{MwrZDA(o>F%%++tdpEax*3>)u|OpMDouO*=rGl zMa-6+-QhN&mCm{x2nC)av@!`MGgUnGQH&jAET471$=DGFV}%8F?&=E<_Y6t-&M>7k z+p*>qTGh;VFbu$7ES(N(W^pgjYp_iT4u^<#FK-x%IQwM0<#q z6+~9ab?L0QPKN<>GztAZohask;pnKKHO{U<0WP-EIMBh$DK;{fosPiKkexJu$`0Zt z<}5r0(H&lk-L8%3g<4uMkfK9fxqel6Z50)ILRZYGGHQ92=XBjOJk*Ju`!M!_J0c=W zV2mwA zgcRu{+Y}*Asm`sD^1hx6wA}S1%N`_?#V!29DTKCLXoI7!{D^F%iY!`PVs;pkx6o&Z zy0SPiQ%W-*XQ034q;J5JN9iRk;-v7FzL8k-j+K3L^2C%-vEbgRmIWX95l(KLoz!w1 zL2&n^;^UCvA!}JPJ){L`8;EolEh`C|3KFpuz&&H`WNN1*=EMZ8wUV4G=;{?zIr$Me zHaTs7Z^o^p$_%YlqUfo6vZMuJK2Dn40LvUJOzuB#3Y~mJ`WIj^*x9QO@dgRP$@Vh~ zWgzukilj_UI+`*S4EzY^8(_$>>K;nRE>NCym#2!H{GbECS8gt^Cr-Ma&0>`Og}-0c zf!Sk(*tI1|jG02JF41#;22ltRE@Qos|BD%S&{vrWbr=^YhOV`0i9{To6v93m%5wiZ z;8`V=yn!~AO<3QD_LmkCPl6#q9~O>%0>!+#b7Y_gHzkH6;ei3vdUSn5*eBHzwN{+_ zrnGesm_xr)+P2ePOnhS7b~LzJ&Bv}Uu^W|EbCD+z^>>xR)+d6aK}+}q(Lt7waZSh8 zNXIs0G4GS}0mgQLC90Uy#l2SSQoSV6%YZ5*x=$*2#eog|+62rxn7Ny0D32YvfL@xu2gi*k zkC-^;Lg4gky;CRu2QYg9oCh zc?p&m2;>8J50*a?Ck`fvo~r6tK&;RnCW4p^ku>%$(SN6I>N?j_@$?qebt5!iFLWaJ6FXSf~F* zmS>_)U$&fnSvh^ha$1a`1-r=uveOlp1(2sJWMF|@l+ap$>_?eAHOWCEk}ec~KYinc zfKR2HAHmJs&4>|EI-AVdf$hM}qqS)l2&q@#Qw2w5FsTOsY=&h$f%^gQ4sQ<(vT6uk zd=b_7Qn;77TZjq<+-~6fUx1~HmM_7=UNz}Xll?mudx6M$8@Ox`WeJO7tFJZ(&*)LL zb<)z#5@+Dr{+1b#cZRBID(mHGb$cO~%jhzvvJaI9*d3D<%9z@PoeR-vf}W{8+zO6J zIRJg1f9gOphVDVAmKI$BuCMid0dY#WwRWXwUN>AlGDywft&w)2U*pXlK(G!c1D{V7 z>usG~L!CQ#G==KREjhN16!Uz~Vd4^5AuIsy>M41_9xTH9T3G*YiCE__&$-;}&OW3s ztr&dh@HtnJgw@?GfFZI7XB)m_Jge^S#kKcVlg{q0E^H1t zPfc{e1ARiF60k?bNA!4k68Jq{ibRi>BGIkGeMHHAa%i`~OmPQx$K{P3*d3Q`?17yg-F(x>ZoU~`M*`f5@q1-Vp5vONBTU_K z_R3t=UK#lf?3JB`!(I&=9p+vct!l4~HtvmgU<7JJnX_a1_Xzaw5lD`OqsbgAqNg3!SXSE_z}d;{fI2gSNV}&1phrq4$ zeWuf~8iS+r2>?j}r=tb{rgds)`4iZ+6KZIA3UWMNtaYwHa&`OpoR*8R6sdMJAOjp~ z#Up-c=EHOxwd*HTK2RF@D=^mmwjUxtG;{r@#jXD|l^fcGt342-t354#wFjTZulBU~ z)t+Xp_7DO4YEQGT_OxTJ_O$5QNE^F0azwYewG@prJ}a@>exy~^@XaXM6RU=Qh*B*^ zX=bAB>e16?&F%aLDr5kc4c0Zx7ItLEuilQ>MH+qW zWG;yRg$nPL^SS?{TAyY(g=CF3 z7)OJ;fZ(`3<+JeOUu!=q8gk`p1Tz^GxAGOY8tAKgHAImcYQ#ljq)p9B?|$M31EK zQi4Ft&*YLBM<93-P~mY`NG!-vSUwb}L;9izLKTY^vTPvjaFjj}caB(W;$}%4B`a_= z+9{SyEkp25#~U}{WCyG`@e?Z;Bd22?I2Gy~5mKl{KxKw;77||Dis3}O$~f=N1=s4{x_XP7_2ByWcw@1S>fVHboazxXvA`waei z9Q!=*7YEJ5J$TMlh1@Gg@7xSb%-AjO{|Pvzsq^ICG+C|o2{AnuUuG)Ld&goz1%-ks z&6@)GUV~T?nQe3Q^st@CtyHE zc$^4>J%t~}lMJ8=^9)wf*$)o!Q?L`nI|9Rj&myYSv#jspRfZl%z zmMdXW>C=s{+(3Xn-V2MojGml;yo%3Ztss`a(fQBSxL$B#&3{IaMfbsMLx$)v5iV=~ z(}|{FP45q*HgNv)I5NR;Ld}18vC9v03(P;6_xua9od2_Fy`j2)&6Cl?d^QI0b-8)) zc0P=UGbNUg5~NEQB9Fs=G-RUA5k7@VGf|YH^DC5yqRtVC3grPx{1^LSlxH4fHBPJ@ z(c4juH^JniUGGG;y#l-KOvs$sdjHBCja$9_>wBbz>D~Ax-1q~#W3J9(M|ooxXRer3 zn9y%>qvz73$ftg@Hyu?$Xj0@;za@nvL&Je_fzWUDv(|VB;VkOw5(;QIgNEd}&~KA) z2JSM#12^5ds48=$OE_kHg#pO(1;=b(qYNP2fm`p6IWjmyld_<~F;@~jTDS}M(1l~3 zr1}B!nB6g7f&ndzW9UKY@dAlSgY%It+}9V5g%XjWLDpxWOQylGNcx#A5_r-h97{4& z5E-AP(9$tePiBgL0;KauXzeS& z-3c6E118RCy$-_D;v^5X#rw(?c;oZ~}T zoKW5EXK+jO1{g~V&nDRQnBb(>Wf8FR0M#k7lWIBPa^Y`C_4_b)%G8gOT=vMi>U3;< z1~tgsV9T1UGUIc|j5#M%mADRbir>Ik3YLC{Oz|AEm-NJS+D-_K_KkFex3`6RcuQK( z$XSAVGZ&k^mXwM`S_Qnv902AKa9R|7dR7Ku^h?wu;+Q>|VsxHy(j1Oc_&6I(+&+nOHkmH3P3pXc!2cJV2d0JB)~>}JU0hdhsjr5VLtSGtMDTI@Mr8jvovSCE zw_L3oyC0+{K8Gw4dKGVjOOY*ke}i7Ypd)XCi6C$IW`oSyUqaDE+I zE4YjDiDC)pmYYu%B290l&h5V|C_^M4Gp_$gcQeD2n~uXV;~-MwC0jb{+LM4}dvc4k z32)QO{a4#%`YAHu5O*{$(-YQpia+u)Jt2dTd6}NDOH!kk>51|HWxq^MTmo^n%k;$g zfm4_12_+Gdm+1)w09lvm38w;9m+1+mNh&YX6PgStcA1`-3}CFw^n|kjo0sXC;%t(w z%k-qrG7;uwdZOkiC-O4=e7KnhICh!-RdAGxbTX1C^Vp%_VO!<1RhRWTD~O zo8!I+oHsg}%0Xs6(8e<84M59zE_(`UN?%x}s{z+wsKrS7<^CcL7Q*-I)T_7=O zaITh)-h~p8VZ@e>-bK>SY~14E($TvllPzLOINZE`9k17hn_6nu*EAxn4UN^7WL!Za zXJyA_s$EwD5xPQ?am-!FqD=O(8J_Y(Omk+--?FB+x;kXXIWcXKQLI&R$+SMzx)p0)6E1#gui+bO*H4^k3*JE6? zIoa{1^my$KdMp*zAh>m=-x3B_qv`i{QzwzubX?!J!^tU*O89uv1`HHw+U)@sZh;uM zxfnK8{t4|)GYolk6xl-|eWBgu<-Eo)uw{*YJIook(C$v)EX9MDF*?-kS)twIEd$DL z;0ondp&iWR@DdP97@YP^$F&GL6IZ@)ZVino_omK6=uXEl)=OSS1lRUdUse2jW?2Lf`R4F_&U-c6_O)A5OV zpq^1#oNf9iL2j*+{dGFtODA8%P$_-DzC2>nR9lZiiO9*`ZAr|7=y3$7W}@o)2d$x6 z`}#Cy&HN!K4iRTRJOePj_^{&pXvP6oGbG`6Q zP1~Cl5`5EJ+yA{(oYU{XRa$jg@q2Bu+R5xefiCSumY`bwL30!NJ2-d)W(M871RwN1 zMC3!wu>mV0A4}Of(h;funXS4g_}SLy8xUFY%NT}7XkFavK7X!XG525I_IstuQTt~ z5N!c$6v3-+fpN!F#GD`GCC-Po2e7B~D)9dS$5+5OegWVHSgs@RBLLrk1*TndR1?Tg{5cKS27FymaWr*%sBNNrxG*Oos7)76Fcp zhs>4hfY$h7uDqaKj;lU0WmN4qgPzdGlVDjyfKjP{r3^;q3SBxZ=T>Z1gIY&+x^02w z6asYH4$GM^{sZG;vwS1y_XAC>r1hvk38w*ZHkh};ly(8Q8kB@Buv`YrB``7yFTsuA z2hB+gX@-nGW#1@;A){~rmKzB$3irTr7fjTxZn?Fh{~b^tAUmT^!L-K-(Cu@u{2a#r zsu}$+2WWv^jUdHp>Sc65Jvm&=T086N9k?>LJuNhxmb(E<;4e zhOvt(X2lN`3YD2dtd|40ms0{ zTU#o7=oJ_&ag>bZYCkR#b>rGC20Gr`l3ks~#xc8F{89FKvoY#&&=qg10XGDFz1u8) z83n1POnX4f%?t@jt!Vq%`Bcf6Xb(*1u<;2>@;#W$b||~*Mf246)lAwd9+Km3ExH2E zbtZL-@5&&lzi|L&yJTon*Ahu$JDEU)N!zbu=d}i}jscs)fe%Ll zwbUUoAg=Opbi5u3l%(B|!12(B<*c&0Z*i2O!VEZb>1* z(B)Cyt^REQJOm2G*Ci0p&4aw#B#3jokKi_;YdvnTu7xH9sG=2`cSx=`XxPr4V36$C`AkUorRoC60!825)9(&YC9IPk@{CQ*>f&@M=y!PzVFW+} zN1LH+g`-PkFQSus&^V~}HR*ZK8|X)}<;P%KH}uL_+8gvcJS^tb(hDBD3C%x)aYvJL z$ei&PGG`uzXWxZ!v=s>d3TDqUfDTIxt{<-<=2bwyC1!A_XILaA6GsIZ@4~nsQtXCVjc!N!X1TSLwXGJoD=Kh?dxbnzk^wX?gA%nQnQlga(==DJI+P9TPIEB z7{HhX?;#6PPpH0x3)a0b){`4le`lBnLPlISVyMF(W5S~z5Ti#uM*L9^d>StYV8oyD zcvvCrI*`pfkW~MUCZgj4w|d0W(*FJpbn!NHou&Pqcq2IsH_-mx1y4OOx7H#N+TZyU zjNjbhj9#gi+TZCxAm%1c`}^sb6r_nsMp~TC_x*6C$LoAoaWr+l%ZTZw&UXSH08-~W z0Y3n#^PNBdfYkX;APs=j`A#51AEWc#huCE6(2=fmzLVo}f=cH*!7B+Wo$my%Ca842 z6TD70b-okWubVpG2^`Q(o$my02AkCRPT)4()cH=}PTkb`PT(NiNS*Hl?$b@3?*txX zu+>uMJHdzbn9g^?hY+&V`A*;o^0r8w?*xB@qL@JE`y8bFu<**0JjZ33>rv3&sHk%Y zkYSBEsL}Q_Im3P?XB_if)f-dU4%udU=!_oEu|hhndKOfQxcMg*ver(I-kR z73z#+wjQTKovb~=&sxno7T|!|h|R?Ds^XzRJ&$dPth{!aPF9RcPD@`p6sS*ThD8;q zLx1`vFnY-C{|L@{tq_tR)g1r+1JtLpYjE22*emtv(4KyP{?NI-=ZGplhxYW}fhIcx zC{Mo-_GlZ8DNkPoH1;E70Y}|Kcls?LdiY8C=O?gCI5h*jfFbq&f)iK|rvsBGqwL;> zB|*bSc#o&=)0r}bN%J08DX24bHoY^VvqEF+3D1=H6CUQ8>zbqs-N>}#T=z_|PI$=g z#tBd0-zPj!&iViJ36BeBFZL52!qF2R0@ewSWHe8BXt$m45R9Gh*r4bM4{`Pr9xqj% zVMGacLI0xg~JQ&>!Gjb0naBnwpoffVb6ay-*k}d~jIEoeSM@-HyTDWBv=L?|R7{vZS zD5tT=JRE&Q4m{=h;U?@9W}Hy#hf}ckum;9@lJ5=dwpdyh@RGZf9n)t|rl9FDWQ!+J z;`Dz^2}7n~Sh8Zc8pogv`4Euf@~jyIqVq*=TSdi>!}+tVZ!T9-F@4{pq@!cXe%W+q(DP>@_=z_|l+y;w;x9qApzdN1->z3Vdnc{BQ9hWzD%kH>rV{h3dhnrg)aI31mwH~U^!M4`;^EV1L4fSH8bc>94^3oVtttQewvV{v& z@orw;%fR3w5aO+(@?M@_Nx);OJ2FUpa0-7^;XPX84k&rv(z=xz+r*#plDMq#OAlHi zy=?<{^erOZSLgs@2Ni$)BamMBb6(1aNPC*_FxTBp6Gc(!)D{P2vzoal;u9a_ zxsUA&s5!Dw;!9q*?#Sga8Jg;N(p@uVfn>YmFm?hOFh}O$t%DF|6Iq!n!R>Thj?s85 zNgc0FP0waqaa;^KWN779ADwQC?84};-RGG`xC`-h2iz z7XEtNp;LOn<6RFNlLQ}lw@&$xD|MK9GW9?$aWYH%4G}zZ7w_SsiLmhNvr)hNXta^R zcy|jY!ouDCQFk-YH9*i*crx@9M&Nl;xb%=8srN~$*S24YJm*{%-i(lV6F=JiQOK;N zZLR6)QHmPHIXQA8Yai!KQVYlKBAFFf+Ho$6EOK9{{|l z4+G+oIjjc%1^61`hQ%d|iMSWQH(^{~fD2}4!tJo!4+>{4s*2+nj`6AmLE{G}a6-G@ z{Qz+3Gaz*CY60?kPF`k_ZxbN-6p41B`&GcR?*K(HK4~!u*Zr`v9wq!17S;rNcynyc z0y00c;jl*IP6u;B{*_B%?7V=ZeChlkK9Z?y|i z#bJFQHzY0wx3>Qc_?HHvsG7&j#YVXo+Az11UsTHw9z`;~3*$wnDA6?%{VSk;29xw< z_{IA31NdZ1hwB;OWv2cX7FJ9faeOp!mKclU0><$uW`)~j9Ir<9CNe?`W;2c-A=D3G z5@p}8pn&~=hQOKsUY;S6bx}H9r2?G*lQb1^U}fC`B;%oBQoG!Y_#P8y7I?zO3F7Nv zhc%BOaPD@tjMfyEe*w%AnE8wGIRM&h(Ebb`rt8B7qy^>t8oBc85r!LkvS1&_nR zn8=gvqzeHt6Mg1{+KS6BfsfBSq!!Hz6Xdx#=R2{^7|zCq`dKg|nELC{Tj74^r^r+& zs=pFhW?pBhHuGRT^5DeM7xNFKzYYt7dyogXSAA^xLnT`1PGe3uZovrWFpnaR>nbcf z%EiraAx^__8>`Y6kd3(~)LQExvhW?4qv&FJcJT*fr_0Qs8SkP#CBzn73CKcM-1&`5 zRW#Rx=Qkim&u?7u=Qr?a{P~S5{`_X9kziYEWg!L$*4}(K0sF!$Va&qIWo^_as5>~0 zD^s3^3ptHX7`qljg6&)RF8fx#>zG^l3FFpcDgsJz$)j`AbuOg%04r|WaCMf#6$B+K zQ<;-)9Py63Gk`}AZ+D2Yr!5@mn$@FBPv z`{vb6c*xKp_A9SLjD7Hs6g_z-yuveww*ol!Gl!&3_{<>*$A0FJAq0{sgSZB=Ei!05(~74J*-Uh8ZFgTh4KE#%=8!RJ?2<>ec<@lh%f!W& z0-Zdj;uDA@PRW~FkTZ$Q%X)pi<`u=6!vya-8B zLo7O$qI$j$Nm7%IWIDoQk)*U@Z=YDCB`Cj!dJ}1f6ai&ON;9)}ce-k}S$TVa`qZjiTQOCQ4E%4j_ zTd-L1k1+^5OUR%zacAxp{7xBvXO8ihGWO1#jGC1WYa1_iXYQ+5XlT}*xjJ-h=!nca zbAt%OBfC3Xl%qFR|7u-_!$mow1F=VGEebBmk&~b6BJF$(SrhFiCeU}yNeTvDRdqJC{tBlJ$y)V5$x4ClDbB5ikW2Nqzno`7#c z4rzOHWjqWGHzs!AaXD;_Y~^!JpI3McX1uU?zJmOyL(#x4P`H3q)yIeSg== z3>W6+-;C(^87EiteAqR1dv12z18b?7oD^i?O`pCFNIY*y=G5{N_%O-#LGmC;fDid7 zH>@7K%D~Z#WP_Ru^Ei$-ASpUc+18%!c0T!yXX<*<d#&VrgatJb)s3-1|MCeAKG+{d7{!hUte%SN`Hvi4HtTdp^9|4eTx$Uq6_i%w+Zc z0>JZQ%tPq-28WwBRAIk?rMVBJD_}Beu`|Qicyu;i2lvBrFAV>t{uv92E8tk;XCW}pCIzN|=`k=o3S;mR zh$9c5rvd#K#&HsW7h!phz;XZ|!ty5=%tXWWL+kqcDCkWo>-s+PsSr7Wi95MNc5oJ6 zNCB(1{~;_5(+12ZJ?@;%Y;_}xoG>z{@0K~8KDfQj!KNrKuY+DIS2!?-NW}AoEwiOP( zt+H$#hz&3@%bX<0eA@)n1{j%d?Xa9lfce%3%ULk^ONzhjbb#40fan#1s_hS9?I$zk zYcf-0nUiW1g6(FnN+stlIt;aNQ?uK(x4z{u7? zADoBb#ny}9gQ_2M$u><{`2E#wuI(Eac_Ip4* z39>^;1~CyvwGI+w>&OIZ3XE(Wd9chNz}B$@mW43*i>sF_cgb<8nmIBsGbCRJ)AqAT z&KZyttvZk0Iktagj?~8LWt}X*DiBw|$a=X-*2}d3>xp8$TrKP6nE+3Nnad9LGK8b^ za}wf%3!=PHCvz_@xz`g(mVsPmLKk_C7em&YU%7u(+vSKkag>lH?V6}J(yO9OxLWQtcY2#Pyhr&Jd!Q9sx#o%3AMj?Ejqg^VfGB9;cs@>|!`0P0ES53^9hzrx4Qabr$)NOoo)q&Q zl2#O8gqEStD1`A=qwcUbE<`JFn}=lHZXi2=T&<9P>ai86L%`MmYq8-$%*Co{ot}xq zw-c&q%|g$77R+9xdCNS!(TNnM9CI5#C9a{Qj9ob59@dyP3FYXOEQ1N!(Dr$JkizjNEwg$$`unzvCgd@Ej} zgAzSU#$zLgE+G8^jISK7@EzO5{mojv-e_H@ zG->zTB*re0PdO;Wyfog2Cj-P!z%YN#AtpV#ZA}~W&fDRBac?{H0pLYz z2$|pHOsAKV&H!7U&xJibs5!4MC|rj9&eLa7_{T7th<1jLAU1YB3kR!U&0#t;Y!jL; zw6G%`=(67-vH`7a=8l+a?D}f$ej?z$2kxadG`e?)a`I~(j*XyJ;C&-A^vDd}K1~~D zT)KP=PujzUVoM@jGhD^1ZFO7)L}ftxuJ;)?_#j8GM)ELwJtXtc)cX*W9d8mIo65Ym z5k^eH<5QWlV+bbY`%^i&vQvg2&^U}xo{*VsrS7~UE`Bhz5qV)Fm=1n8 zH8d8Fcl;2*kEX^YZv>eG{Ew$HtHcTeSz@Ie)eAES5VfZqKLd5PEUces)C=owQaTZ# z9I?LD2Da3;hBwyNw62B{5sPINa80-}S>Hkpih2}J-Wc8y#Y5Nvm!519v8tN(OUFAX z92JijH6M2MqK+DB*R5(@%@--hBU@Hu^Vt?5lUE+A$D(6lB{~)xJDL^Ah!3)QbyYLg z7q<9fmv_tXR%YDmjuB*5;i4&;_kF}R37tQ%)3j8Uf=J?o6|!d2wN%;tB<<+#9EJv^ zTm^eVAG2D|P>?@qaDdy#+Drup0C#M~Dc+2+G@;E_ZnBd) zq}Z%BNAcw*ojuZz15a(Pf@dY+!RB7?^3BRMAlMonTsoRH(nlXZCn-R6DBce9hV9{sFx+xjEU{_E74iU0YjoVGkUNNh#sa|Awo3sm%W}{EW&3lTAbz2xvpV=p% zUQ!LT5aQk`ye$&)7?=q5b#f-?ZIu|mF*HIQZCG?!;d$3fNkp?Wtu zn9(ZJH@jY8rUM^trqAJXRKCX$eGyErH5@9f{vhjHa3+#MS35U$VZ$&B{j z;j4!h4{c%+rxV$j+Cw<9O?bPKIL@xXX^2ODBx4Q1H|a!z@*_ds+Z2Cz#IeE@DPmK~nsZ^S^n*q^>{Th9;gn9Rmfs(|E<{7OICeuk2gTt zsmT9Um}J>8_jq#{{0I<3M6#Ooc;}LlstC>_jtlG_ZyAfgf%IQOoLp!3cvp9TF#~5I zFC&hD4$wl|rBgk#@*duq+e$52Nb zI^T=EjwL&>5^rb$#`AD|wKt37@^*NZ4MPn@7=y$$-YgE2 zhq01<5GK$LQHEixTDFNBypx%TdDu{##!W>z_Fx@?Q^W>1IM}b`h=TdXh+5U89 z%mE~4Ka->eva|g&m4`R(ncN|Vd~vqFh&7FVW-fy-tX$3wuxPMoau@o>J^1x#A$F{PsQf|7;hwV-8=AJTcc{lWW>q)E)aKubQ{Tf5G1Cw5F{iACfATi5PP#G%A7Cd39%6s568hszB?h=gSzUtAK0HG z5>LW7ZU^9>f(`>l-w9rJgNGQfxzVLx03>VfZ&Yj`A#P>A{%b^(o$65=zw>uMnl;_V zEA)43L*fXsXcpptRxl>TO?4r0-+WX>NmH#tzZl4as7q{Mbl{ndG|C2}KLyfHW}_cc zq;V}u*H?w`;u20FEe6yhy%b0v68#lBauCR$BJuaxk#_)@GaKjS5@`ndLm=4!A0ZOC zW9)nQJeSaaX7YY$=j}#~XJV;ZPR4y_taxug>tu*?6iub*Fp!LJnH~8LAQ|B%8#41J z$Y17_NMPp7s|lg<_M&)y?RT@QaQqzD>F|CkjN=CYmcddC<2(#r4*Q6TAqD+2zg-=Y z!v)n}T9e3~YI-Q9c{Q{i-%d=%y}6u7`q=|d^X3yheG)><^ZL2oah)TyS>ANc0CQbK zTf4i4p+-)t^Cq$f!$`;;+wQ+P{}e|&jb;ExJaae^l>r>_ltcq~(-RHgcODsl-6Kqw zhQm8C)xu`-uiT%&FTa6U{mQ+BQIVeg%3V#*Tzs-c`>oqUCKvQD{nkC1Bjxl2@W1Sy z%wb~sQ%K#*?m3)ZuX>hz-9nr1k^2g{t`4Cs_DB_bOeE%MVRIoe?2-Ez(7~jp=vab} zC=^P7yIm|lIWe9hGGdv$MMZmy`hu zf|>Gb;0D}s)YSKFn(~SSWllqB;S8pKz&({|`#HG350l`{g^CD-qKU&`-sASY&vw5G zSRc+p95iJBieOnxU-WT&<7G}da@K5?u6IvrL& zqJ*fg0^b=^QSt~LO;+^+`VQ5l41wV^FtGA2mJDdwxnv{dY*6aaqpT#w>VYl?lGDA7 zcH~!q+?xe4zGDUQ4b!VD?5;iql0(VY$GBq1Mx_e*VH>+M14xE!Ma5o(5mkgM-2-JI zahU+qel}nf;mWZ^xKb72E@WXZj4Z-6RuOKMMYzUo7GaMp!ZoT0p8)qmFtP|QKq#7^ zz;LVEcR#SQ2#><@76BGv27*t8kwsVnOCbRk;VG~*!Z>d%i!Z{SaYeWlx$z+Qn7hxw zZ?gzl8qBCeAhC$vB*`kGw}E87j>h=qpeCFjnCup-JquM1R=NbL=hHF=876YD(v@d# z1eLBLHi9!VF~Nh8Iaud18-dEfI@g#StaHgma2dEi2b1u~bhJX*2)>ORY;yVj0<7ax zU>|_x9s)-JyaLOM1l|DfcUb;L;6(rdL@Wu$8H6uZBPf!MV8R@fo%0?U;$sN0-Q`{m z*RpfYfhCgwJLh~@7Qr}g1+UpT3p5C1^eMow8MT5OR|XG*#O&^ik<9FV0u+|cMA>gHk0LQUU&Oor^YOhQ(b*e^fbRH@RiSHx!+J0t}Ox#9iF5~2ackd%k zGI1N7iFTJ zAvk+Xip&Np+;Jt=-PaYy1?fJl;^sQV!jQN~*2#ygVw>xn%^cnU@7KY|Vw>wMV6ib# zmk`GqIM-RrB6%K&!Hs?vsEJFwO{q<#duU#h8Ys zv;!15d_M)c9KIdnrXG+*jSP14ttMW1O?I+8e3&RCJLfURasra+-PXw{q2s5(@^}7ikJ%TXhg7FuI zoQHf9#Jgb31>-M_$qZ26w_yrbz`ro&a;Or{Oy#I4f-E;1}y?5VUlTOkdvXeAfAtaro zvqAQRge5^RkflQ)8>B-LM1n!Yl}Qi*eIww4;s^+cqktge#-gLlIHKdIgNllvj^a9_ z{`r4@Rp;D$Zg&TqdEd|b`Tu#}`}x$lRZneCJ@wR6Pd!!V)Fs~1Og#B6soP4RMfCa| zDPP1jvw^)dGfHF%j;?6#-Lu_WmZ^~$j|-cRue`scwNF2cIW!7=LFy$4e;>y0>8JDwIW!2!w_ zQ0zK+`^=A3nQU$TK4bgXu738Jsanlg;j_=2CZQ3mP?KHPU^ZY|`kU`w5!R=pN z0(kzLj1`euEL|IWt(Dw=k4x1NUF!MoH*SkKEgFT~ur+#d+W-VR1K{Re_mgl>Aq+Am`_r%3jCQ-i1BtIH_c zKUO`OVI$B4JD-$hcRJA=$xS1 ziO?lh^3HWs_%*42fg8DDT#vT_+&(_aiq*}5+m7#`0Gt)8Zz2h2%c*;7khNlU6j2>G zD^^dzGY!{v2kBz5`k!N+SbdCA;eR268cMum?RQH+;*hmVyx_OQN!I>eaWc^)YC+gO zJ|%?{sGCw{Sg})TZJM;UBZda|PU!KLd7`Q&WT-kc7%3y{1)zF1&eCsaFjYNsKjDwy zf`1{C%>D<+V(~^DTJZuvmGQ8E{+f}DWiKAf=E{%sc&YH@u5rHCTwvl)O8_tYBSjRK zf0Wq|U&jgW_t((YbD*<-Otb*!_xodn^Go3NJTB&h_xqE?2@^Z%ahz?*`~CT9$<2hX z#o1NE!xYEN?(;3iQJ$1{HT-m)Bdq(mq; zku+uNi*O5&;zU{OqLih^?(DqTm$_GZ&6|BEW~6Ps*`Hv?#)OR%F+u=7w+A=z4xGZ zpWUXADVbR}xX+(bO#gYmSavD>4)120;g&eV&HgDxWbTSHoT#Sn8Jm0-U&cC1;*k)m z9cM?(N<0g3;b%#hP)|IPa1zI&CG?M=Kz@)W_{X$5G3NvN5biphxtzz@h?iBk{9yu* zZKn=)#QGq83Oa#|)!uMhVi}B3HRY>IC z10KHjJjA`))PH0gNv;KYJ<)Gbde!wi58ieX{K%4MBGC?{KY+_O5IBnh&%otRAaDzws}z__V9^95 z1uj3Ez)N^uz?uJ~%nnLYX|}T#Qi1PvL!qw+P3x3IowbeV3ss>y>smZ-R6w0|C!P=E zT#vkMntDX<%x^Q{wd5|*_Yo33h-Ko}5C zezW~wl0VA-tcl14+*s`oy zATw|NOL%6UgxMWO4w-mo0{^CRe1pK-fgi@@e?s8Xcs_;;yXW^ue=oT!v;_JtQP>SA z<4d`49Y{9)2T=1%9=XZf3PkpaAqw#(Qgji0azr3FdRn+~L1~BN=xMA_iYtCE_(fdb z0x~2E>C;KC#if{DwFhsE6YaE#n0AxZb?~k@=1ryS!Cf)ze+)?d=Q!q517g~!QoZ(M zshribfJwXsSH_V)PqG!w5+9CZBKpJ*(V{V+ec@nqToZ3^mo5Q`0adJX;S9Z#kf-G} zuc?F6?<{!UQ@CHP>hW~7#H@J`^|%M82Yh=oKKhJUm9G8Y`242PWN12gP__=-b(CE> z67)YG8k8T^u62ia%#0RY?vwgbUvi|>nZ~Fc)n#d7hK9={4U$20dxy1XH z9|?gBclaIHzhy7syuw_X$I^#Ty+TzP7uBLbUFHDSWaUOuK%-|q2gH7aWGdkXtv}gs zP?f=V{1!!4ZrsQl#~T+-o$t*Es#L%Qq>PFW&nPFS0vG61iA@(w463hA5~+X%_A7EW z?!=%@S#MLc3e&wJoCM>0W;Xh)P{3zb_jsz?KXwn8WH)(dH7OH9ExXP;tHb#-J~l>M z#%83%IXHjQ0p3womOBU?b&##z`Wk_PDa;`PUFxm3XH_=>*nso*lB-2Yt|D~6LCDoE z&@BXS6ll|Kj)v^_&dqC`c@K~~1>Jn%WtScF&aD^ln*hEpAOO1>{Z9hFw}8Fgx%M1p z%T$Pq%U7Mz`G;-xD#To139?w%;2n*+Jh8p$scBZ9)Ct4avU@b@PAZL16`YFIWSv@)|Jy{#0+rxV=)$t08r1QZWQCXnI* z$po-U2PBg~azHX2)&a?M*dCBf0I36#2`F_yayTapz1GYcKV-;Nx&xADh@f7jJs!DM z{tSK&o~@HVS6Mt7d8Wcul>-h&o+Wr)rOZakcD90zUcG9{oMS}XCJDl9GJoYah#QVO zA&=O&Wz!DMUFvh{xuDIj-2$r3Tf7rgNb?GBqQYLoEfms+uSK2cdmW?k+xhUH@Kh!9 zp#ol&oezaprm8d_Dx9lI=0k<5s^a-jfx0R?AAXAh8u^MSf40h>;;~hFUZ<+md}t%< z3A~IO={5b9KMo^o+P;miAMdpjSd*fF6#*RrL>qZ`r?=OpI5nu#^`CN?;={|UK9Z&8 z!qbB);n!E5@B}ow&3g`d;naqkiIL0L$9v_|JN4eEO#W5^qbaU@=2dl%Dw)bvZ9Dhv z=+iekHM=izcs;LpiJtP!Se{l6!sLg!=6~NCCzswO)92IS7%c!bSZR3q`Z_p zyZ6r!Qa06TF4ewbs-&FlQ#aWJb6tX?N|3~rO%Th18u8%$=>(gmy7o>FYQ<=}5!2zI zw@P$lK}nIB82mhmRm5ebCN2$Rm@3gp#N@E!_8nJP#8$hmn~RbOVoY+**S|Qcs6+<)$No}>3a^cTCi%``QaS@yvuUe0T+P3Yz-|lBTVmq_W~cqr zS*79%m+(Q0J>JFB)kyXpYV4-QJmvZ*(SL)h;as$Ry=uzZp2L~#JGSrij*Js* z`)md*&cZ0XOTd5aW>tx^z$xC583HbygV79Uwj({fBi(|1gYbI=yVyHwTk{u`gvW8_ z;;DR&MoB)FXYrC&g`UO+lVg^`jCapK=V?XeTzJA1I{?X ze)MDk^NT(2rvhHKW&35`4Yn133+#5B**n#{!HSc{(^zp^$PDiWi}a$?k$gC#6S*5V z>x$JIEZXCjFnQt(AACnJZKpjq*xuLz@LWZ#@ZO#6hNC`4;9UZ2@@}k<@OaBo&s%^q zo2Ga-)(CQF873H<*|HUx?;kCQ+3&_qfsU@A&v0hv zMcXNEtYD9>`~Q#p#Pk=?l(7RDKtMenH=PvR=JcsCd38a(2{<Fd zdJBOGc*ZHPlfcP%rs5{-C-4c-8FDqAJMj2?UICl#(5r>lF@VcSrdPqdx+z58MkIzX zrM?N$c-%@yzjp-H_a^DjC++K$KKL~0CAhytlr>h$9{NLonjwBr0$Rh|(eu2a)97VM zaogRRDqb!n@`5dK?C2_2Txgo^1Yf(DTNLr1^QU-2Kb(~DMLe2N7RJf7s;PVQtR7Dj zSn#8BoM&qs8y&s(P;`Rvf@Ls@s=hLg$Z$rT^RnU}f!|#Oe@5UfG5F3@CBeJS?eR2? zel`YzR`3EaO~BtNB^2hFqrcK=+&{)SewoTK>vcVL;;*ePQcpRuNB?*gho?t+!8vh) zF>!(z(+>ld+L4^6vS8)(XIL6d_JU86M4HF8@}lDcwlQp^aa>z0m!_%!nepmwAy$M4 z{(wjIe4@M%e^ydU$N4USw&8$znWJ$zr6hCXB)=;rdB9awN^%}a)=*8Mri=_Ru6v19 zOQ!UXwY_s4vEzw7BaSVSBEt&|ko`k(?%n-!U#-~th*eA9Qp|0^&4>Yk-(Lh<2ALj% zb=S69YmW9rUfp7#B2uskj}ZM%|Eg@}8{%YtDvvEu;g4~G_S%>V&hB4I(8ORAnP!zI zh{<%ASaILEacqhZx2UO~xzNR5QH+nfDs$Af`ElZNp) z=!p_gJfyKux5?4sC*@HwuJ0pOB+J&tO|_>SGNqjI4g$KwjEnt)aR0@ z7&)TeNR(LMd*x9Mt%;BKx@HePQmD>&US6xSNm2s)c5m|hk#=e0Uk#RxRx|97658mr zlnGK&8lA>?vee0n?cOM>rC_NUWmT+-P2Om$KUAR8j>#)?#Q|?jjq^k_H-R27GC!-;LMVqUpN_#QdUR$xloN$G=27 z*F}ff^?yx3S10R#1*Dl{505A^V@m6Pg>xoZ|0`5w;`P4*b;hp$3lz}kW$p!%-^b$w z!WF-#P|vGWm0JHR5}Rsjq36#HRmRoZd%QWM^pAa$Y~dzvZj(G17lRdEPfq2%8R{R# z1qZwyd!Fsx1m0xJs7Z_zcg3#PJmw_ z_DNjr%>=%K=i4}%FYGg_mM{CHmaYECNZg=r3HrxWD5#EXj!~_@#PdfEQ*(Y#AwR@{ z>!`did;M9MmT>{*@ln-+JVxj)3)$+8vgd+Fo=v~tf`i_uW>x)q0_z3Xg(*VCzKOtI zobCzPu{$+xpNB1pHwDJ*^S(Wn-l#Diyx@A%^FH6^MrkmLk^Pz)rJru%-V@wo^lTob z<1hp|zRhfaZ8bK*HfbxWru=(7iIw(M&976=Aal0A4K}ADbJG}CZ40?N7d^MSFjq66BWbCbRS+0${gVuSO!LN#Ot%KuaFOSP| zdz|oU&y;SD%W{33V8daNLGN7C^>G&E()Dp!?vMMi#A{|n@&33h7s&mEax$5I7szF~ zK~535OxX=`S+0<)pt)7HjM5cyS?-V%ybc_bJLIxlBKIOiaEY8cq3|k~$VtJ=iR%i3 zHi%2)j$eu{kyC4H2HX^v`*=qU1=kF6iCpgP&J&5s7U$mA0R3;9pC9mV%E8f zqrk9HZ~}VU7&Z!&VhkGv3VT&QCNvFSjKL|VK&e*4#`(L6&utA8+jdAeUZphy`~5G z>*%*#peSIa?ScfbeHULziZ>O;30PIg((^c5P2S6?%4Ti4@7TV#&%3nF5Eq(f|E0&P z5@F^MoTbhfL2vB2Z2RTRdj^mt?U%-HLF z+^A!1TW$5KxDc;3iS=3ryZX*M-i3H; zly4Ia@mdDG!OuJH#eQ!usHV{e44%dJ%JtA}lRY3;Eo|W_PY*xykAa z7`I=JZGv|A$8IKF^@ZMY(<=C*Xe1Besy6|#FTVUUp|9bjU2ci@VxA20;+A{&^>{DX zLV`+0$d)bMlYx~Q=)#R#2@F3#ksKy)>%%95F)m!iF@~=M6)NHvl>8#DlDD6~8WjEl z3H|L@JMC!w6A{0|`CGPnPdiZiIcStP{}Lb-BK;OZ>jgRB{bP_9BDWHHpCAW;*a7<> zq5A~60!Xc7$Bzm93vT2a8hM#!UytYOflB7R5LDk@0jJ(b#Kr4*&kbj8BXAJUejEp~ z2`#&vJRd{I&M*xRlYabefZk9QcY&fFBK*O2o~xOPR@@@^n>MCEzWSE##@ zoB@^_mhoOrC_?XK`|5_k3dlWuJ>C?W>>qnx^uEx$A!rg29l37^hB@+P`~%qB9W5T9 zg7@J7O$JsY`b^uviU(JTap1N{jX|?~*AsCy4pQA1vr&V`7)eisM1EM<){lT>+vd? zIQ?U{(HF4gsX>cc_!cr;iGwXq4Mqy`7@^PL{LS8M$4V~}`hg(ZIUj2IXXxuV?}YRB zUkJ#``*R2`wV+Las>ML>BY2ZQP;HK*+TRnr&w;iAvsBBR=Xw8+g>3@VC_)Yg)Q+_dfE3=X zFw5ydBwJ|sc^*@UJqZ_nn!s#4r{da`%KQV6(+DlXnSUX$2G1%5enQ|}JnI#BiNF>- zn-qANKp&o63RGbIy93V$a7?C;QNMUHnn({8nR7C%Q#vH(4+}z(&~GMhJzwJ*7Br}H z>M;)u3x>&C303_Oj;#16D`4qad4NPC93k=B2us0AHUc|b2UcDzukAPuht#(7FMP`{ z&+h5r!XOZ^mjyI#@_ymBsa8fr^(6#4gxw=F=u*bp+3^#q;hqs>M<%(Gc?5?+K0gxIdm}PA0?X zcO=qhG?YNc%j~!++giWd(W+;65BpZscx)UdGe*+g{!r2CI_lhsgJ5_2Bh}155_%q| zW`4pobKV8;8_wS|15mBJH5X zclLNg?}od+4^sZS;ooQd%It7cz!$(SH!D`QJ9)%=}J|hs0L4KKk9)VysMPvH^aKT83Q_~%7l{+mFb1jDZj$~PaTc=<+(P>vVlgjlUfpONu8znnNj zA+5x#5KiGMj&pj`hD;r0&Vx=PBOp0{vKL)UVwLU6^{UiYD%h%NfksI2IK^Vu=TGnK z@ph5QKc=Q5PZ#@Eo^GIN>u_+vV&BTsgM{|skf*1+F2A18+XcCVYpJX}{UD)R1=)Wg zAUkXxAb7t8Z31M+&F2U{VnLe$S+)9`1ivEC0dJ`rBxnHMUo7N8Kz5L{0;N61v}_1^X(k_ zRYG4D#2xNj;akc4V<0~g^a^g>s{18;@>@bL;aUs396Lnk;?dYlb($NL1zu_!b-6e4 z=#r>?ar@NKQ%9Wudm}WHgDn23u5Wzxdp*MLRKGzzS&2NH?MUy9aWgMd}~F@3mv7qMD` zEG1T?c8hN>KP!e`ScF>{8Z8B`wTw%9gb=!eX=eQ4_4S~g;LsAD4Dbk5T#pOK5O^=1 zn{m|oYZ|1r(!+7-$hv47d9{>L$+^otYXlgXP)U^7sACViIKMDg^__0p+LKjVmkis^ zS70pR7%oe-2vUZvTNWSSrNLcX%op4rRVbx7jjZ+Y1xWy>+VI)-lL7=6Dvg&rpJ$s$ zt@bTYdyc(KKJy}=kI;e-YMip;kAUJ~4cv+aHu&x1H`sfxUl)ZX+A zWzMZ`*Z(q=y8I736f%Fr^EFaGfy+NiVy#AhO2Mh#iJpq#4(4VzDrghuKTF&X2=H~m z78K1cK+4}prU3_Asu5jdT|Jltq;Y{Mlz?3-oK=YstdVJFr@M7{wR zjUuu%JIYc6o-a|S82IfZ*A&Byvx;3hB0%R@$;Ff{+iA)u-8`!w;0MbJE> zl{zZOeIw-`Nsy>V7z7(Y;uD$Y6C^%H)&UV3Kit?|rUGSf3 zmr#Elk>}u|`-$w|g#09cLcCfti|c~x03}@zC1cUy*{(){A8~+^Gm1|#(Oit z=;jKj@V|TiRkwH8PqF%$x<7$Vf+wH202>w!qxJ7^V-Zi^25g>cnmkEKTL2btS&k6; zQLWOAC3pf3Q|a${djAGY{kmm5mb=$fzdlZ%MvPN$KZ{EK9!&LX5O?Y5=8S09@2!XU z0-IpJ-frh3(wkaO5|xH3>7YAuWGKE6;rHK+`|Ebu^>rNA%b{;OCdS%7_Fbwq`@L5E zFQ(__;moehyZXG5N_a8BEjZrx@w0Z<`)Wd0;^^WDItM;+m=kqE}*wPT)KBN2?Xk;yd@!8jY6Tq6;Tx53mk62SxuDP1EGoM-{*Yb1h+ zw#;@fYp;ry_@e!y<`vfC?++Jn+=FPpsr4TmcR-5b?ruaFH&o4xGyE(hWT zD_BvuU1_#@FQ=4D&~K}konFCnLD^0(8@<;6t+EA_HhS6T6}S%Ul6_vbdA9&+^eRO4 z+^cN!?gpz0u~}}=rm)R>{8F^dt9E7wZ1YAR@90y+Altmr-JLptY>L~wru=of*1ik3 zEnKbZHx|rax0n+U8VlL?)J%^U3Z;mRE6-YS=F&5ltX({R(c(oUsX2T8+QqBRTInf$ zE+0g?|Ik01GZ!n@oVmeySDm$D1)37TrBhLF^*y}+953JL zPp8vfemNCdIe*=9Qn*sj+^}Y`00h0ZnfY@oe*pJodcFMX?5yf#tPv)EUh^m5{SGf{ zDYf3qpC5`%Yjr*LMwx!FPuQFIx;5#9X6BKeVr2TFzFrqSo#?v%(x&v5Jocj}z`?7J zZ~pV@4hW_Rp7YxOm7U*vQ)Sua)qcKM5+5YGa5Go`Y~Q_UWADCQo4w%tmgk6-tJDbf zEL|GPt{+^bJ536Edc9R6m5!5pC4!97Rnv+a)nVg?Dy_7r9VGOReU?yrD-V{S!xXiL zDEkN3e5xO0y^jSVL_Gf}0#D6=kLvha%$44EBTvgMGqH9y!L+sz;^1q4e zs39iCsxy0_$J;_qFaLLO?8H)R{>w3}CxL<0Gr%U2qllHaHS1LBpPxtvyrxv@A7Xv@ zS6m%b8V->}=pa_92YiOZsnn6K9CH^(nU&3AM;0?~Lyt^- zmZV%Mh&aE+CmOeJ*|_Jz%P!ox71QiSOtM>cY`x6O+&JV15|%&VQLEPkGW{MRLPjeH zu4v?0H<*GEy{Be@bnRDAFtTX{Ba4FZRm7;~o&T#~{2!}e80ifu1w-MYf}ud7VAzDQ zf+2rO!BDWIV5CAt1w*j3f>EEaz8{|OsMQlL_)d@4An&i?$&5VQ?CW$2jHItHc&bLC zR)?ksMI!tmc?-&$WZm|PSsTx{S-X{WEZ#;gSlG6I<11(KYfn@SlGS8ker$5NUKvY2 zYjbsw3!^Z(MlhQ-@7lV*C{x^(|Mz*WJ(gK;XUHt~yCpT(a(^Yb#wzXVwEwVW>T_i>AM(m^El;cL8Qy1GD}G5`HMftl84`NJvzF-mP#z z_tl|9FAO%do=n<`PGuNWFMLIly5(v!#ML&?EIaTOwzLYEE8R+K?g(pbDMj;1=`FT$ zmB~z@vg|oaS1piLB6Fx(+03&7u2|z>*VX`)$o#S9r{DNp!@jAh<2Lbjtv)6ivu(?#sOVsm zisr)dz4oF(K6IATIO{pc*d)R~c91n+6wAJ8sI9P> zv)l;Hg?tX;)OzjU@)?y#=RRgbIYB!3|E8$m!6 zB)z#7TG&n( zO21chICoYV5kxsS$P zip$r==;oV~226`-;JvzQ^PEE z!`~A6upn_a6xs#-B9KRM%~w<8CW>rH);C&)dVXi6>I;tUU|o2{R<55`@H=GqHm>!H zWV#E!{Zrg4=4ejcksQiAdY<~mpTe_F2Qv9@G_ViZlUiMApDC*cRQuFL|ctf{OCFz?M>+3rYrs^0^?Fng*03w_pQi?aVvcq_^gZ@?WR- zbBX-74KJ7f$2Ub(l*#{^`7mciN&c(Rk>1LySh2>Ml*1YRkj*ZfNd4;wM$%WBmhN4& zVBtEfnp-5{7a#DVd(E)^z@C{L*yVT=88SMrpV3T)snm(^-1|moE^98%6KC#DW(hAl zzHJb2&(!I9b;+9qkGKM*G-M8_Xu&fVB|a({WMatY?MVy8^`97W2b)wv9XHBKw!i03 zAiCy_up|Hev0qRjUkG~72`}M6rWsO9QWBk^L8RR@^v^aT*RYq8-SwfjG=7`{L$ifd zU*!g(-&-jX2S;~hdntG+knB!8YE1PpJVTMv`mh2ipt9zcD%BgQn5sYE13ArFy6`W0|F?)S~zkv3y-H#?wxObzJK1q^WT8kF90qu*lYpiIENW zvW*~Qw^79gT)4}tuT>N8CUBbopj|&fX#a-Lj|I_mIaFPFD(3tXabet*4O0a@1msd& zTgGoRdJV9EG0>hD1Mzmoy{yLcR!qg>)# z|6`!~{CGV%=`dbisI%Ys@n?GVe*;Anw`4#ZDHA^73*hk-%YP%MPqVj?O)|-|cft>q z45gXB$k8CC*N!Pv_L`br^>OqSxO=ba@qR+H@7oE%yNA{ie0_{CfO3F0yl6yu`_W8LT5W#j(O^Ae8^Mdiy7WYnh{^ z%c*|rivoTl1`2k*-{Qd`QR#fi)x=i5x-DOrXRzh}j@Ptvhr6s)dU;Zp38&w9&if&HHxk+so(e2r4D&NHLXQD^-rN z=a#Hoz38kJi@orfCgnTs#z9MeW51|~vHz1~dL?6jWn%1CdKvrYNOWL_{lCWk|6`5) z8M;4h>{qyG>{lQ$_S=N9v0wg_v0uTGu|E|m8v6xH8~a;#?!8<)>1ktx=eIS#gYglX zJxug%;?W_Ai7=hcStk6B)_20ap;>~N&}_t8b^6x6mAkj>+p%?t-9$Ubd*{&ppq}aW zmW(eer&X?pazJXkm%HDu)zJLD;CgsQ`ee0U@V@9WFTZHSS;YEGn>IGI!?+srK3n-0 z?%3*8oLjF1Bb7jLt_&fhgI)8tY`JXfUOs_2tQ>&4FR`~?xz;jt!%&lK`(T>5KeJi% zIC7{b-eDMKuYN1H%C0jjYDauC7Zltq zF;1dJZxm&Ab#%6FSkVI&C63dVD8u(GPERYHQJe@uPyzvJgubFObzXD~ZFwQ^}; zW+m+bQd0&o<4Ds^R3`M6^8QU&>s5AUyV7Nb036Lzuu^5sYwK=<+HndCLgO0c zRaVZeG;~D8QZHz|sue}5;G0}!Wn86Cu~kdFH~NS0RL2Ec);nFbOn^Fi`KCK(h3$~a}-ys%IXJc8j87?*)V=gK*ho$T-!W1K3C#^q;xVA2T zUR%=8v29BW-EBf?h{MkFhFVP0)wOqd=^P@=u<}A&5yQt=0*{zs|2mp{m9DZoTYO0F zNYvRZ$jEBa2-6di#)YSLRwZN~RV*FSl~5dAj)ySkwdte)+eb0U#t!qR6xRBmSx|Xa z%lb}#L*+S@s&!nYe-;EC9~a+g)b%RAqthsULWaJa+nKfe;TS{U6V(!VCU(NqlS&nK z4j0CvZdVaw(AbqL;e1jTMb5I+?6mZ#_Zy;~)+=mzlhsYUzMbHsF76DKIh_hiQL%{Z z(t=odnpo_x-zF}bUS2~w=Zt!PbgocoMhqPxa2;(w8SbZmQ!Lkpr8dqSL(@*RJOVe) zYVw2yrc8O*d9{S8tYl!g0U~P5DfsZc9QSXUD5s}Kn)+^uC7=}pW7|3` zqb!xCuq+LSM{LaUG-p+A2+x6-D+a)X+saCK@T?YjR}~Wok@b}cKd#o8Q-U=b?!4t) zpr^&uYm1zoFeufm6R^8&SI!MyDCsAnjSmMVg2{pZg3S+W27+=ZcT~Ueihoe24vE6K{(wzy)dgY6igI|oS4fFPR(4Yu^M*y!5Uft zaKMPEEFUpd8n0?;wT6!8mlqpYJmZn$hKeb%0*YdhErL?UL=>1JlUdr8gwFp|J*BR< zv#2P=E62%vqn0G|u$#m}WN>G-n+&3)cd|AIw6-#Uc{nwn<%@h>k+6RyLz+q7pWXRY zPm?!uu$zk94D6)uD95;b4bG*d(M8s*P57g(41K0$H!#blIPg}c^_){4=LyBWQrad3#5MCrCQo;t71kF&HGKzrOs9A;jsvGBp~&O zbcJsEa^kL|3n8`(X4a&2C8%}Uo*>J}RKHI6dntR!8|_IKoH znN37TF41;8ry|Cbu^M7&P?&_@QnJ#?mB@BhXNR4-Ix9r9>?u~_qy}~}kn)b%D#~%I zEYdWV;ZAT>K|8Oy7#H`Yi>oPu<1CKuPM+2F?=;7&NuN7ivEDJV@NGzVI^81)%0cjUF3vCz%8vJ>vDGC4 z)Firft&=7(ZcHhbl1PO(bA350qc7TNy&=)#`zhd#(v9(OwoUGA6vdko?&IQSr)#&A zSQ<3lnxfUJO-?4|mCXqN3y?&DKaMy4ox0dQ_>42G)B~M4(PVhcO`R2*veHWFh`9G% zolaS056&@oXOS9CcZL?ivfp^OMCzQJ83#W8tI9w9He8?Q>toWnS`ZP0!}Il))>$QpeC)Fo&*D=w9v_E=1=G% zH9T47*wJT|Om6MyKPKH!omLJ`29WTX9t(8KUFyymWdO%MC%dJ{DJ3OwW}5D?W=R95 z*tLMlo#oP^lxwu0lq-WOMxl&5X^tHt#Lca=oiHywV>pT4lLn>Ho?j+k9F_|zQ8*eR z)xI#D589-&E-K4P^^0-dX`*MjzFHdAvAhVJh84?;WSOI3;1C!nm`fbh+2lq`b|6#b z94F=bb4Gs=U0R}675mdlWY@|4#B{pJH7V_~wmff=ED4>O^bu9L*qS#M5c94u>zHrWs|aux^wLGx4xVEgKWOgA#(Tyr@h1CyL9p8ZZP%W+BWtA|BSn z;#jIkxHT+WZOEdN+M1~%XjwEavATTx*VsW6^PL2cTPwrrHhtW(;>48()|WcFp{$ZP zlN1owv+<@&Jj*dxIf+)Bt6Zt{w-$XRWHmvyTG?&U>5R&)lx@ir&v15Pf>ji0;;}h2 z5jDy1?bpY{5(zZCNXKN<$4i0{W!Nx?HEDE~0pq5oXDGK69MzxGMrZug_6Yo>(Z+pv zMa+k&f5$rUKQ0*xMar=8lBA5cUF<4lLNYF-D|WVPJsNMw3>?{|_0pu62W;u-9T)&^ zNh{<|(u5-qdoBHXWr~wsQL`IK;4S}#b}#?#%rHKR!7BDb=oV%eE)K4swOomm>a zr$S7(Dvc|eMFX@djT7wo9uSr@O`G5)J=&Q;N@=EG>TGhC^7iCvP&#j)V;0f5Io)P>QYsmttAb7AS*d5Ry>KK zh8Ni-W~V{4l5z%XELTjOw%Sw#($sR%)SRMtOTzftP_?QlQZ1ztwYl+;=p;oc zB1;r|(pJHm*9kQ3{zw7VAgXep)MA#!Z;@-Xr(idEJ72K-4>8*_3l)>pC33z#7F%&$ z4T*q9h3v<2MQyTkq+_rqC$O3mTsq$yxQLcy7HDC<@XjW@}MrnV#_0G+DfIwk5^twW*QOPx(xDS90RT4up8bU^hsJ=V;F9mvVYG z#+@9a>iG!?Eg&u?NaRS_3e?gyr6gixDGtvl>Gib4C<=(QhVCSf+kYs|-6fM)GK$ma z)RYf-wzb{vKtdd+r}m#168gj~WeYOh^-c~JY{?jP;zUJ};i_j0Br>BsHxVs~$P}Zb zEkdn{q(W?z#Iz>mA&IyxEisE~kmB3j02@|Tm)&i2@_j@sCmg=Y(czMsa(RVzs-5;k zdk5LUHXp3AR+1UCxHL>hCEXB9+0jL&bqL3zF4kPBK9QtOhNP>;zG6jAXO)PoD%`|5 zUfhE;DXS^wzZ1)WG=LSUdQS(A+%ce7!F580`sf1$bFB4u716b0%q`(Xuai=WPiOUP zNzKUv1;7+%UYqI!=QIh<>4VwEX1Gn$llx0+9S$nfRA-h;YmN!A8)3Pab0)Ln0>bVy0yx#jJQH$B+2IKGf)`yw6bWbXWOU?Qv+q$PM-^ehykik^$$zv9MF z+a-JkjB9FlTm{=5a;GHp-(c-ep)@(Nb`&NPr;{w&7qH8YG9;Y1i8pQR7O#^@ zolf4w%Gp4U3>{L5nr5r&FYjIji$wOsCYh9JE@g`xxX)~}!KM9FM7O$*=pxSyOvXiikM za(jABQBagCjT{V3rm?1heb5|ZZ=0_6wK)0ES|%D0n2KvFKifAzuXn?l-IDSdf@3I7 z)DJ6;hZ4i9+~U3nCr(OPnFxyMlkua}ov8uxDhIaW1wpaPlC!<>1aQ1lTB*(%lXMOj zQ5HLuT`=_?3iUUt<5k{3$|iO>N6#83eQT3_hPuJ%TW)z{f@mmhMHVEM#cz#WPL-q$ z>A}LJo)&E7%SlfDf^hj*E-l_lonehqu_P^9;FeYQI%OOGUaRWoZRi(h5tBh}k9)uC|@Z z=Pa0Z6qf7>{S|8~I*K}AKGB;hoUn5y%zi~w4Q5Y^t=dbp#!jqp6B|o~baLvi zJi^TwQL#g^2@(6Bye|#ZQhy}7Kz8zB**<_!RnCkVp9zALu4ifo>x806wz}AWEz9)5 zs&ZUtTfvQ!xU~%2f&Eglvw&^7Uz*c1b2_3W=`d$KwjJ7^H`>ZgfJ4-^Y+@%JJGjg& z5<8Pjj>&ejn+r3^YNE7p5p#MhOq26&<#cdeSvmMDm@Fwf2k>RW{KfXUsU_ZmE;74D zLdk|QkiHGH3i(uMieIc~q+7hE{!^Yao!F|JU1#t!1GZstD)DYxB9VurW1KW9ofwG4 zsKdYCI}?eJvIjF05mo96v{qTw0)i+w)s~ADTkwn|pjeG>wHqp(*qu^W;^2SY;mZ zJBazhRb63ZKI^X!+usqI=(ccZMAG8_?S(M1fGJ@$|IxOv`u#+26SOO=;af7k`Fn~p z(FU6^jE;up(J&-!gCf@xbBzr)Cc*2@jf;Fw7&cn;q5>(*PSTo3{g(=1vua!xW~1*F z!V3J^=!UQ+@^b=#Fp-a>vI6^rzbsC8yAt-Qk>9xLC0BTT*t9LIIzMcBBeETu}TsZgu<<4Y%xFT^TQ64 zUl!)gzV)I(WmlL3zl!L_a5Vqb>uK>@!+N}7bbHvre@^M%Q?SXx=-RN;!LrfvFwEVv z!KN85j9wz$d7nKutTwZH!+^e5&-R7ko5P`7!XTPlaJ}a%ZS;hmz0l_dTcGXNiDBnO zVTB?N6EP=jQcbBKUXz;;mqLoyQ|e*x_4&8_(W@3cc*s95tOXcGPuc%m?r2!8VzvU> zP;7^q?SBwztf8B$*AaKVjSE02G!+jejVGL&6Smm`VNPYsM%QGF`F4@Ey?iA~obvZj zw<0W$o#99v#sw_mL=-vw;#$iz&g!gBPlf$xXD8t)dqnE zLT`j_^r;`!hgI7`|L$JHv~TNKsVV)Tu30mR|Ca`Y38j}eK+w8oD~yUeZ$Q^ zsE6)72;QNGz8DvLK#z~>VMqC%W=44VCe4#K8J}y9hr$v zvI5f}eH6|zoFtJk)~!DhnQcf0Q|Bjh#D+u|WbY}2d~H9hKwu)64BqikEGoKV2J+jE zFtE(thlEyj?g+)@pK$av&Hi6W(T8d0=haqCufgx}`FeHxujmVXN@oMk<$RYi-J-|7 zm@m4I$5H0TB>Oq8gI98G?#x_#{$f2=2)0fS$0e;0AGxFf%1vK1FES%@jsm%tqF|E1 zgKX>VH^8oV#{5K7W}W8wA^=#|}Mo`#_+}34*ulakCz}{yn%;j|cR4 zM2|1)@hu)siFr&-`e)i_%v^X3HkmK~yk*f`1wMC3p}+SmIUkC0Kemj8XBuhE(7T{| z0lL=%`FOyTe9l zG;?AtX`B6TQw{6GF8%d}HYBOJ!V2I>(A=$r-)6%ncDsag!V}L)d~ikW`hB2Spi*<` zT?OXaLOAlPVbe?i^THFrptd_4u`TSF4FDwr4T5RXPR`v7v6T!;=C`f6Zj_Ak?Bv(b zXZGiwi(aBs^ueo#K+4+#8d7MqD&Q3)x+#p%luY9?kTbiUxZMs}DRL+Z-C=F6GaUN< zuuAwJA;kvr@hu06Xe?M=5ZV+9tuc8_^ZKw39HOl#Q=63DOj>W_MyY^X!=d^USxnPm zr%KvD)e&ZT(RZ$^$>Lt0dx)&>4u=sFMf(sF>!ICpw22ntn+w!rwkXQ9^xAN3RDTkL zIoD}lmC;(X9rVB_pp((WXg+1}4*j~&3^6T-X*Y%44O`4Ge+L`cMs|nUNBGFMF!-EE zE+*MFM0FkT!}?poHWG9%Q?1qOTU4wtq2>*wyC|voX;(9;u8EcfqZ-1j`5P)2tJc$Q zQ;9}rE*E9YlPDhjnurdqNhNOLcH6{;S8QUgPi?GL8}CoGF`N;Z|3tkt<}HW1A=CN@ z%`YE1N)H&%Z&B z+Xb^?bI&ja2INClfPP(=y@;!kLUc|OU8M0ach0GJ=h&f9Juj@ZA>;~e_R$jkZ5`2j z$V}dPsEu5j6V?wW$A)PAU!@ZK0!)4@Y^+QlR|Rs@^td8c3C=oh`>rF|^|;UDZ0`KC z%J@q?{wx?96JAiK#}GYE)MKU|^YyT;e^#yk3_UDSX-5hZGK+)>rkd7>-nrjfy&umU zJMmXVH(M>K{iGH*!nhg00 z1nxQXjIvI;wdHtOuT5oT#a0=ahb=Feafi#ZWTJC(bIgXWRQh?;X;ya85M!pw|Ii^9 z_!8`yF}(_)%2m*B??M&T?6>i_)c+f3coXg^(NcX^`8+;fH@*6Q7A&ga(V$1W9;5V_ zqDQwLi}g5L566Wg=!YL_ri+!d(A*SOKNXrS$VI!FW5DDZt#oSESlP$qRUcN)fk}~b zESQjv(za`mQK)E458W=G6<)?vE`!9sSr(ZG4p}X|F&rYK+nzNzthZha3p~GsCEdI)~UJ^A+ZGXg=P)~{xlq}0=2k) zvn2$J6p(L~mQY~sG8fv#;7|x+meIn-gj|+AM@ZP7p9WjO)Zr(kR%#;BYI%&LzC_^V zjF2vHnsbeV)r6I6>^iiPiiHdRk=06?p=^sqqw6K9$6NiN>8}zbpMaVEP7GxQ$qV@W z-|F$|D&Wdm&pQ+6a=sOxe@u@Lv)<4kSbp3=@LQ7o33n3LM%&!kmH7O#_1Gxbc0Km# zaY&Er^so&7t4?m&sCuwVoVdWU9{qNZ{*pQ?%aepK9Cn6=? zB-|OHe^N*EHCvi9mK}sCcY)gh#@)ius&lB8(d(pSE)^HR+%Yy_OjLq#nXQZ*SX^j; z@|sD0qr+}r8oS!)o-{Jql^VxR$;+Xrt%5o znnFz2x4aTznYydYG>V?u!Hq%^2SWUVWr%;qPS%hw`0=I!Y+BK*0I| zq5OT(_guf((yuR-PK%x+XVo|;bE7ICc%+-au|rlw9fa!!?KLI+4_D{g4n0(=^Lf=N z%iCx=aBqseF1DBi2uX2OfKF3uuE5GjAZ9UeK^T|%ag9ZpdVcFvYmtU-d<&B@Djdey ze%+X&#-kOSCljaaoz~v1PtEuuN3`>XcR2H@CQwU$`pO z<|Duo)_Ch7@x~v$yTX>W@W-1VV~cs&lSsz|X8w>=o8ph2SK<$~`2QihKfOZ&;MB&j zTOB&5BkZ1pA+0M~=sMQ%&DWFSrFNMQf>)KvcR;Rhs%Zd@pkJHsUp26A`?vFpuAPrw zl^Oyc_32wpI5DO9UrDL|np7Hhd_TD|^_7}u+5bk-&$M^R>|%C4(PZZ^t+ukJq8qKz z)@TlNGt)bMo}k%76VZOlR9XPZj_NPy5L4089o>KBBLBO{-5*Kz+PR^Xdu{x2dR&EM zmxP~xTnYaclD!M}1)R+t{y-UD(!(?WtJ0&92Z*uGoID1(J_Xu2g9qlJd6@qt#!N5< z#-2^%Pdb-wi;W&X8|^UIc{~g#vL=%*nDP_hK3m)7emPiX$lC;aA}0-Usej# zVfbfMTz>^R5BGSWbk*D&hTY*panpKdoR&bs+#5Adz~;6-oJv)d7g4wd%r#yV7fH_-eM=QsN;z4Ed2Gp*W1Fnza%t)>cT zNS4;_aGG*Gr^mAJ1Y3e0cZIDM9yUbRO8Mlx12{c!)sYmcs8tP-`3m19)LL>mIG7JJ zMBB`F$-s*7HikdlD#=^cbQQcFY4u%(Bb5HwRZ`v{#Cg#&K4I*eyUAC%Q7uo^uq9S)JbW$H5Ie`Fpxw9Kl2 zQs%23q%k0m+T2S?YtrT1YRjQZ@4pHy02P~&rd3*4W2&Ev$*xAR2vBF*ekiAXEsJ^4 ze&4NFhp#iO+*s z|6{#IEZK#f&&ssVj-(GzyA$Rc`8OzY)=W1eu}4+0ZKVyNEP72@FH}UU9ie*fik^`V zBG7@@lU&Er2akKKaZ?>)cTG(7L+TAQS2R#5u1sai?K;JOBy69}u?J!i-pe~WoPPv% z5tcD1w#@e-9o6i{7y^bqV;$~#yUA3`h zypjFFS4C8|kigUm*-O{QO-H57zkwod199XsO@*Fp$(oCnh2unh(4o_fkqY(6PjPk2S5x zH=nWjwl{#+U}Lk%yp*v!+V3VsY54z4JG16yV3jw6gk-EMQNp}(82|?FJ;b2ZVj2Fc zdCj-Hmcu{^T{RdiIn04u)jB(Gwpmkbc%EKn*l?r)Y!1JH<`34*-)7Z>Ax`gn(MLbk+1^! z3c{u-?p?FP4&w@1+!`lB;uRBdFhhq9roja?b{}QsldwL}p*W9!VB0O-oN%X>+E} zkC4-$sbg51NVZ!)oOn%GVAheGyG}${hM6IhHi&|yN=ag(7j06jt@XJU9(FURhR15# zkzw?h>riCJT@<8jra~68<}jRz;l=eQF{Y-QX$#HcQ&%!tOS*>>22UDPo5+0hN+t#v z;rj$5^ZqN3D`&nB>ZJH6DH27F7Jid#S@Us#MP-hPRrZiGtcF4L_gookKGHup&7;}< zX;N4Pq)WITeNC@-pj40v+1`jOm0w*(>pQA)MIC82h2=)36KO?LQTqf-E*+7l3!$t zZ@W7T`#0G)Jr_m_yiDmafJbF1nuFC#yKS$Jb>lIw7*Hk@{=d*OnZkh7qo3Pl4tX+%}jB&MbYogG+QA6YN6ZVU-qHZB-qDnfA!QJ~@o) z|75%Xc`|G-E(6&(^CB+H?vq~?{$4*Nl?b!cDe7=Pip|^bNy}p32|wnJ1l7#DvddG^p--{ zVMmvaEvydxb>X}*4Bso*j-R(cp04=6TmObNDyQN!f6m(zR`=-82D>kko7VuX*Yw?p zwOKSm0L=W<<0Fg#c3JcmlWDQf$Ha!T=rM&f8cqH%O9fe#sA3t*OjHgmwIGDA z;w9!@5`&113iIhJUF`}LYiCv6j+R1ZWX|7R z{#;4D)I7_1f+x_U6^&XBugidgC6~1h?Tbf$nbn4p(`$V@&pwj(T~q3dO}QFn$P6h_ zaj&RYeV3@1LuN~87*y!2`7?7%IVs;nT!nf0plggm8rwNUTWd5#i_rt|2_NhX;(zhV zS`hbHjmdxj{wyD|xi@H#(h+g<+q%_0=sik1uyJqn>MRBQRJY8EHM<@-oD|vG1yOasw-S)ZM{rl`cjZd9?laOKzUu#0^Lg34lI zohrs_mCqU+7HXhzfP?UUGS_fU(59 zhp*Yk(^+2rD)lF8=z?$zI!AZ71prMs!w)aOhtZdZ#!_@~tORbb0~OVfeP5jitLX)B z2AW`OZczIn>2?ex^w?UC=8?=(U+bU@^9D_c?^Hn4$CO5pW9=bpKn*$+ zUg&4epobK3jqB-nC80^`lU4w;PO0wh?z2W2Z0@R)_OK+9qz<28GCGX()Dk?8h4Uo4 zqHF^qHHjnf<2lYH6lj~KtPCgbkKP)NU++Ro!|`)MofktHW8nw4*S(&=oN&CXf*K9u znG5P>a7xMigWe3#E~>Oi8BL@zWuvK$*8hclcpa$EUSU!^sc`SyXnf|Ulyjf!PQ zw37O07Q#q&K6L=e=T_)Bgs;d9H2?py_vZ07m+SxkTI>B@Yp*@9w>=p%CmFUOr$H3b zgpkgsbWWe+h^-Woz0oaw8_i`KN-9c9h7=)3gJ_^p6N0NkDqV#T zSg8?%9cD(zt2io}s-YKem=l4dlOuOWwiKC~KRyXvvM`+EU5hD%64hyqi$9W#OlaJ= zK(w;4fzU}Y@_`vpgz|SGd|(M0NsOI^Wz0GEAypT;E$;O%a@+cLFyDdp4&Tdew@18_ zwX1z?04z?n@LqFE0wbtdal4_Bs+60laYG_p*1IDV73rpT4F?_hN zr#E-dyJX6&0^?8pJCZ$CG8guYh5cf3B=eBH*z3<3AZqlmWt)I$T*AyoZ)5U}cu!Cw zcHG%n2g$W@hC3;=cHEuL61qzdtSLG;t-)^)+O0)6neie996OU-M^qnggnLC+R^?oY zaFS_Gx+ZD@U=i1WnC>CXRP(NqCzHVRNbc#8YebTmlohm@ zL&X(0S;aRKDpvH6psL|vCzb_B#c)N<{D{g^T;<)&v8-2kRy8l*dIdasuivO*Yi~tB z6{X)XRsUzT37^`8gtl4HW~8Lk`u&{k6}*43=GDAOqA%SARr3nmf@(depKJ|6 zFdPNY0*X?uuj}d6L*V}?_gR0^^|ZS4W>*N}ACnX*Ld6k_3rDW8qSHZB!cDfBX}mQR zA@S6WS=b1Z`#~ef1@)fmEFKB(OZPUR05rRSQQcva%w#Kx z?PPKQy6_$gA=H#LrH4douL$)+P$O7-TNY(2U2fi zvP}O$Y#kz|EbsH1fQa`qj>*b8Y}j$?MOxZSKeL%uNJL5z^Au8|d52-;XT8qHp2-?& z_xlbjyBt<3vo)i&dq!}`)Kz~4EC|W)4^XD@)lq#Ow7DJG4nm& zGO4p2;WNj3+Zz8G=0eSP4y(EIChZul5t%a%%Y3-BSyulgGNt-eio74m4%ciAFf?3^ zT|rXTP-FOZ&aw9(+z?LkIe1A*9E-g}6p zbtr{K^8h!Eo~(CfYa&f=XqlMY1|*56nKf%k7}3;SISUzudf2r8g;7YXC_C~AM|fq{ zc=b)Y%dN2^An!}~R#k5dtSrYnBvyt@@Bt)%gvnI`gSvxlFUqRjHe5poX0 z+a1%{9o4)~*c~}n_3UXT+Xw#6tsxo`Bbg3Ql8k$jnEB9Qk5d%sd-rrtyG ziZPw!csKiV06b#qlM8tL>zJsfzpOC;w8H2rZsVqRf(pM z-hrzd1dqi8_c63Ovqk~JUm{+I#5`r;k#AXy$eRy_urZ@i$S9b_-Vy)PdvIjZ>;v+?iB)uW~!C+p$7hr8mp)d2rW`2*slpqq)PB=|PrHtoay zrIBr%Zk3v0;%ieW92#!gXozhgg)#)E4npvJ77bs?i_`{j1O_DAfxJtV9dA-%a~rRq zAMJ(lhIg+m9Hmj-529pP2eaB{xp8muaO6|GnhcRrhJB&z%y4!yHtGu$sucFyviZ(5 zf<+X|)|--DY=j;L0NwGqk!JDESyc814&>Uq87@Gja7JBTQ&E+(%!0&~W(F@s=jo=8 zKjTf2_Tf-<`SatG_gS}gfofFD%C4qHuq^tBL*X!(I*rn|#?a!NCSW?;Ss(<4FSC|- zBO=Tw)u_Xsd0`X3TtpORCvqQ^l^4d1Efkq)Ws=8&jp3+uIFN)>3YaiLzMsvUcu$~- zUSvxGsdOaEK<4G)l%~Ym+LUR_hu5WeHUB$f| zE{OD!x9*8Jd2=FN*i@Vv@;Avj(^=pXBG=|P7iiI*FL{TUc2Qj*I5We@PB9P{t ztoL9c?6#pOo1_Cij=$`6Ka72F6aSv&pKX++x3n`p(BpGGb}D19po4<=Ba1XC@62fz zvu}2=i9%j|E~D*N%^V)Pa5I;T^8~(wUKhy7Yc;17l8D!ZOJuKAoWj8}f*0%h5iQ}{ zB2@a^%jD_(wOh$$Pwa+y>#GC$@Bl?TJclSH#~+_CCDJ+^;%*fz^#ljaOz}x>8_wl! z-rQh-`QjO5$2E&v344#TC0EIEHL^&gozVk*ch<<-sAU0Bv#-%tv5hPmxjCXvZbJx! z)z0&fh`sfQ_OwnCE`PAwP&S7LcY|-;DF4o6?`f|Me}FXRKtUpfA*+bDGk`mfC?rLF zWBE`Z+K+D|XUR(58m@3=IJr1n37w~HI5}T;0cmH2+v<(Q@HJ+-2+B8HY1ZPso=+U0^S2PsF~Xlhdshf1oGJo&#xKOp1C_|^gK1O8CYB(7h96y`Yh&+qd`%9o{9IXOpLhPa zC=&~MBthNTr_zlT%*%(@|nh9Vte zTxG08Fjfx%Q)1-1|+ zAb!NGtD~J&MUurNThGeqIc>vOn<$C|BQZio6MLIdw^F#c!*#U`{V3qc+ebzw(zq<+ zju+?5EX`MY_50Xh=GtTf z-f^p&>4~gr$jg{&F7X($Q_gZwYr88!&T2~muDD2-Ea^9)YFptIdx#`P_AKvFHzSoY zO4co}C2vtl8(0ALN`1d%#{_^D#I|risamv5VHI&P%VMJ~SYo_#|(rW`WJiXq>ahYWR_*ws1*N3{h8w zqoXJzOEKo~q$j~&f05edHv`!#;%#|NaSM6CAN6|t!IWWquuQaO!qs{=*RZQ)p&mR-1PG!-$lCd}uRv?&~y zo@8MJ=9&tZN-khrkV3Xq3o{t58^xetTo~p9QzQMHlH}n(!5ou(rT82Uy%dJ=QnYQDQ+8T!ObQ@KgHxH^}R_}>! zsGupy8y;J=&|}=Q$aqGm$V@Isw9ey}7gb!rFCD`-^C=P}!(2W>_cv^==!xN^b7{Vv z+q`M{2#rXGy`f$Sl4ehiczI5i?9p#DX;iNzYU8CrhcW9-h?~UT2#E+Gnu1_wjy`bS zRo9{-nb?PS&@A$aIng!@3l?sD?(u9qE)G$0nieo$k&k!Zh@M+)uT0~`K&=g%9xQsp zgeILTx=F31cSTIgL0F~9^o}HPnV5d6HhU<~TYu9Mil1s{53)q$F4ca~=$O|A@D%1f zYuTIRFzV~m9F^z?%Fa+f+^!s}?HkmNYjzC~N4mZnz$V4!r4N2o)cM+-gs3^-`~wta z6dD~)9?=twp{Z5i?WR;Txeg4K&cfbxxV+;rjgr@#7)QKu^C&hRlOr4IyBl%*J9@E2 zutVdBMWFKB;1cs%qy1c|;r+OHy#e&LMb0o27;n^!38xCF^7&L3?O;&)* zqMVAgV8Z^J2~hSr5o=HpJ6r^=m5L!ct+f!6HY%LTS5`)m&cOw7i77VI@hYPDJCc#< zQ)elnw~(BJ6q<~^jFnF^H~JAS-qc?OPH-U^-s+SA&D`gO2^pKUvdzE)p|I>t8`)DK zhZ;97Q#T?5NQI*n1YY0f!{MQOWACCi7~;Ih`?Mgk)wk+!Az~^yai1>DV=>Tgcuzb zPR2+TX`>-|&(ndXkpl7H5uyV}AJGA8!r4nUHAME*N#V31G-L$8zIqNN?^H+1v}iK)dD_5jKu0QGfkH>rtfb-a z72(wL#C=JEwI%aS2(KMuZAFOKAK&?cViKK%VqBI1(O$Lz)Q=sT)R<>de9^`KItFyWqO9Mo~c2{O3uk^!;4j5ZWiaF5i5H|;tE7) z-mgNQHpIp-e|{k3EktL5;Gs}nh)ex4b_^|N86~Vr)0wh_Rp_^*O}~CIW#mON>EL2U ztlh|0JUH9!DxhB|8!OTWvCqdG+thj)R-wP}&zV}M00ke?;}Jcc*27Fjf4v%$QC)<7 zy}O{dNkftLgj}Adb_ny;-c;;4veRHT6apmBdbk6KG9(5oF$jn9uAL(>=xhe%TPPz1C0X0TbY%%e9Ammu^KLMg6W*Vaixw(ps#%J#4`y8G#1}D$w8*6}CqFX9 zn25;WlDq~m9TGb03#37> z#H>(^RT@@jBzt5Avo3j?%=}~@$qD%^?gGB+Ars+{W7*OD{j6UoHxS^DXasuk&lb7j zO+dl1dQ4E-{em9h;XS3t5TwXGvV%zg+XdI$MF$TNuA9>|IU(iCkgR4gSdlsX?QebBec_<;6U}oaZ0vxJMG5ZB z6YSwxkuA0hJesgXtDt@1;VkVi?`OY2l}b7EGaU^a<2!HIAT@I>;`Ku;WeM@CMW!0Y zhQo+=1=0m+eK8*i^(o%&a>IavCo&8zPKC%Tj!jjTXrpTe|8HB&3rFK|49w+B3j3`O z%h2FxBbdWIuA?CP@*2)&A@{8lgvE29Lv>`%Nqj*~b!5SDulk3*UR*$WbxbF!&PAu! zfy!pT5|OoytXUHA>baq|YX$q^Uas9=6e_z_cG$1RjhilsG9{7uh``z?UZg9X%1*EV&TP_73M!Rxy66-d&&IqTR zEv=#=d(JDwyjxMs5>OkO58UdG39KMEMEe0~GeJR^iKKJu$dXvgS0o}Gktc5G6#3Io z=Kj;7SwlDfMD%?G$hHFB5N4X*Hbh3*HP{f&S zkUV(*6Ya(DsfFbAk{AtyhsqwS6HM55lk_5gH8UOe8tJlW%9e|fIH=F5c|)ZRnN}6o zL%T%!NmP*jmeL~ZH-bqr8fWLz;jXIavsh{2RLS-lE#`Oku|Iv`E99*ip&2idX&AUC zlxI}OkYX>n9G&c>d?%2dBMM$jol2vrc1H_o$TW_&PR|emcRoTPtXcxmoLdUr>p?Kwx`&WMp*@M|E>reE@pO9&g$vex4nn9!npzW`LqYUh zYb|z+#cHZ8y9ZKMlCNE`i1}?4~2+f!!Kb*l?Py0x^7lpYYK?1>| zBQp|AQb#HC;_l4eM59?_>I@G`Q69u{l8 z-G|StB{;&B$0GN*GWdzqHDT7Y5T`a_TwAF^5jbuyqzy1!K+NH$#G1Q(qR$-^$gb|y zf+NMPWjlmuaVT^Wn&r|j=v}50F`<-%0Q%g*Jlsp82;8zsWH+&T;u1kGHxKpM8`LQ> znlI0i0@v|*le2<128SWXC#Cm^!FX#zY&~z$O?j*w%x@1-2@`fEOPSGrw$bx>@4lwHqv=+M#wx~q1W^IbrSQpwq zVQiAtxy>isHs5mG4!c^bPM0Ry(J|+yo@jgG_b=%56t*F2F~T#o5ZMTI(xZelXGziJ zRcAb>WMhD2xXa*rSN0d#@*OU(nk(950^Z+wS=eF05q%vgE{CNA9~B*=Zc6PB_ zEfHT+ZyMYs^@M!3h6ESUZjoAR!&u&4Eio8t%_IZ z3ojfvt-~l0N4A(6IRn3YM7+9OP`;{VBEq#hW1JS-WPG;_$0alBGJ7%e)ZG}FuNWR7$?Ih@&EoLQ}f^rb$nTkvdNSTiG)ccqx$AA~Le+N<8s@(r7u|G!hs zH;nPpRt^0A^1@Y&6R6!Pq}63?`mlvwdr0BkE52oLZHlu|L|| zjX#!qDgP$$&y-g0aiHL{dc3GKGfJ!<)-HgW=Ty&>y5V3G5z*CQ?p(t!gik!vw$F)n zMb6&*bDKQteeAFHUV*=Nqd($vA^%+OR{;gD*W({bv)%*4+Xe6+v(qCTUvXn&{lj$$ zy)|Yl)c5XPvLvVw+P>bA6&9qv|2bxx>2pl=eI0#m8 z)uTv{2lSYs$J2T&)#DW&-g-ec=H`sqs}FkYHGbYfZ6CIqrzPQL8h$5tCn2T7gHzOE z{M-}<(QxD?$HIEoNW0;RqA^+KzP{0BN=rqLPFS*081aEjJ^D?|Iw#e1UtcXUA*b@*zUWXYGhpQB^*@E8lLxM(v zTJm4s05{OZNd7J0pCiF{fP$O#_)ckVIn_NAcYVskh*e7CO=7k=6sEr>qHg}@SSaw? z*Wizc^6w`8SvScK0tFw{<7uTW*5hS8*6OiAk5706|1HP{(OrYcf|uCDVa9Tkb0=pK zDuM~*oF@wTVksvFh$)=I`GQV_Zi9A*OBZ93vIq+&{2x{hH5;G*YAea=0MzHln&*~J3)>nNskRJb@E4Qj=Q+SN% zU8hwu4M?1X4-Ts){p#C~5(zSFV&1ShjL54fV=;1?{1#(A89u&`5?FA28;XMaCnwXY z5iT<-=o`z`dt#HI@(s=rr%bIGW)->ZMs7%|WcOMo5WC8H8YAO1K2C0(Q}AxCP~j$p zwI%UfgWa4|UQ5S4B5Eoc=B|ExUW_xDTb&@^3(MdTb10{PQ138cdb z`UA6pzzF^sfuuYH6r82UJf$tsV}%}X>akIePxbg(j~{rjYWN&>|2tH|hn>BwDh!1= zaA!4;Iox7+#W%|gOi>I+t;1RQWVChKlE-VB!aU#BH{#$vlefWi=9mt`k>g{=63&x( z)6%YuyaBi^*qa)dh=X2ZE(^T>m3&YSA6l@+jfHE`*%yL(hYP7Rc)7GZuQMORw*K_J zN{{zvf=GOMy6N%$IND55(n-*$FuBRQM}G}-8P>Yp_nP*(_=`{-;lswhxH^a zuTVR02AG8$n^GT7By;OvfJu6D)-0b))Q}xe9diIZWAZB2su@(D=&8|wr$=b@!} z{a8A$nykc~SuY8-{plqbZqZnRQgi4aBODIZM=9&tvo@N2njCZZPSGbRe@V?i;7|O!i+}&*pAmf8R-oWsJ(6mX zmchfTrbk^p^7J@UkB)j=q{pRt^wPsff8L08)G#g$u?XL6+h!4O5T~P5&x~;0tDVk> zogs{CVgm$XyToxjUNuHrjcm{umaRJ1>qC2TF@TDy$-Mw9jn@3SW7Ft8gz{G0)89GDzfr1&e<9uP-NRU%%caI~-jxQSgfB1LF z&?^i6R#2EfFmTS*`F*avWaz-aCH?=N->u(;1AAYUAL!7#0Qj8i`sN2N$?sFx|GGi# z2lT&c(7^mbg@KFu54vthY5hy{hZK+<=-9ikcgOxiD165s;s;k5v z$`@4CSf4i1^3C_^l|QI=VgF(Iy$T9@4=EfvSYctjlm8ji|FCY__w73*zo4L7zjFr_ z4*6U6>-q};8ubwfC>?lTFyhK|8=OCc&IVm|iJ>|^#q;_P$S*@@*P#Qi%pcONUyotE z+YjoiRtf^=-q0t1u&~}Ezt43;`UW}<7;w(jy@&MfQztwUlFY z+2y+44C}lBy{{?=T-JZkDJ^4_F6}*FXucb4xwejlroO{_mFgQPfLJaZ)bF|@CNMZgMS(x|ADGYjnaWuU>^0wrF{Jua<#c z7hP%Dg#kv}r@xWxFq=H}uNx$~R>fXdLaM!bb?ASU7Wx-}0lf{ZBppRO{Ksa7xQ&{Rj0KFcg;a zi@D7P7xcXryfcj7*m1mh=Hlj23JmW*Xi#C^)gqfA{RcJeKXC8>Vr;#9iU2V{iuBFz z2Ye+wtgxUjQ2vl1S8EG^!QO-WlTc7d-eyqch}4;z9IBByzP!9%X=10zz>-v*Is9k$Uz6gKTY=&aw23@zwYefkXS-G9*k zg%vI9sSJuE{TRq9MSdw(f9rZ{iZFZjJ&X8%iE4ktmWq>O!VrHMB?)wsQ0I8CGMpXW zr}vP){}rqxG?oA!&#?Lq7|?sjRm1)(qbh5w3_6}M^%|5vd?0ere`Qb!jU|9_cKRFH znaI0vKtaLK!3ksg?QQ%rw+)o`PzwASTP!2u!+G2B;Qxj}@COzkVUDrW=QyUC&{zWa zJMiB}=YRP_@i>N+&`JXMJBBroqbUJl$1x;Zs$(9|vG(JEJF?)@{jcMEI-XTNr^g?A z^&8xxdBUKNf0Yv&O8|clIwd3Zx;lRV@_a$U5RU_|Ch8e`w$9nhwby za9y8-(H%bme|hl_>+Z1VFvR(OChe3S;76C9%GkuwWTiMU4a^^iJ{cFUVzN-FL~w)z z_B#clq3AIZz{+o*|5J!8-&^@uObk38a=4-JhQj~aiIvb;0(d;b;<)N%;&Of>Yx+Nh z+w#4YkHsnP=yZ7mO&wi6mA2ShTEg$(D;%kXSC^B*j)SB8@@6gTq73>^4{(PcDKh1? zj$?1Xn3BSz(CQ1^uKj)Z8OYKl@U-K=5A%*SQU)Cl{U-9crUS6TBn<2LsrYEE9xd&- zMvPge&wpo_zf5GPx2~g7;6Ej?QuX#YsbpaPf|v=T*O7etHxi55lBUm5U)I*)@!z@* zc8)n)O0Qx426KQF_WRw0V~zfb-0lXR(B%;cr6;vD#};=b2KT#8XW(%Na*1<|xAJ?{ z?*C+}$G0YVdCjisTXnnQw-K6AD5N|6US}ImBMjoHRac)pIR@X=wE&RrvY1My)-s4@A z3*8jT7R+TPEXVI&g{%IHs}OIiG!%IrwhG6CIoQvCjZ%K`AUMo4du;X5jVCrNc1(iN z)t2b+b%Qe@v>k4LEicE#PO&_TcdnEp=)_3$1KkH^XG_t3|1 zX2sps@$a}_r-ndTA7xP4qQxbv#3LcTex(aK@XB7p`}8uy@t{)a3w4y6JEFgJL2YLY zypov@|NSfZA0VKtt1{?6Lx2!5!0rf5-YlK*u@G}ym>AHfmlJ&dJEUEPkmI;Jvk4qi z&NyPEM=!~HhtEJL?sX~C5F~D3dVm6TD;F-;O^}=tUSD&Fn zP)Be0FXH;B?H)Dp{~>>c|9>d|T?`b)+zHhxZnXCE*N1)xg_@W6KZJ_BV0VA2_n3lP z`Ewb;?(s5vLQDPNWh8zbAsCnRY*O&D0F9O9KkliG$GuOz;3brMAfo64k?j$pR{j#+ zzNfFh;0G_Y zbiDaA68{ZS92)vpS5TLwKIU6{h zPp1@!T7fhx5VZnnr3D1|?FOsn-~zQ$@JLXRM}m(96@4uDj>X>zZdClpdQq#F=ITXb z_0nSXqQ0xA{``UmX-O>@)Kcr=(wMT~+k%sYjmg0Wc`xe561Rp0H}PutE)l^^yrq3f zAcK-+xb*7`C2eKw>&#^F^S|+SL&Anfe_ME4IM{>W+im8R@7?_w#4@%S3&9{dXDD1^#E@FTzB>2tN{0bVXzXJI~)8 z*^#8^j-)+FMDI$ToJ=&{#4>-ghz~MYhWB$f6{I zMMT+<{+^_wC~Hs@eJtt~+)&~_7M)LAh`xs;SPalZKUrn5^4l$Glz2R2l}f0YsPv6NBsPA{>V)32|lUG zeiHma!IaP(2JAl_TBu-2Xsv?vq0I`u4DBO;I*hZhM}i>-(W~R=fj?usHamg+CHn#-ZS%kSZ(+EeTn|lF*x$@MdVeOIRP;VhLM9U%G@ZL(GMP z&W;-ER48!WJ{9ZbH49%0eidZv`G3+%5#t62}q1piN>m=#i~SORnlTr+=0UGy4k-|9PLhjig@GQVv)8_?)2{$ zWISg<=tUdsiy=OVz`kII>E|n*N|xNiUlaONu^&RWL5j*A6)fi9v62of8>c@v_*{^} z&jr8H&iFR?y<*=7_v)x&RJ8`$C}`x2_GGlb9A5cx>k0-rMoBqqJ0V@xaHzZ2RJqRz(94g%E?WOaZic=J_|2~6-Nd4?~S7Mp-lHHb<2 z^12Q7)!^q42>sFH`@uUyAnxAKqXcf$%2tKXm1U@28hlk1=NHunRQ~)jOiZwATAtYa;tv zA8bw8gRQ5Z=@qXUp#e_=b_0olw&!EqNn=j@X^Ow&=UR2o7Hl zyyHyfbH|wvpUFp+O8ke}+$W_BAk;p{dC6g$x7wzlB!wzglkCH7_8 zuiKJ$uhN3#;?H$h?Ba_%>^_J1?sI-P$MSzT=iZLQ@9jA8T#HXU z_x*E;Z#uX5Jc8o$mY#3POV6L#iTKP;vpZRQcBc)BZ|L-)i+|YZ*3QIl?R-aP%fF-Z z%Zk6;d9{nL?)+XC;_r3Y(Z%w2bop8FpS#?7k;U)4XwyZ+H(j*P#rIt_sVnhGU8i>S zhJ{M}sa@xG1xa%iTt=|iB`@x}M#*cs?osldt~Yn{hIu9a&E1N+kzCYmW;c>&c3arZ zI$79lt>SCDjp=UjG2QR!PW+zkGrC)~8Qq`hPPJ#ce?{`IQcZc%N{q$mGQ)rGtRK#z z!5_{#a8{@|!+%YSSm2`@w<~H7u{_P95q!m35j?3SQtn39hWNc5$7?0VpF8nfRn}4v z-F^N&7eG6Au@IDbxzig8zUy>HXMz&2O&}ywQn)1$N(m-(1);*!+X}wyx}&RC5G?^k z4|afx^xPTV2$tB5&90e{k&|2&huyz$KK3ZCQ(A`Es( zo7dYAd#lYeZHXOh`&2uE_uKuTps4-R!q1xaUnv;X;SQl`afgk<&JX8o>PRs0+{Nb+ zEIt35^9k;`;88J^M=xA>Vbb8h1@$jzP~z{s@GhZfcBhvWeAwwb1$T5V5vEpmep|u# zF86jJc&rOFdWmnOiR5t6+>404co7IAu`9T_#5W4M#NQ^|&|UYH1U6m&oz@%0s5Tm- zS~`N0=s)@$gKfp@9<3FBGdxAyb%ro_F{t}S!8b4!#D4!7v6G9zgmVar&Y7!V{yA?c_~D#q zI}%Jhcl`MTOV3}WVAc8S&sV?e&;R%WqMI*Rcp<^U3-?~=6)zeA`tR>Ve0HbT6@1v~ zX9ah3ezY^e>drqXF!Wyx`sZ~a_HviE73}D8+eHL-Ui5&1M=x5g;B~kJY8f=WP0@W9 zJ=m3CZrA4(yxsK!1v|PPR8Z9I(QX6_yS=MmO!t!R1kZGTi@?s-G7KYD>pYEP)$j;* zA8Pk_J2u+m#(3Wt;g2^C3UD{Hqd`o97llqKq5CymLw_iN`xJFk{2sW7bjJj#P;Il9 zv|wKG@)0cA*KG|r*!E4>E-9PN{R*D6d_+vgiI47A!*cY)9458#i@D>6!R3vDpE4biO-&FsyUH_%2J`(eN_c&}z-S=yi7( zynfO9TJHB*ean~x#tqm3$_*F;&J9@E{R;yOasI{&zqyc2`3=Nq)|w;G36tI1(Jy`u zjP32XzawS$cRbWFR2c9NbsTlB%Md$t(58z%zsNz`x_#fxLEm@#Sp@bo9KjL}+xT?w zY)5U@XFG1|n0kExQ$;mr=h;R_6^m~hIVDtSV`U6yM=Js(yW&Cq|S<3r3t z(8`W04LV}h2vNo7%{t#rVAlEbwTpxvauZW zZnrOlfiJpka|wrIp!mGS=V=0q&)aogYC!@9cAfY9`4x+|jY!0RIz2p1-Kzn0et6zm zdQEIp^STQB4Z?WhPdNW!>V`)RbVGf4c_Aw~1T1J(Rlwc1Z!S=<=_K zAx5o)2B*=;z9yl9H1Xw~O(ryrYd7)X`nyiPy%C+<-e^@LX1A)*KO2>w-9HAX*|_rf!1O{ zlUIqAP2*vjwQ0<3{In`P-T3*&n#S{ums)y!8V@&~MVx8Oq5#uy`SEF}6|L3G#!obk zt<}Rc;D*m!Ha7mQF{3`Dp@`68NoECBqFH}61;41_tcDC@R>RFFhl=&FB1>D`kclj| zG^Zh3Mm+c7hQe#ZWeu6;vWBY~hKhUos~WCu7`Z+$EKsAwU)}IimH4#b=7y0Wfx$}J z+;A&N<=7KluWcy1)mr6U=HGnsxRYsc+{t&HOy_r<{P4-9eLQ^f#{j|d%pyJBf#tNuUh6aB3IP6a#be^Xy*^1rD+hIJ{Oia(>l>;~QtTASVA3C;eA z2J>8eUV|kKBE>x;EfN^9KPq`zGKHok|2w%Hx%>Z4-lL2?$+xDI%TPxNQ=$QGFM9YZ zl2;^0h6RQP>Xrb%l^iu#yY8*z4@z^a(H{P&lv`4$b4$vtDTyN=mAoVwtSq4^5V?XO zAxJO~N|^;JvD-0pYpl&W9+J?p!KEF?a@4UEAC)pD#egyD{0Lg#b;9_X%zAvyXKJyX zo~gB)V#+q7h9G3A$t*y1FxV6?xwc}dKnA-P>RG+I&k}d+`i1Y8O`Jg&^ zu;$A(Lj@WB+L{{_-B9zxn%-d6?cUmtse=3Y&yyS)7Vtl;Ik#4*FoWFZY7^&2jk&dA z_#^w9nhb1T%~_1ks*?0&&E#Q$DM6>3?W=irt+J?%$3|j(YAlh0jPkS*sKT(S#P0R( z4VK5my}_-)gfq<#T+NDJJ&}9HY=2_Vl@66&QdsG@O6vzE23hBLsbKk1F_pn=K7OFP zZG_;{W}15@xZc9`!Ow!}!@0v7SuN$Pb~Pkil=z=%Kth{eF)R3IZe0DWDul==RhWmN zM31=1e{<+L&qI9o7kVo^=DEUK5mvmkU>QIat_OPf`$E$_cHVSZ6ubNLyhT1**GPYn zzse`R3L7Z78~o1|8y|j}Kb7h~9bUoxU};mXC6uYqExMb$B|JVXtd?P5yvnnFkh=et z_ksHPz}xI;jbe=u|CoVy$Gl1gXx!}4z1#o5KVTH~Z}y*(-CaMDzV^zNoverbvS_SL zD>yH@5+k?2GP+r}UYn!a726*DSF~Qjb(44FtagEfc=No5!9Z?SiT|(YPwsN8tVw@f z^!+Gkdq27*3M#g!#fz;)&AE&={uTW}#ePr=WqeF46~A1?o#9gd?#LE7Fg%lFccIIZ zxPmJyU0!#m=9juT{xUqyd>$gj5#~~iTZE2*ry`3YDPm7Gim$*j>ssvYFHiab&j|mA zq|uuA=;*Cc6}~lk4_AHe#?P6-%O+@OTOX$Ji)s8B-5%KJF6O3osbWi$mM4{8-{nag zk|@1F7Yg`VL?;U+lXaaKTdun!QzOa4OE>Kck&OvaI)pB^M1FMFySF7hR_^(~OlXh_s*66s}9( zpRRCHrS)_teCsEp1rW22nH~knr^9~nJn|i0V(Ia0)zp6~?=Mb$E!FtWYpH8fp{%v3 zn^V2vu>IYs`%{yK1&X^z5TC}S%}cX;!+%#8ThWS-t$0sGR_vaN(<_3f*%hCw$QrM! zxLv{i^gA+0ydz^`hLxR|aeqc>Q`0i0Wmr=?Grq~Np1#TWE`y%G1IL!J9@J8OYo)Ih zjLICBNt=&kKH`EUncnpb@8!&wGp+QN%w3rj+XdDwz9(}+7OhRlD$e4At|k7ItQQr3 zF>7TO&8*CNJdVF_t7Sp37%Vv;P~xr1p}%H&iXO_NjaP6Zoxr;3cYR@MMW z-b_gHj7qOpQuK8iD>_Y%#jLmr>hYV4L9ktv`KF)GLWt-C`Rxlwv7&AlqlT&7-M2g!@hVfdVI4sn} z;o=IfR)}6Y*}rTuoEhwFDvKHNwH{F1t`u-LFKtO0f#{83Ql%M{VB|sb;bEm>A%&kZ z@_Z`grIb*I68|MtzSvb}6Q{3Bmo0N$`e*4l*!iESv5Q?}&PA=XN0~y^r9z`|nMUbU zCeYZA5B>@AXO=GVRxR?@3NytiW>%bCF~YHj4&YW8J1-lV9PvsgW(WN36%SP`6B7LK z=`+*IqBPDSv?uenEYmKv-AlF0*cc8A{N*|CMHPb^%oB-@M871Rp9OFyAI3PReirz*rUpjQ+u#CAo)l1a)#)T5R_pHm`US59Vr*KbSi!7s8#DyNTId;BR7fk;3Z9yKpme z6S}}A6i-txD))Bvk4npZ#=PSBZbldQno&GWbFsFWOf1c@gQFcgM;UC{7&qjqoejAM zbJ->bbH`RT);+fJ?#je>SN@<18}x%Jnn_RpK$Q=xD*UkO#OmZutG3gL-RQ`?=f|xp8=Z<-4jxio09-i`7?D zPm%9btrCkbK4J9aj2gOtzMV9U4l{qVOv{zT=Y{?0oU*d1cp%|)+2sh?V zx{@SWIg};`IsN=Es~)PFAl8kq_DHp|C4EPuu+kDda8A3bHBi_be{ zA;5(t{=!pUJcR{(@swq!FvMl2Y$tiqNPqh&U!Ow#uTRh7)Jl7;)$M<<{Kcm} zdm8a2r)^QN<+NR=QTp-I?>xhLn|{V~05tR58EXLMjr7-?@wrR+{EV;8Fed-i8Q+~j z)$h(2-P-DmZhd!aIJ&VorpeScwGuN_+X~gV!s%z+-C9ghr>3~4(5Z)m%blr8=jShP z_DQqU?vwo*$6#uZ4P_b+I(Ac zvsi6w4n6kpXE1MqWsnuYzEf^)CH`vU(}R_L?hH~zGfG`^#ui1loUv2Uc;g8noos9l z?QUdM!ovb*!;ZgeKD7lYQ(H`L;SGkmr?U-WS<6Jx-CbYZU0>blW9J!?EMnB-PjB@^ zD+)c)YH2Hvdj{xz%pV}qF@L!I59X(o(0hQwt6M?!t6RO^Dg{(e2{~)U>#f$cirY%p zwc4#ByIbuoP1?&AB59|{zr5P%FFSSBsr0_;)VEG8JCyy8Pu;21C8w=8jbO!Tt4~YF zeD$5;Myqo2$b+i8k{$A~P?#Gq&moe@F3m z=t8yj`;W+Djh`Bi?^(GD^5>Jdp9w9;00O(vaFYNct&nCuW|)lb3x6kWWwg1||3-G0 zZ)lA>tCsbPJ4WiN+5YyP_h8D{>v^XTR#I4G-GapoTr{o$x;)F@2`AHm(_qr!EkzyD4lfB#N z_5zAO5>6@G)mF?e@wR)BizoY+O!obkdE+7Cwztb1@6F`TK^bT9pY>n&Qwna3$TRrs zm<8Qm9{gAyl>W!~w|Rs8*n#wV@U5Vn(}a?@Vvcd4P+VYbH&Qi5j%2My-XlZ7{aD}# zp2p)+TCslq$hkpKF;_Xx!sUYm8mi&a!2)pFfE2)(qD z%S)THyumk?1e(@4obvs^Se7l|PY&;~aBO7x5jS9(?XUg(z-%bq_h*Mb!YVjwr2mos zGb`%T@H7aCgm=PU(}VAijm*ak<@@s^3uQQ37%5J-{9WmbG7We#duz4<2eR+ZF<@DZ zq7w|b?}T+V4H#c*Ni74$)h?=S!0391PBdV3gRdGGF#V(jCmArU;qwg*Sle(sS7nT4 zZKH>|Yyw!-_MS|OE=$@)w4J{zX|JM(((Xg^%<%84Ft-BHxfSLSZRgLc@VTO&SJ-d4Pf8O++Q{C3 z^`@PF^9e;K#3-zc8Muyp&b?4^wts*0>8P=Uy{Ru@#>w_yN;_nF_@Oj4neFeFjtf~l znZ1HGv;7scO&4lfQDG>4e26sl@7I3*T`V;$K?_W9S7b^=Jxqx_EtlN+krz`5_NLw{ z#c)^p&nSlUzBQXb>q?+iCAjZ|SqikG1f%QCsYfuU-sknb;?G9-6HYvIBJtG?-fcjj zaT2U;_>qE7Pu6l$Xlev&^Gd>qLuejv&YqKlM$D12}?jHVjy%470 zC*G&|afDx-bSTY$`&c~#_E!)oleH`TlS&3`tF*IHGU_IYvb6qOX%Zw(fqStb8E{+H z{jvqzpS3kBszL?2KXMy@Vr~Nf+@14WPFeoEstc->#nx5dTix0kU*qW-2Dt4)HMd;= z#@Cv|b^%yY>jMXjtNkkbh7`AN0NlO-aQgq9a zTWlEsRyF>H%?02#m$Gbg{WwC4QhKhHp&IBglWJKWi|~;z0xM1`mbhH&H(eOF0N`>>#B>5lHw){FuvAy<_O@(0l>`>z|9fB%@M%O5x~t+fK3j- zO%A|K4!}+BxskujOO8$hv6=fja-OK{70(&z|FiOIl{qS2tNczCi@#H4ixl*ds`Ho_ z`OB)kC%5w-tBsTHJ+AsQQo4&yxR2SA`)ch^Y7_sY_V_wx9iCBV0{|Ht>MX1)2D`BC z5*?vS>Ta^=rn=wM z)wei#Z?Y7*UFr8{C|r~AnuQZi+;F1e8{ms5;#~8^`CF(w9hIh+qa(IgynMOzga~KW z#HiV(C+d2pI|`c)m!gsWqm0Rby~)dDWz`{L1$BNIpku~>-RUbbv{_eVtdW&;Q^pP~ z^E9z8>kG&fKnIbPjGaVx=S;P2+xMV;PzTagEU8?nI=ZI||h! zD;}ea7)2zmuND>@|H48ZzD}j95XVy>CCU&}6aYES40hH&53#xxJ~1-p&rU658V=#3{hGd3h=z7Q@VuGKxRj zds%nkFMFSPxh;whjEK~@K5%`Yc~iT@N%@117V{Y@R5h0esYUd!T>kC6j26$ojeP!< z%U>9IEfUW^TK(4&@{d+uvhA_PCdoG5_R;F@$hj>S0q*_EA5~_SA616EU*dmI<2zYR zzpHUGe4KMw`<`TXKocKd>w#J+WJEZdb!YfMtr@l8rZaF_Nlc#4@tvX~NcSrCf=ND{ zzq>dPXn$>^b#kv;hYV@mywiA_yxO*zW2#|FM5c@!v1suGcCjPpUFn;Q_wC5} z*$km$s*HzN?;FY4`gwg3g=AWVaVE40g400Q#Y)yDR;WF!P;7+ky|^jH)qr@Pr6f}- z=0ZXupYlu|R|;um3_**mlQeR3lEuZ+)rUB`SN!e>|KrrX4mgxH8L5^kpTpd#@_B`w zE}qERt;#0Yl-kfHjmR7U{r|h_w^bSQw^hZ(Ise3ut*|(<3X35dH;*G>o@r~0!0}z` zIIc#DIImR}?^R_9p4O~mbMKlwS%wp!BNpRWxIYHc;|K(g{wD8!Uk2^_5kb|vaH#le zkz$azZG_X?VwUc5qbQB|=U)gmF1#Zc8XoZP2!6!h+6yGF!xZ~yDr=BHC#q3MofNhs?M(#hu2kKCW(DnjRQ{0IRIZEo}k)nuSHyvEP<#fUQ^@}hu6lnl(=$I z4jGI!W&(1fj*S>*Ax#dhu(Q(7Xm-;^qRK7GBz`o-f?@TDaVaWFuNBpdlQ&!s+}EUc znF#`&wFi>(HY7%MAe}YtAy$U7BO!lyk-W!>pd#Z}P8WbtK^JUY0G+1>I6agUrxXAj z$S%U90UBm~fW4{G&HIQFtSOAAu zfVB;OLg)Z+qMTA1y$!PiaLf*1RpZUzeC$X_w+w5EcsUkqIiF{B(cA7&EpJ#VTYCt^>FCdsmU4rE^|h|%P>En2wui! z!)4|&CJodXZiy85 zlDbpmq&}tIv&b5hop9pxlZY=k=}iT1p7fTathY`=o4YI~Q&4EUY=^uiZB$15VYD~m zCUh|~G&l30@gJ)_7sfpegGRX@Mv5PDU>4ge@094ev+`(JCPtfnGRn!U#_pYEfE1_f zk5W6%ieIj|Ccz~wIkzabGO=xfmIa$TFwLvD3Hg5$`duZi3rvf|GLVaDv-|} zv?q~QN!8hCQIwmFMTK%^LAjVcee(%w#wpxnOQ}-%_?c#w74Fj!q?POpE0o(%=XUw= z-;F_qV3WN1vChbvaxp6S*apPLHh7txkD=)022#nX^i_iiSXTgwPI|}z3r@Nlq9#T5 zT@B>n#*6K+F;<|2?;DRn0i(niR5BW$++@0oJJp+vy-g0e6o>cNHaN^ya=5%)x(dIv zA^cA(#?{uP?zp`l7cq@0z6?KCdJ;j2L2SV8tqe)XmLNu|gu8P-a>w!1%8zJ)9;qyg z7g%w6EmdPHGOy|~cU;HUWF1cP)|69pDH&gsC1%qjVO;GQP%OZV+OxD-X4Rg7-HZ6$ zb??`L+>hDI3}U5AQtXzbxWN=H(G->l#YYQ7EH0-z@m&2iddhK{ry4Dl0=3kHax&tp zC4S8$4VnH=b;|W=2iJdo(3dU!PK+mhqlT8C~yVm?(uOocK0EAcfyHX;7k@ z9ohg+2n6`_y4|Qm^-fi zhO+o)XZssaLn$UfEU}oTmKfIz6XO!fO5-%6`6k3QQ|Ac~pKd&KOOzZn|%# zzNOe(sUIumbbacsPJ1!#J>gSn zb)TNFU0T$-n*Y{YJ^$w1A<^AdPkZL4SY{cZK0s89R}GZK?O;Y3NJ0Fp+*$27A56Bij!aK*{5oNTVRugDbK z15>a}M&yeOh&usQ4e2y0dpVSeI;2LScYsfRd zc^emg`kKXT`E$^UD@TFh*?387-~gohw}d8yk_-Hn^?6;dp1dvmz17Fo$b`#9>hXCJ z4{sSjWwqjrin|I?@tx3nq0nHPyQcnlXk~~2t_-aX*_>CCZ6-RtvbKqLQ0zj-pT-y0 zG`EGJ5A+?UP{^8zOdIu{H5BsSi&u*=0ZVodKY(QA`)zyZ+2eo0*9W>`HTs_$nEDM1 zz1CLr(49b3!H*RGIXH=Rbv?LetbO&+ zINomC8{!aipw=R$z!=qLJmU?uk1C<|hgjz`IfwMyzvQhk4?^dTJ1s*x%3_b1?U zY{j%Z{&!VwtID*Wt@?abX`#b{? zC%WW^s;*KWtEili95a7G<{Ul#vMRSoJ-emqomKte^Z88AomHn)a$h7;eX{b6IIt!THB1hN^jm^(E1SV3xJkmq_v>RGW@93_)47+E32$Fx9W$gE)hoD zFJAT$e4Qb$T~&Ul5*l2E#%5I|It%>-1K#z46M3tbK{R4oX@?G_2dcGeSHDhaq;#^e zxALs~Ji7T0%MbtSi3~gBFJ{1zY3ZA7Xd%YE3^#0LXgHB+b>bPbG`jNMiLvR#0)K7J zMtQny%=tisR>S=l-N0)RD!9R>9<~Y5=1=lvQ-wGiiHB|w zALr~7UiT4r!&6HD#>&<(Huo-}=q{!$SH?F8DfLF~N4ehMq1fDWcdOKHLugM!sHGQI zUMC%F9V7Lwzt#?ljX58q+`ADQHT%;l?a_O2{+>#&WU{iaWImsr#Bmtu$V$DOeFy7{ zfj{TToOq%QUXNH!pPJ9(f)$9B_N{d3KCrI*F`1L4$V|>$p2EUM=5+NnJ@b`Jy^Q@z z=438$m{6-4{^aZub3iM}o}0~D&dt`q+gV$&B5vxw=7b(!y}-p?-5qAa$Li_|!^}`cg3vn`wv!#_9dUuBY zFD?4NGJnX_N1pv3G9Sz$zJxK8`)<}w#dc=x%d*^kS(D}Mb_eh8kvo~rI2s*KRN$-8 zQssg8E15epm9bdFEG{arf8FS=+P9d}IANm3HWDELxrk z~{gO#Xn&(iiZW>lOa;j5jiDh%(^%uT*?n?{;pe zxLLsu6(`B4MFv?*l*ZAMzKUjeMdttO?rXr~s>=LlGB;`3rgW-xw|qz=6s%H0TGCLo zYTBmIVv6~<0V*HfP9~FN=wvd^Oxl!XF<=XoEpBjIw_<@PU$r1s{wQ@t3<#yJU8#u3 zDnft?)h%oIl*J1F-|u|fd(WK72iZLPf1W*0o4NPB?|I+%ykF^{+FYU90giO zj@lpP&}RP0B2&m~4-tQ*^TJVg0RLj=j;Xf-r%^m~L!Rnb;=Y7+9Pqn->VC*1b_8ML zITI7JG3TbKPxFPqr>DL&RXoJ~($qJ2-XHDfLmK_j2k~AYhA0n4AL02U(Y^9~FWVNF z1o#C1eAFf`mu{Mh^&`Bbc>UC`Of9><4^XkQ>{^IXu@V;7-} zT~qh+rMbPB+B5A&d!sn(jqZvTIyi_zE zIYB-j6J*E_3su}zfgj3^pB+;U;%!Pyj=xCvyzZ#2e4a@xI>u>Rp-B)@?IPsH$r~%^$~@j!v8kdgUoMU}ft-fFRPkrd)&GnpDmvTH zN_CZ?a#O{F74o)*bMKTVdE0_}K|DF-%_+_#yk`mpI|p&1#S3GD@B8o&F*YkNf0_G^ zygczWydql99!*HDt~oR0b>ScK$icUu%DMdUJkz&VOzehykvb8 zgG^l9S1wbDK$f26{D?d;!Jo)mCRjWC^`zhNwbI{BdSQ}SCwPI&8OnmsmEXXpq_$0Z zQ0<38LSc$PuJ4rJKCzrHI*7Z;w@>^QcW?O?+M@c(uyYr8YQa0) zoAhtM#6uVqKv*v9?3wr!_hWfVRM3e5nBS&|-=1_IL_{ZW-=tqqMD4Mtg(l%SG16VB z=r(}vuW>4y9>T@9sp_jH0p|kX{2ILZ>I4pdG0D&G&aO${o8)J7HWvo&QIG1sQM!>& zT7S9h0G_AEZV_eAaxyIzRXLWwq4e%jcKvskKE-X2pDKL@n;(DSX6KpG-v{01*mTXnxr_#$E@`IomkN+!o#*cGiVv-t24-f45*S7w)T~QnhnY2k3lP zE?z+0D31Gi>hbe`&$-v%e<>{At8>n|NJZH>7hyY!-{30`kjD8fXQvxvcx$QzEUg-Tv^gvLb|>D6>l-q!rajr&oJOMq|&*$&@7#0z<>_$fZVkG1+i;% z%K35x+p=)q`bYWmXym^l_zc9nS&@N%uM3^v{1@M4@?X6dv?tbp=!wUkj_K4dBF{xi zvmqWn7kQ0bUqgioAmF1C5(lmgrjL^w)NnJn>AdIkI(Ojh2<#r=yvkqUe9kZMA`>y6 za`0@6=)mP6u44{${e-VifIJb73I^~54I6*sgnuZP$^`Jn3C~a96RvL)=)c!ZQ`4F zlz4!3MNW5Kjc!xpq(AXUjFaBvkr*fODpM{Tn>L|r*v~Ke+o&2WJrjK)8XPRWz+D3P ze*D*`9VN$1tn3e)o{7GSZWm0yj^P%sgz7+1FFcVt=!Y=o4LQ^KFxn}nQQH!VjlUe{ z#M9K^*QtIUgv~Q*pfORr0v&RWJH9ls2BCxEyq^#T zPV~4k0lYc%2WU3f0G(rjJs7?L3oXbWCHHV~@ZOS_`15ATkMTMgmKq}e#@EyyiyY+7 zi;=$)Dw;u7%3vh2OZfCIEZ{-?3@H4(iOu)v4Vxo77*Icm{FpzF@oogZln{9fKPr0( zV&0zsIncqoFCHBbz*=|YN&Y+;c|IaG?S4M;BK9>xf_*XaCNI2+z!ew%3*z#EAdKB? zuBA}dmaq+CBIGl?4oL&odiic1*0DuCSSI-5bTj1UB+N^Wo_A7r}M$ulgf$>kx?%nnhxkutzcB{|g zLB0S9(YV3L>vXBtBNSJC1mvyA5yFlLm}zdb^skZa6mPq8C*MfAQ@mog46N{CD(-WF zBcQjI{*LcsJy$viplGgh9ln48&`%K+(d$1~#EM##U=g|#26LP{!}!z(K8|r$_-Eq$ zXIPNs`F<>`^7;VQ*m?e3_&*Vc+Vq-^q*AV^R46Ltj-*lnD!-vyp!Tm&yWDxUgjBSf zm{ckhm8happ{PWaEEP(YsFFo0Qq#~aiYhA86_u!>GF{2S+LR@#WWn6kD6&P<6_pu^ z%5+6#hN3cEQJJo&Ob@kzis{u(H}0q)Xv5!-3+V&+3HYbey*142vo(Ax(Y_u20e^nL zRsKl-6&Pj6wRdINk1fH)CFcb8M>C}UUv%=hfFhk})S32vXU1pI%?yN*cekM1IDjw9 zIEPDK9rAFXP-;p;En;bDBz&Uw4!$_T#Zu^Ugb^+)<6*qra#2p;hN51jv-f7s*n zIL;THEspaQd|d?l@cqQW7wP=Np3arVOL~uau$9d+>RY0~qi-zbt#Y3>&ZgwanR!FS_6vJMXYMkhYye*niLYOXQu1 zbEWfTiglyo^x>s$ep}@mWzOwo-zszNEW5kR`61Q0zwDP~&XZ+_z~m72bOY({g7n>( zi8)VThahZM_1Yw{-4C{v`cZ{*VCtWyI@_mxbDHzbX}3+oT@lQLowqBVn1YTR8}?wE zp07@IzBaXQs&mWK?@bLkZ()lb?8epqer{^=lMgsQ$6`Fz;NSTGcD=dsZ=7E2PlB4* zn8f+wF;^eseCe349^-rs8Y2{9Gq=rjw$HqMCU#OgdEjJhdwTf8A?&Ps(?^|eV0&GRW>2~A6z5;CRV}Jw zk6P?dyBqdDJm>0DoiAaxVtlUew4Q3`hU(j@ot@SHIM?~g+?{iso9FGG=k#Oy2j>v> z^>7|suy=v8^UOVGI{%KH7%=9M*uSrK{?n`CV=KgiXNpZBAWY3a)Hq+Qxq7~H?fjq4 zcQ!BBzCi4&fEHp41#Fx!SnKqkap(+nen1@V!n+k|Y3rtit zQ06>W_CgsP@|p?GwRqLk>BH;+&Q$hpnZtR4^Ro$$Pryu}2f&-Ku;tuUv9AK(1e$hm zn)3%Ndxo4pv>K1Mt7KRJA5k2{i6B{D0@&P^c}8@8Bl76Zo>T zQTW*&mT;(2Ofuz}`Ikd$cJKm6T)Go~IdI_P3VbXx6uJ-pqZsA+IFA6rfsVv+6X|ss zoK5#3v25EqoJmWPjtdCIJI=$wLFfSfHyV_ALy?0YMY$uEOlK2$O6be;pEx@J(I}5t zLBQaI!n z33ua6>Me-)8HIzF7=crXzc$Y26wWk((~Q4%Nh_QwxD*MW26>p{%yOB+ISSxNc&5z= z@J$Mbp&h})4<-e9whhp?CgQrlq5s*XPt~MiBH`n3MvePLN7E93R5Wd9!w00>PDNBT zC)zaTF4SagK_}#PHebpsv+&>d%m)-NHi$BXLcatgiYmfa)m3pJ@P~8z-5MvPeO1Uoh(1Q6Py0?sp(Ewr9n zfGhQ!UytZ^bMaWZDxS$`qTJXL!$qO7Vlha(4+3XUMfONh2b2B+juRL?31Mq`UQ z&=6Mk#RWu`sw4@`nl~G#f_s=*i&pWA!m06PR*?AtnbjDe2i1mZ`~Qb_)$rTovR$^# zbt`sg%@^}ksh=~RK$4bp*!P)5+x;ehW!u~0amHN10O)k6RoWCWSi}}?rizXye2_jT zt#B58b_-8nU z6J!JzaE%@|-ZFkxt1`|nJY&8j}Guk3Ej4g=PM;xe5=BPP<01zlTE?x&r+RIHR)^~Ka z1ZMEs7ImY@QSZ$bwhvM?+F~_sP0Bu{6lb=6Xp7H!wir4tdO>fbxHuZ7mN{zZxOl^} z#n5rFaTr@@J)aD@rJnPtT&^oq5EjB#QqPq|`vU6ci?C;pumxg=qk`@hHx`MH>j@`$k( z6cH#2X9?$pL*l5}-vC;If6x!HP2Z4o>M!)#UTj<^3TGh6`sI^57&;l+26R zYxyz<5O7!pGtav4!VBr?P(Ea~eV#bF)=)US&bYYbbR8Fe0ZZP$Hw)w98eEY%YKonF zfX=ZNR|jaRNVp6~_*YPv$94iXQXX67#m)@Wu*X{L-_L*)vVWghz(t-i*KL6rWv=@} zQ5@#F?ZdfXV7 ziHjE0l5t_%odiX$`?$90(7A3aR+gkqht75TKyRd6w-&xJQm*TQO-C97+%=3Xw4TrR z^c+v7ljf@Mc9P!p6{iy5)ydd+dRguw*K!)`e)yo_KYr#ESgZ9{?L0a(C zPihO^3{*S&(Sj#JV={7!{24kJ<#B zTRbc80ghdmX`dLz%B;amI|~U-X4*PH00*qXpSR zj%2~r(1y&Tqnf7`m@~e6hC06mT;$09n!&t2|o_-B&IEp0ou=Z?@o!rn@{_3_UM+ z)w9Lzs>S9Hqv^;nw$OTh1b9-_V@LqU~r9 zyv63j8hya&g%ngwBz!CX66_W2na=@FdS+KHk?n}(+s#h>WDv+8Wet_XN$?ZE(LJ{0 z(f~-muH|KQNr&>ZpjLrA@1cM9coms#Q6BowUq7q;XE_+zI->u42TG=wM#AsInO*2V zH={$4efJt00TB962LFfiANt1C(1y(X%p~Zf3kvQ2JcvlUKT>d2p3?5KMq&3F#1{;J zcCE%=)1S57-vBLX_wIamzW9pJfUqFx*y6mGoNzg+iySqKz0SHs+l$y+1faCV(3$@Z=y{}Gc)>8X(0ZN*^-DeD9>3cjOShyF!9g>7X{qPp zqh+0+eOvH;CtK*3_hO44R2#__%Ri@WQ3rK@&}XEMXp292w!p(8~<;=po>Uw#V(P|gQ*C){$Y10av*@f}dFpN!EgL##!88W_>0b-KU zcIQ(9((YLP$+rh@L~g7Jm#7B}sH=5~6)f%ENf?0;!V(NB!r$_?RbQxKdX)ewm-YbgA~S4?*LL zjq5z2kKOV<9q$`u(+aTYV<)1|rN!|iMo}%&$M)g>a6ZQVolmF9{{3$YxX4rH@Pp8^ z%p2E39(s~l=ws&qfmb5oN}Sn+KDK5QKK6v?WBWk_1`HJKV`oDO&&N8DU;F{3*XIrj zD~~pZn|#Pp-|e~;$HEVbwo3N7ySlVZ9bggLEVSu|KcH?nPfaHjS$J~6J>jBwqKz0E#Xh0qy9hlIs^%!dzke30e zgXYb=>iY(t1vTxV^Xh1PV<*}N21WgcZKrR%v0nSe8j$=k!A7BPZ1Q~Ln?;?BdG##J zL1bPXD2hY>nc?|QC3x6q%&UC9K<3rw4LS4)^XeZwAKQuM(X-9M{=F0A;bv%ilX+2{ zG5799u*-NKD2hWr+%b$FvQi&#`Vdmggpu$LoSE}9ofEb}9+`Xj0Oxu^ zy}fWAX$yL-U1*Dkhp~m$^LGmLybf!I$#lL|Q;fn^)k4q3bAq;o#9`YTP__8nlf4bt z6}Okl&!e$L`Z{fki@?bCXOW*hTky#cyD$b%hOuM}o?s&Y!WiU}ro)YG7+*j0>~$4t znf^?|jGYAtQfaTl1X+dldTJPZu>xafA;x1ecIp5DAW*bTpM}i9*vYhZa^=Bu$`fGI z9%a*FeI?e7lYE}evzOp87qv1NyfODm`z`|HfB`ulkjA5fIF|yl`(tmOg@;V^B9}Pk#V<8}ON)YlvE)C2HLaG2cU_j~s zse`usmVOS9KHNJt`yHu6`g7atPzaX;Mdu$+hhZL>Wi#| z`=+1v>bN-b)KKUzfS84GaS5ygw+EfO?1Tt7<6;>QWL!jMvCV~ragiAzF4#A1$A8&3 zamPu!Fm_fz9+~T2hIP?V6%_VOKS6ETH(}AKNMU@eCQNdK8})w+lV07gvVM zwDhR`(7g00FfCH(Q6GcGq(_|uY?!m4(4&sS73ooSwkA3~Y70iE(xYxGpde4_Q4A%J zf-N|{v`Zgw2HXJX~Wbp^0*(a;{HeM90XRvF2YA$UOlaTjaYk5?Kv^fPI*Xgx%UL^E$3SD5R2Zj4pSKEEgWszX!Mo?BX|ov;Wp5bndEmk zgL(=IJ)vq8p71`*2S>^zvq#|xQ3xYFp&OE*0fC}D;q#s+6m^8qUNoa$S-IHQk0gHq zoN}aMqy z*zrZ{!|4J=k0JX36YUE3T3wpX*j`J+%S3r7*Bo4q(?mLXTa=s5ic$Rjg$E}u=O62Ui~Tm?Fyy`_jW z+J7Vt{pU2Fi8|8$Q-gB|0vrp)TYp}oqq_(WYtc8ZdsX|!Q#gYn3JQJW+1c7RHWgfz zr}T|Kp4v^Auydi z66UiTs1zvLrnh-ET_4XC+EjtKcg|3~u(qkhA+I?BF7j}^ZJKyp+w=uAs<=&CKB8@U zwIvS$wCU++m$WH5mm-BWtsaF7%2tY0D^Vy4ADk0`Mws(}z5p%5_LQ zRZPnjEB_ie^mV`3Rt5B{M(R6yjL^!~A9!X8x~-!)oCdHXZhTJ%K};a?E3j zt#d-XlA_pdBziR1GK${ZnW%00Z8QpIEhw~U)2Z5~KQFi{Pie2-QP^uC+9mA;!xiC0 zd!2$6U}>)dMS0O)eWS3~X3t(jXZ0B{s2qK*hrK#Gnu94F+_%79jmlm{V_8)j)HW3o z-+)?mN!7o5aVqK1rhi2X%8a&Yn=S)X#;I{xoV*vCV)t?trxU;k0a;LJ(-Yv8P$T4t zk*8is znEvw@&wr4tiWK_Ky`%7-e$Rg%1{E+46dms$#Z*iBPe(KSXMN!s@NM*;ONZ$TRJH!K z+FQuv|Dsk(N3P)dC?J)oAkO1}w0k%%=wR*;YU~K&ya*gG1NeR@;R3G>i~H*q_x%=k?kHtye&3k5&$hU~WpQWU zYufz*i~E5wai3#xKVorz!s33w;{Mc_xO2Azqh0ga;Tn({EPa`f+JhGNfiZEfvA8=H z_oprHuUgy>jfwjLi+j1neZb<*oN0R9;F!2Cw76GT+z(pZ%aF26?r)8Wd!5BSYH>eg zaev=9aj&Vl%5S!c^ zE$*|&#C?s$z1relZgF32ajzZ|_ZEvgpFJ^JTEn*!3`lLW#eKn;xVKx}c?Z(qRTH(i zUuto$8x!}G#eJd0eY(XxYjIyTChi%Fd!5C7hQ=*;=a}5zGh6^FSociSls!Bkl}~5H(A`<$HaZ3#l6YmKF8vIuf;txChl7- z?iX9!^$rBye5ZDw#l3q>+ythV#r6J-lElj<20_pKK97K{5ri~BPc_swJC zzTM)^Jy;DNtEscNAF{ajj)^;;jWc-7Pg&gSE$**c+_#U3d!NNUV{zv_c0$}u;-0s-H(1=uAdHendEL%2ao=fi@3y#e#ocWC`+V-|nYRVdhFn7%daqZ16+-Xz z8d#}Nt=O$w0iK1$S`{gVqwa%v>mzPsLr_PK~7!;%13Pe+{db(x#PI z`_fSJ_b9AsdG->QxKB*K&=_h7$U;D(opN1NLK*?-RS^F^n(sd^q8?Z7{9^kLCdFbV z7QnImhd5@@PvG>UZImSv=Di8KaP9PO@q!7p7YV;o9tzpdxpM8aa;{!G{Q#_D7p|S& zm(pveSY$4cnKig}ItXE)6!5CVUz-%y@ZS%6$u)fY$r@A=Yxv*A+_V$6r&|+Q)WyK1Mkf8ih)R zvnlVnl%ws^UU*v^}Z1@z;s zV>bdzXw_TC_H^j2V;`rpjfTuW4Dv?5Q}ZzB7?4`-+Myu22N@IhCX4$l2yF1GIbw1D zB;3H{zGqC_do1pa7WdhO+NkCGe&8)abIn5ueP}J4Nt{g^@Ds1bf|t%@Apnm zkhSy}pxdXcEVxg%0#^!)j#JO0is7gn?)g|T95n_YeId^ql~GW8HBJ>o#UO}^^4_@b zx43V$xTh@c`ElaD&*I)|ap!2oYnbpxli z9l@+1Lq^#QlDY`*w?a#^OG2oVerT%4Qawzt!TNvbY~V zPTcRcxc6Gz+b!;s#)e`1`t-)eF1vAD0cxZgic-1{usgiz>lKxU{_2vkOS1(4bJC1nYjgc}!{yM)YC zH-dO3Ayo?EQ~m@X$Eh_CiNjq&pHQ zbiP1Hhg>z1km~_q3~~)bLcR{j3>^Bc*ae7uzE@NJJ|H@lWv%^yu=uU`4?y%>M&guI zguHK4O2~15u$1G!gq#9MJT7`L=Po3#@KnT?!7B=gEbzK8Ua=2>XhHcfaP$)tl1?okmB8{PTmpz5Lox$oy7Fr@#%B}Qe#NC2@uogce0k@tNnn`4}JYS4#=RPpBDkqD|oUMq*3R2 z0;-3ifRr2Dj|W7L&{W$2VewnB%!2Sk>dd{qJXc#dHvuvoIHW2m-w#MT&U|hB9FT59 zo~Hp}PwnIU84#1#1l;0nGHQJQke+h(Fy!UB_->)=;yBof52+i&%EMdNXYpC1P@QK% zvF|}Vvq3(O1aA@bYmHh2oh2Z30sWi@h+esswNil0F(m9}Ewsef>X!u_4Z*kOJCi_> z_aZg-Z<;vBb#e4WEWYON0gfJPq3s=s4m2XP+G`iF9|caIAD`kEO5JsgB z3F93g-!n-WpK}SVF(}Ujj=YDcW&S82uN$7Z5D<=ke4CyR2n$T6xHkimF({`1S%&L= zExwb~sA@==uLnfFg+%VMFNg*5p@g)ESoSs6hgwmi$G;m8`Bsah!!O>fXo9%{+IRps zdNoyY|5rd37&uP@!s1(b5D-6y^P4!_-*UFmQf?Zu2DSW_RsgcY)1iyq8~C2xPNUWd zz+v&*RRhRsh~|$a76ZZ<^dT-F`W~5V#oqy9YU5f!So~Jp0Em45Lg)ui&7gM(%`oJ- z&8X$Nt^Djjh_h_JT|Y2z$YZ|+`7IzElTbP-;j4h?y8{w35sg}gL%&^r3&>rb#z^^8 zK=kZS);gQ2z@gtReh%z7gH95VJqDd?0MYl>C7pi)q~56Y4M5rr2zL%PM-Gnw!Xml5 zeE4WBz6{k7>_MIYjyZyT9uU29B0W5g-kfpc+x<<&OGXpMTIm**{m4(s975N3$ZHpK zUA#RTlK0;$70xu^EL1gQtup}W^JHJp zr!Ii=K|t7__`FU5WQ{@jOh9^o<9qljKxTV5ZmhX8n@4;ZEsX)E-_Yt80FlznH~=ZmqI0YnN>gSOn%szLA~DUYwR>Y7I@kq&c^|H*pPXRs>KmkNf@GpaJist z08SlXvR%A*Ga$tCdEKYz_>e~|$dd}<=Z#kg0aMbV_vZ0JbysS;(b9K-NzaAvpTw z_W20|+NILR*zeBrcoH~GpyNl=ARuN=2w~^oKBLwoKsFjJ zJrR&SMh>4#9HSMV0c4?9>k^z*CEBrw5bH?biL*p=SDPEl#?ozxoIXvaTRZh}thqUx zSf|fY$+TCAty1*~W<$O6&Uh-B%XnGJ4!Jp^rxH-0)Umu6?V7oMSpkBzn5*Dhm~ zqHwXEi%PX}$fA|`U25}+BUZxEtG;+315L*cwRqL zQ-TPloNh~}+m`BVj-^~|QJc+yO8`Vq1BgDGgi~LZXu)+fi{Gvnm>beAKGY+s*3F$4 z1hI|+^6q870`V%fD|7tbd<(=Ml#vF22Q;O&FHbIi9=_zZ0^cK8 znx&vBn$g(67v2DGOLw6gh$k{aDD`D3BwSqsA1$H;?%Ko#snMzoVmH8;2ozT%xXd4{ z?yj$`kz9!qYiaRN4B~Ow6uU+?(b2gsVOMgS8dlNinqwFj2S22EDmi0|9M?Uc87r1g zEbl;8S{%zk$?jRnbW7t>rRc)*#T(9LlGTlN&rakO=2@NDMJ+A(Kq)Pvl;~ew(h1`f z5?k7xpb)@nTpE;MdG6fKv^M+l^pdV@Hj&N?44-x)-PPeHJA~V$lFivzc7vOSYytM( z#}6Qo+BDyduM;}gr()G9aCtoe?J*e-L8Kxu!AavJI+EBZD&6XKW+8^iVER%wCa7Lm z`;rug3OdLQAh9+#V1+f5a7DRC97T|!yvVT)jP@wEOS4=qE z`gx0Zo-PD%J)b*Y^Wx{bI^m%jHJfWsw&wL&9>JKpOr8;6&fyq8F(g3MGs-h$0eBj^ z3ytQ+Q;Arboulh64v0hEGPE*SU~w^Ehq{om*TK)Z+TbI#Yd z`j6(*=kYH)DjYG|kPuyWNvboKki$>WL2>s$jAhXsvGQ4P{RWvZW%pIz^qG}DX^SdU zBvZbiT~t`Bvy69kh{Y-;ke)U}hY(M$W2s9>{tdBQ9(`|Twho3}mB^o$mc47sQr+F2 zl}y21gtS2+{!dpTyFrdMWV^EoxDv+4EuHI^b}y={;n+!xQ@q9_SH<#4^p6vQi{fiiC5ooSBzI%1cigUfX_=Qd!(-r=sp=dU?PBeu(k z#Jxjb*`odh$(Fne_@URiQ%2e6s7}N4yx5iDHG=~73WfAypuwgs+)(9fGjIITe?yzq-C=O9y$`v zy;Ln6rqK_Ps5x;$1Y`E=V{t(SQ%WeC*#i?TeNCE#34-mbPqfNdLS~9Dl6eWQ9TaL> z-nj$Qp;MMhg`Nu{ePx0k=qVwS393P4i-J^ApUh=q7@{dVC?CMY8E#czPhOY&wTAzrmtwKWzCzwKiRRs!@-k$Nb;dyscr*`?edGyc~%g;Rtz;jK6#+NDA;Y1yFs-^n;a#U*Qu$m9Itdrwe~ODSxCpq6 z=ho6E}81{4Aw2;0tFFkaFAreu7r7@0Hkf-gJKX3PTo;BQPM)_SU@GfL^idKhPf za*BpoL|eK8V{}$5aMugyN)4T{7S-nj+_$hNTE3{sY>T>SuE$5Bebk864-f5n5XkHM zwe&_Wr1Y=#i;1mPFDJMnmx)zOH8r(55Mbc0C!B_zth`YG5!(`C2?z6K4t58(6fkEMtqJ5e$eB7%=Nzv;ix^)XJ+e&YZ``#+;$dM@SS`-c_)aa@u(JBTN z-jHA|#%zfchZ^3Xz-*h8HrN@$ZP5*3&|Nfejjj#-6TCRk1!|!8VYm_PURG)zS zof~A55}knns~fgD@CCs@HzRP0IjL|nOl$(aEVZQEP%9UsfOSPXYC&1tc2>OAZ5eeY z@-yxv!+}LFYKCLsppC&$(32MQ`m{PuSNS5)Bz>X9+)xk1o9 zJV%f$>A4Wd98!u)`a^{7QV#u#jdKK=x+kvN#3o7qF*}doC`I~qKA@ARyO+R#@d9I%gOPX)9k1J(inYlBt2xQ>a}OxG@%FX3{94TQa1u-+ z!FZ6*LArJqCVNBQF9I^LH$YEm$tvxnZ?Y(}`RhyOz)2g`%=1O31VfJchfgwv{b`YL zvGfQ%?S-0#?j!Z^%xzK2x{Nw3T&tx+&q>i9b4`>P2oR*8!c^>yrHYJ13r13Uuv9od n&?6%XgON|JtJCY5b;PSk@a-TqUu@vYiD$u3a`;4~9%uT0|4HO{ 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 c873d4096bd..956f0fa5aad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ namespaces = false exclude = ["tools*", "docs*"] [tool.setuptools.package-data] -pylabrobot = ["visualizer/*", "version.txt"] +pylabrobot = ["visualizer/*", "version.txt", "micronic/code_reader/native/*"] [tool.ruff] line-length = 100 From fb855a07c967a9ffb6ea175efe16bd62424fe1e8 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 6 May 2026 11:29:09 -0700 Subject: [PATCH 15/23] Remove bundled Micronic scanner helper --- docs/user_guide/machines.md | 2 +- 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 +- 9 files changed, 396 insertions(+), 454 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/machines.md b/docs/user_guide/machines.md index d4cb1ca7f57..da2ec995a78 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -192,7 +192,7 @@ tr > td:nth-child(5) { width: 15%; } | Manufacturer | Machine | Features | PLR-Support | Links | |--------------|---------|----------|-------------|--------| -| Micronic | Code Reader Software / IO Monitor HTTP server, or direct local TWAIN + serial control | rack readingbarcode scanning | Basics | [PLR](https://docs.pylabrobot.org/user_guide/micronic/index.html) / [OEM](https://www.micronic.com/products/code-reader/) | +| Micronic | Code Reader Software / IO Monitor HTTP server, or direct local scanner + serial control | rack readingbarcode scanning | Basics | [PLR](https://docs.pylabrobot.org/user_guide/micronic/index.html) / [OEM](https://www.micronic.com/products/code-reader/) | --- 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 956f0fa5aad..c873d4096bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ namespaces = false exclude = ["tools*", "docs*"] [tool.setuptools.package-data] -pylabrobot = ["visualizer/*", "version.txt", "micronic/code_reader/native/*"] +pylabrobot = ["visualizer/*", "version.txt"] [tool.ruff] line-length = 100 From f3a8335f92d2024a033e055a80c6f38c4620f6f2 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 6 May 2026 11:43:53 -0700 Subject: [PATCH 16/23] 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 87312f9c385ea47cfa41f3be3dabf134c9481403 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 6 May 2026 11:48:46 -0700 Subject: [PATCH 17/23] 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 119f3cf81bf89c2072df03f1074c960abab92686 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 6 May 2026 12:18:39 -0700 Subject: [PATCH 18/23] Simplify Micronic direct rack reader --- docs/api/pylabrobot.micronic.rst | 28 +- docs/user_guide/capabilities/rack-reading.md | 11 +- docs/user_guide/machines.md | 2 +- 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 +-- 13 files changed, 81 insertions(+), 1090 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/machines.md b/docs/user_guide/machines.md index da2ec995a78..db5a5fbcebb 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -192,7 +192,7 @@ tr > td:nth-child(5) { width: 15%; } | Manufacturer | Machine | Features | PLR-Support | Links | |--------------|---------|----------|-------------|--------| -| Micronic | Code Reader Software / IO Monitor HTTP server, or direct local scanner + serial control | rack readingbarcode scanning | Basics | [PLR](https://docs.pylabrobot.org/user_guide/micronic/index.html) / [OEM](https://www.micronic.com/products/code-reader/) | +| 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/) | --- 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 5e706997dfe77dc6e98b539bed3e6f80fd966052 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 7 May 2026 15:22:00 -0700 Subject: [PATCH 19/23] 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 74bf9f2980007c34504e94682d3d7d2697503417 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 7 May 2026 15:25:12 -0700 Subject: [PATCH 20/23] 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 7e78900bb3149d108798bfd5cc7b81c946d0223c Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 7 May 2026 15:36:45 -0700 Subject: [PATCH 21/23] 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 84d456ad50617b42e4901b3db9ef51daa87d52fd Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 7 May 2026 15:38:39 -0700 Subject: [PATCH 22/23] 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 65bb7ecaa12d5df8c0fea405fc4ac6ae96094d01 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Thu, 7 May 2026 16:29:54 -0700 Subject: [PATCH 23/23] 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