From 0909bfb7501407594111530086869bdce95db220 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Sun, 3 May 2026 11:42:40 +0200 Subject: [PATCH 1/5] Add LI-COR Odyssey Classic to v1b1 architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new capabilities under pylabrobot/capabilities/scanning/: Scanning, ImageRetrieval, InstrumentStatus — mirroring the plate_reading/ umbrella with three sibling capability packages. Device package at pylabrobot/li_cor/odyssey/ with OdysseyDriver, three concrete backends, three chatterbox backends sharing _OdysseyChatterboxState, plus a minimal OdysseyChatterboxDriver to satisfy Device(driver=...). Dual-base errors join vendor and capability axes (OdysseyScanError(OdysseyError, ScanningError)). Chatterbox lifecycle verified end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/capabilities/scanning/__init__.py | 0 .../scanning/image_retrieval/__init__.py | 2 + .../scanning/image_retrieval/backend.py | 36 + .../image_retrieval/image_retrieval.py | 33 + .../scanning/instrument_status/__init__.py | 3 + .../scanning/instrument_status/backend.py | 19 + .../instrument_status/instrument_status.py | 23 + .../scanning/instrument_status/standard.py | 18 + .../scanning/scanning/__init__.py | 2 + .../capabilities/scanning/scanning/backend.py | 48 ++ .../scanning/scanning/scanning.py | 49 ++ pylabrobot/li_cor/__init__.py | 0 pylabrobot/li_cor/odyssey/__init__.py | 61 ++ pylabrobot/li_cor/odyssey/chatterbox.py | 201 +++++ pylabrobot/li_cor/odyssey/driver.py | 786 ++++++++++++++++++ pylabrobot/li_cor/odyssey/errors.py | 54 ++ .../li_cor/odyssey/image_retrieval_backend.py | 69 ++ .../odyssey/instrument_status_backend.py | 89 ++ pylabrobot/li_cor/odyssey/odyssey.py | 210 +++++ pylabrobot/li_cor/odyssey/scanning_backend.py | 179 ++++ pylabrobot/li_cor/odyssey/tagging.py | 95 +++ 21 files changed, 1977 insertions(+) create mode 100644 pylabrobot/capabilities/scanning/__init__.py create mode 100644 pylabrobot/capabilities/scanning/image_retrieval/__init__.py create mode 100644 pylabrobot/capabilities/scanning/image_retrieval/backend.py create mode 100644 pylabrobot/capabilities/scanning/image_retrieval/image_retrieval.py create mode 100644 pylabrobot/capabilities/scanning/instrument_status/__init__.py create mode 100644 pylabrobot/capabilities/scanning/instrument_status/backend.py create mode 100644 pylabrobot/capabilities/scanning/instrument_status/instrument_status.py create mode 100644 pylabrobot/capabilities/scanning/instrument_status/standard.py create mode 100644 pylabrobot/capabilities/scanning/scanning/__init__.py create mode 100644 pylabrobot/capabilities/scanning/scanning/backend.py create mode 100644 pylabrobot/capabilities/scanning/scanning/scanning.py create mode 100644 pylabrobot/li_cor/__init__.py create mode 100644 pylabrobot/li_cor/odyssey/__init__.py create mode 100644 pylabrobot/li_cor/odyssey/chatterbox.py create mode 100644 pylabrobot/li_cor/odyssey/driver.py create mode 100644 pylabrobot/li_cor/odyssey/errors.py create mode 100644 pylabrobot/li_cor/odyssey/image_retrieval_backend.py create mode 100644 pylabrobot/li_cor/odyssey/instrument_status_backend.py create mode 100644 pylabrobot/li_cor/odyssey/odyssey.py create mode 100644 pylabrobot/li_cor/odyssey/scanning_backend.py create mode 100644 pylabrobot/li_cor/odyssey/tagging.py diff --git a/pylabrobot/capabilities/scanning/__init__.py b/pylabrobot/capabilities/scanning/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/capabilities/scanning/image_retrieval/__init__.py b/pylabrobot/capabilities/scanning/image_retrieval/__init__.py new file mode 100644 index 00000000000..3a7d56f2a7e --- /dev/null +++ b/pylabrobot/capabilities/scanning/image_retrieval/__init__.py @@ -0,0 +1,2 @@ +from .backend import ImageRetrievalBackend, ImageRetrievalError +from .image_retrieval import ImageRetrieval diff --git a/pylabrobot/capabilities/scanning/image_retrieval/backend.py b/pylabrobot/capabilities/scanning/image_retrieval/backend.py new file mode 100644 index 00000000000..f6f82ae01ac --- /dev/null +++ b/pylabrobot/capabilities/scanning/image_retrieval/backend.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import List + +from pylabrobot.capabilities.capability import CapabilityBackend + + +class ImageRetrievalError(Exception): + """Capability-generic exception for image retrieval failures.""" + + +class ImageRetrievalBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for the image retrieval capability. + + Lists and downloads previously-acquired scans from instrument + storage. Independent of the scanning capability — scans persist + after the session that produced them, and lab users often retrieve + images they did not acquire themselves. + """ + + @abstractmethod + async def list_groups(self) -> List[str]: + """Return the names of scan groups available on the instrument.""" + + @abstractmethod + async def list_scans(self, group: str) -> List[str]: + """Return scan names within ``group``.""" + + @abstractmethod + async def download(self, group: str, scan_name: str) -> bytes: + """Download all channels for a scan, concatenated. + + Vendors with multi-channel scans (e.g. Odyssey 700 / 800 nm) may + expose a ``download_channel`` extension on the concrete backend. + """ diff --git a/pylabrobot/capabilities/scanning/image_retrieval/image_retrieval.py b/pylabrobot/capabilities/scanning/image_retrieval/image_retrieval.py new file mode 100644 index 00000000000..848c7a9935c --- /dev/null +++ b/pylabrobot/capabilities/scanning/image_retrieval/image_retrieval.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import logging +from typing import List + +from pylabrobot.capabilities.capability import Capability, need_capability_ready + +from .backend import ImageRetrievalBackend + +logger = logging.getLogger(__name__) + + +class ImageRetrieval(Capability): + """Image retrieval capability — list and download saved scans.""" + + def __init__(self, backend: ImageRetrievalBackend): + super().__init__(backend=backend) + self.backend: ImageRetrievalBackend = backend + + @need_capability_ready + async def list_groups(self) -> List[str]: + """Return the names of scan groups available on the instrument.""" + return await self.backend.list_groups() + + @need_capability_ready + async def list_scans(self, group: str) -> List[str]: + """Return scan names within ``group``.""" + return await self.backend.list_scans(group) + + @need_capability_ready + async def download(self, group: str, scan_name: str) -> bytes: + """Download all channels for a scan, concatenated.""" + return await self.backend.download(group, scan_name) diff --git a/pylabrobot/capabilities/scanning/instrument_status/__init__.py b/pylabrobot/capabilities/scanning/instrument_status/__init__.py new file mode 100644 index 00000000000..f17ef263cbc --- /dev/null +++ b/pylabrobot/capabilities/scanning/instrument_status/__init__.py @@ -0,0 +1,3 @@ +from .backend import InstrumentStatusBackend, InstrumentStatusError +from .instrument_status import InstrumentStatus +from .standard import InstrumentStatusReading diff --git a/pylabrobot/capabilities/scanning/instrument_status/backend.py b/pylabrobot/capabilities/scanning/instrument_status/backend.py new file mode 100644 index 00000000000..254ed6ca970 --- /dev/null +++ b/pylabrobot/capabilities/scanning/instrument_status/backend.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend + +from .standard import InstrumentStatusReading + + +class InstrumentStatusError(Exception): + """Capability-generic exception for instrument status read failures.""" + + +class InstrumentStatusBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for instrument status polling.""" + + @abstractmethod + async def read_status(self) -> InstrumentStatusReading: + """Return the current instrument status snapshot.""" diff --git a/pylabrobot/capabilities/scanning/instrument_status/instrument_status.py b/pylabrobot/capabilities/scanning/instrument_status/instrument_status.py new file mode 100644 index 00000000000..f3535874272 --- /dev/null +++ b/pylabrobot/capabilities/scanning/instrument_status/instrument_status.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import logging + +from pylabrobot.capabilities.capability import Capability, need_capability_ready + +from .backend import InstrumentStatusBackend +from .standard import InstrumentStatusReading + +logger = logging.getLogger(__name__) + + +class InstrumentStatus(Capability): + """Instrument status capability — poll the device's state machine.""" + + def __init__(self, backend: InstrumentStatusBackend): + super().__init__(backend=backend) + self.backend: InstrumentStatusBackend = backend + + @need_capability_ready + async def read_status(self) -> InstrumentStatusReading: + """Return the current instrument status snapshot.""" + return await self.backend.read_status() diff --git a/pylabrobot/capabilities/scanning/instrument_status/standard.py b/pylabrobot/capabilities/scanning/instrument_status/standard.py new file mode 100644 index 00000000000..c99a95aa6ad --- /dev/null +++ b/pylabrobot/capabilities/scanning/instrument_status/standard.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class InstrumentStatusReading: + """Generic instrument status snapshot. + + Vendor backends populate the fields they have a value for; missing + fields keep their defaults. + """ + + state: str + current_user: str = "" + progress: float = 0.0 + time_remaining: str = "" + lid_open: bool = False diff --git a/pylabrobot/capabilities/scanning/scanning/__init__.py b/pylabrobot/capabilities/scanning/scanning/__init__.py new file mode 100644 index 00000000000..74cea53ccfa --- /dev/null +++ b/pylabrobot/capabilities/scanning/scanning/__init__.py @@ -0,0 +1,2 @@ +from .backend import ScanningBackend, ScanningError +from .scanning import Scanning diff --git a/pylabrobot/capabilities/scanning/scanning/backend.py b/pylabrobot/capabilities/scanning/scanning/backend.py new file mode 100644 index 00000000000..3e0c28368e6 --- /dev/null +++ b/pylabrobot/capabilities/scanning/scanning/backend.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.serializer import SerializableMixin + + +class ScanningError(Exception): + """Capability-generic exception for scanning failures. + + Vendor backends should raise a subclass that ALSO inherits from the + vendor's driver-level exception, so callers can catch on either axis + (capability-generic or vendor-specific). + """ + + +class ScanningBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for the scanning capability. + + Concrete backends configure and control flatbed-style fluorescence + / luminescence scans. The capability does not assume a plate or + well grid — Odyssey-style flatbed imagers and gel docs fit; + plate-aware microscopy belongs under :class:`Microscopy`. + """ + + @abstractmethod + async def configure( + self, backend_params: Optional[SerializableMixin] = None + ) -> None: + """Set up the next scan with vendor-specific parameters.""" + + @abstractmethod + async def start(self) -> None: + """Begin acquisition. Backend must be configured first.""" + + @abstractmethod + async def stop(self) -> None: + """Graceful stop — finish current line, save partial output.""" + + @abstractmethod + async def pause(self) -> None: + """Pause acquisition. Resume by calling :meth:`start` again.""" + + @abstractmethod + async def cancel(self) -> None: + """Abort acquisition and discard any partial output.""" diff --git a/pylabrobot/capabilities/scanning/scanning/scanning.py b/pylabrobot/capabilities/scanning/scanning/scanning.py new file mode 100644 index 00000000000..6a8adf57f88 --- /dev/null +++ b/pylabrobot/capabilities/scanning/scanning/scanning.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import logging +from typing import Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.serializer import SerializableMixin + +from .backend import ScanningBackend + +logger = logging.getLogger(__name__) + + +class Scanning(Capability): + """Flatbed scanning capability — fluorescence / luminescence imager control. + + See :doc:`/user_guide/capabilities/scanning` for a walkthrough. + """ + + def __init__(self, backend: ScanningBackend): + super().__init__(backend=backend) + self.backend: ScanningBackend = backend + + @need_capability_ready + async def configure( + self, backend_params: Optional[SerializableMixin] = None + ) -> None: + """Set up the next scan with vendor-specific parameters.""" + await self.backend.configure(backend_params=backend_params) + + @need_capability_ready + async def start(self) -> None: + """Begin acquisition.""" + await self.backend.start() + + @need_capability_ready + async def stop(self) -> None: + """Graceful stop — finish current line, save partial output.""" + await self.backend.stop() + + @need_capability_ready + async def pause(self) -> None: + """Pause acquisition. Resume by calling :meth:`start` again.""" + await self.backend.pause() + + @need_capability_ready + async def cancel(self) -> None: + """Abort acquisition and discard any partial output.""" + await self.backend.cancel() diff --git a/pylabrobot/li_cor/__init__.py b/pylabrobot/li_cor/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/li_cor/odyssey/__init__.py b/pylabrobot/li_cor/odyssey/__init__.py new file mode 100644 index 00000000000..669a2e8bb33 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/__init__.py @@ -0,0 +1,61 @@ +"""LI-COR Odyssey Classic — public API.""" + +from pylabrobot.li_cor.odyssey.chatterbox import ( + OdysseyChatterboxDriver, + OdysseyImageRetrievalChatterboxBackend, + OdysseyInstrumentStatusChatterboxBackend, + OdysseyScanningChatterboxBackend, +) +from pylabrobot.li_cor.odyssey.driver import ( + DEFAULT_GROUP, + OdysseyDriver, + OdysseyScanningParams, +) +from pylabrobot.li_cor.odyssey.errors import ( + OdysseyError, + OdysseyImageError, + OdysseyScanError, + OdysseyStatusError, +) +from pylabrobot.li_cor.odyssey.image_retrieval_backend import ( + OdysseyImageRetrievalBackend, +) +from pylabrobot.li_cor.odyssey.instrument_status_backend import ( + OdysseyInstrumentStatusBackend, + OdysseyState, + normalize_state, +) +from pylabrobot.li_cor.odyssey.odyssey import OdysseyClassic +from pylabrobot.li_cor.odyssey.scanning_backend import ( + OdysseyScanningBackend, + StopResult, +) +from pylabrobot.li_cor.odyssey.tagging import ( + DEFAULT_SOFTWARE_TAG, + build_identity_description, + tag_tiff_with_identity, +) + +__all__ = [ + "OdysseyClassic", + "OdysseyDriver", + "OdysseyScanningParams", + "OdysseyScanningBackend", + "OdysseyImageRetrievalBackend", + "OdysseyInstrumentStatusBackend", + "OdysseyChatterboxDriver", + "OdysseyScanningChatterboxBackend", + "OdysseyImageRetrievalChatterboxBackend", + "OdysseyInstrumentStatusChatterboxBackend", + "OdysseyError", + "OdysseyScanError", + "OdysseyImageError", + "OdysseyStatusError", + "OdysseyState", + "normalize_state", + "StopResult", + "DEFAULT_GROUP", + "DEFAULT_SOFTWARE_TAG", + "build_identity_description", + "tag_tiff_with_identity", +] diff --git a/pylabrobot/li_cor/odyssey/chatterbox.py b/pylabrobot/li_cor/odyssey/chatterbox.py new file mode 100644 index 00000000000..69b0082c72f --- /dev/null +++ b/pylabrobot/li_cor/odyssey/chatterbox.py @@ -0,0 +1,201 @@ +"""Chatterbox path for the Odyssey Classic — no instrument required. + +Three backend-tier chatterbox classes (one per capability) share an +:class:`_OdysseyChatterboxState` object that simulates the +instrument's state machine and stored scans. A minimal +:class:`OdysseyChatterboxDriver` overrides ``setup`` / ``stop`` to +no-ops; it exists only because :class:`pylabrobot.device.Device` +requires a :class:`Driver` instance — the chatterbox backends do +not call it. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import List, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.scanning.image_retrieval import ImageRetrievalBackend +from pylabrobot.capabilities.scanning.instrument_status import ( + InstrumentStatusBackend, + InstrumentStatusReading, +) +from pylabrobot.capabilities.scanning.scanning import ScanningBackend +from pylabrobot.serializer import SerializableMixin + +from .driver import DEFAULT_GROUP, OdysseyDriver, OdysseyScanningParams +from .instrument_status_backend import OdysseyState + +logger = logging.getLogger(__name__) + + +class _OdysseyChatterboxState: + """Shared mutable state for the three chatterbox backends.""" + + def __init__(self) -> None: + self.scanner_state: OdysseyState = "Idle" + self.progress: float = 0.0 + self.lid_open: bool = False + self.current_user: str = "" + self.current_scan_name: str = "" + self.current_group: str = "" + self.configured: bool = False + self.stop_was_partial: bool = False + # Pre-seed the default working group so the chatterbox mirrors + # the lab instrument's name space. + self.scans: dict[str, dict[str, bytes]] = { + DEFAULT_GROUP: { + "test_scan": b"CHATTERBOX_TIFF_DATA_700nm", + }, + } + + +class OdysseyChatterboxDriver(OdysseyDriver): + """No-op driver for chatterbox runs. + + Bypasses the OdysseyDriver constructor's credential check and + overrides ``setup`` / ``stop`` so a Device can be wired with this + driver + chatterbox backends without contacting any instrument. + """ + + def __init__(self) -> None: + # Skip OdysseyDriver.__init__ — it requires real credentials. + # Call Driver.__init__ directly for instance-set registration. + OdysseyDriver.__bases__[0].__init__(self) + self._host = "chatterbox" + self._port = 0 + self._timeout_seconds = 0.0 + self._username = "" + self._password = "" + self._base_url = "" + self._auth = None + self._timeout = None + self._session = None + self._group = DEFAULT_GROUP + self._last_status_html = "" + self._last_status_http = 0 + self._last_configure_url = "" + self._last_configure_http = 0 + self._last_configure_body = "" + + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: + return None + + async def stop(self) -> None: + return None + + def serialize(self) -> dict: + return {"type": self.__class__.__name__} + + +class OdysseyScanningChatterboxBackend(ScanningBackend): + """Chatterbox scanning backend — drives state with simulated progress.""" + + def __init__(self, state: _OdysseyChatterboxState) -> None: + super().__init__() + self._state = state + + async def configure( + self, backend_params: Optional[SerializableMixin] = None + ) -> None: + params = ( + backend_params if isinstance(backend_params, OdysseyScanningParams) + else OdysseyScanningParams() + ) + self._state.configured = True + self._state.current_scan_name = params.name + self._state.current_group = params.group + self._state.scanner_state = "Configured" + self._state.stop_was_partial = False + logger.info("Chatterbox configured scan: %s", params.name) + + async def start(self) -> None: + if not self._state.configured: + raise RuntimeError("Chatterbox scan not configured") + self._state.scanner_state = "Scanning" + self._state.progress = 0.0 + for i in range(0, 101, 10): + if self._state.scanner_state != "Scanning": + return + self._state.progress = float(i) + await asyncio.sleep(0.05) + self._state.scanner_state = "Completed" + self._state.progress = 100.0 + self._save_scan(partial=False) + self._state.configured = False + + async def stop(self) -> None: + """Graceful stop — write a partial TIFF, transition to Stopped.""" + if self._state.scanner_state == "Scanning": + self._save_scan(partial=True) + self._state.stop_was_partial = True + self._state.scanner_state = "Stopped" + self._state.configured = False + + async def pause(self) -> None: + self._state.scanner_state = "Paused" + + async def cancel(self) -> None: + self._state.scanner_state = "Idle" + self._state.progress = 0.0 + self._state.configured = False + self._state.stop_was_partial = False + + def _save_scan(self, partial: bool) -> None: + group = self._state.current_group or DEFAULT_GROUP + name = self._state.current_scan_name or "scan" + if group not in self._state.scans: + self._state.scans[group] = {} + payload = ( + b"CHATTERBOX_PARTIAL_TIFF_DATA" if partial + else b"CHATTERBOX_TIFF_DATA" + ) + self._state.scans[group][name] = payload + + +class OdysseyImageRetrievalChatterboxBackend(ImageRetrievalBackend): + """Chatterbox image retrieval — reads from the shared state.""" + + def __init__(self, state: _OdysseyChatterboxState) -> None: + super().__init__() + self._state = state + + async def list_groups(self) -> List[str]: + return list(self._state.scans.keys()) + + async def list_scans(self, group: str) -> List[str]: + return list(self._state.scans.get(group, {}).keys()) + + async def download(self, group: str, scan_name: str) -> bytes: + scans = self._state.scans.get(group, {}) + if scan_name not in scans: + raise FileNotFoundError( + f"Scan '{scan_name}' not found in group '{group}'" + ) + return scans[scan_name] + + +class OdysseyInstrumentStatusChatterboxBackend(InstrumentStatusBackend): + """Chatterbox status — reflects the shared state.""" + + def __init__(self, state: _OdysseyChatterboxState) -> None: + super().__init__() + self._state = state + + async def read_status(self) -> InstrumentStatusReading: + return InstrumentStatusReading( + state=self._state.scanner_state, + current_user=self._state.current_user, + progress=self._state.progress, + time_remaining="", + lid_open=self._state.lid_open, + ) + + +__all__ = [ + "OdysseyChatterboxDriver", + "OdysseyScanningChatterboxBackend", + "OdysseyImageRetrievalChatterboxBackend", + "OdysseyInstrumentStatusChatterboxBackend", +] diff --git a/pylabrobot/li_cor/odyssey/driver.py b/pylabrobot/li_cor/odyssey/driver.py new file mode 100644 index 00000000000..e6d7587f6a6 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/driver.py @@ -0,0 +1,786 @@ +"""LI-COR Odyssey Classic HTTP driver. + +The Odyssey Classic (model 9120) runs an embedded Linux web server +(Apache/1.3.27 + mod_perl/1.23 on Red Hat Linux) that serves Perl CGI +scripts. This driver wraps an aiohttp session with HTTP Basic Auth +and provides typed methods for each endpoint. + +API reverse-engineered from HAR captures of the browser interface. + +Endpoints: + Scan setup: + POST /scanapp/scan/nonjava/configure.pl — configure scan parameters + GET /scanapp/scan/nonjava/command.pl — ?action=start|stop|pause|cancel + GET /scanapp/scan/nonjava/console.pl — scan console page + GET /scanapp/scan/nonjava/initializing.pl — ?scan=...&scangroup=...&timeout=... + GET /scanapp/scan/nonjava/time.pl — scan time estimate + Imaging: + GET /scanapp/imaging/nonjava/info.pl — scan progress + POST /scanapp/imaging/nonjava/openimage.pl — render JPEG preview + GET /scan/image?xml= — fetch JPEG preview + GET /scan/image/-.tif?xml= — download raw TIFF + GET /scanapp/imaging/nonjava/savelog.pl — download scan log + Status: + GET /scanapp/util/status/ — instrument status page + POST /scanapp/util/status/status — stop scan from status page + Admin: + POST /scanapp/admin/admin/index — ?action=InitiateShutdown + +Auth: HTTP Basic Auth, realm "LICOR-Odyssey". +Transport: TCP/IP, 10/100Base-T Ethernet. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +from dataclasses import dataclass +from typing import Any, Optional +from urllib.parse import quote + +import aiohttp + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Driver + +from .errors import OdysseyImageError, OdysseyScanError + +logger = logging.getLogger(__name__) + + +# Transport errors worth retrying — connection-level transients only. +# Server-level failures (4xx / 5xx) are NOT retried. +_RETRYABLE_EXCEPTIONS = ( + aiohttp.ClientConnectionError, + asyncio.TimeoutError, + ConnectionResetError, + OSError, +) +_HTTP_RETRY_ATTEMPTS = 3 +_HTTP_RETRY_DELAY = 0.25 + +_SCAN_BASE = "/scanapp/scan/nonjava" +_IMAGE_BASE = "/scanapp/imaging/nonjava" +_STATUS_BASE = "/scanapp/util/status" + +# Hardware protocol invariants — contracts with the Odyssey Classic +# firmware 2.1.12. Do NOT change without re-verifying against the unit. +_CONFIGURE_URL_PATH = f"{_SCAN_BASE}/configure.pl" +_CHANNEL_SENTINEL = "x" # hidden 'channel' field must be literal "x" +_INIT_POLL_STEPS = 7 # full 7→1 countdown after configure +DEFAULT_GROUP = "odyssey" # default operational group + +# Environment variables for HTTP Basic Auth credentials. +_CRED_ENV_USER = "ODYSSEY_USER" +_CRED_ENV_PASS = "ODYSSEY_PASS" + + +@dataclass +class OdysseyScanningParams(BackendParams): + """Scan parameters for the Odyssey Classic. + + Field names align with the configure.pl form, captured from the + browser interface. Pass an instance to :meth:`Scanning.configure` — + the backend coerces it and forwards to the driver. + + Attributes: + name: Scan name. Avoid ;/?:@=&<>"#%{}|^~[]. + group: Scan group name (e.g. "odyssey", "public"). + resolution: Resolution in micrometers — "21", "42", "84", "169", + "337", or "preview". + quality: "lowest", "low", "medium", "high", "highest". + intensity_700: 700 nm channel intensity. L2, L1.5, L1, L0.5, + 0.5, 1, 1.5, 2, ..., 10 (in 0.5 steps). + intensity_800: 800 nm channel intensity (same range). + channel_700: Enable 700 nm acquisition. + channel_800: Enable 800 nm acquisition. + origin_x: Scan origin X in cm (0–25). + origin_y: Scan origin Y in cm (0–25). + width: Scan width in cm; origin_x + width <= 25. + height: Scan height in cm; origin_y + height <= 25. + focus: Focus offset in mm (0.0–4.0). 0 for membranes, + ~1.0 for gels, 3.0 for microplates. + comment: Free-text comment. + preset: Preset name to load (empty for manual config). + """ + + name: str = "scan" + group: str = DEFAULT_GROUP + resolution: str = "169" + quality: str = "medium" + intensity_700: str = "5" + intensity_800: str = "5" + channel_700: bool = True + channel_800: bool = True + origin_x: int = 0 + origin_y: int = 0 + width: int = 10 + height: int = 10 + focus: float = 0.0 + comment: str = "" + preset: str = "" + + def to_form_data(self) -> dict[str, str]: + """Render as the form-data dict POSTed to configure.pl. + + Form field names match the HTML form exactly: + channel, scan, scangroup, avail, preset, resolution, quality, + intensity700, intensity800, chan700, chan800, x0, y0, width, + height, x1, y1, focus, comment, prename + """ + data: dict[str, str] = { + "channel": _CHANNEL_SENTINEL, # firmware quirk; must be literal "x" + "scan": self.name, + "scangroup": self.group, + "avail": self.group, + "preset": self.preset, + "resolution": str(self.resolution), + "quality": self.quality, + "intensity700": str(self.intensity_700), + "intensity800": str(self.intensity_800), + "x0": str(self.origin_x), + "y0": str(self.origin_y), + "width": str(self.width), + "height": str(self.height), + "x1": str(self.origin_x + self.width), + "y1": str(self.origin_y + self.height), + "focus": str(self.focus), + "comment": self.comment, + "prename": "", + } + if self.channel_700: + data["chan700"] = "chan700" + if self.channel_800: + data["chan800"] = "chan800" + return data + + def to_time_params(self) -> dict[str, str]: + """Query params for the time.pl scan-time estimate.""" + return { + "resolution": str(self.resolution), + "quality": self.quality, + "x0": str(self.origin_x), + "y0": str(self.origin_y), + "x1": str(self.origin_x + self.width), + "y1": str(self.origin_y + self.height), + } + + +def _tiff_xml(group: str, scan_name: str, channel: int) -> str: + """Build the XML query string for TIFF download.""" + return ( + f"" + f"{group}" + f"{scan_name}" + f"tiff" + f"{channel}" + f"0000" + f"" + ) + + +def _jpeg_xml( + group: str, + scan_name: str, + contrast_700: int = 5, + contrast_800: int = 5, + channels: str = "700 800", + background: str = "black", + clip: tuple[int, int, int, int] = (0, 0, 0, 0), + vflip: bool = True, + hflip: bool = True, + zoom: int = 1, +) -> str: + """Build the XML query string for JPEG preview.""" + x0, x1, y0, y1 = clip + return ( + f"" + f"{group}" + f"{scan_name}" + f"{zoom}" + f"{contrast_700}" + f"{contrast_800}" + f"{channels}" + f"{background}" + f"{x0}{x1}{y0}{y1}" + f"{'true' if vflip else 'false'}" + f"{'true' if hflip else 'false'}" + f"" + ) + + +class OdysseyDriver(Driver): + """Driver (transport) for the LI-COR Odyssey Classic infrared imager. + + Wraps an aiohttp.ClientSession with HTTP Basic Auth. The capability + backends share a single OdysseyDriver instance. + + Server: Apache/1.3.27 (Unix) (Red-Hat/Linux) mod_perl/1.23 + Auth realm: LICOR-Odyssey + """ + + def __init__( + self, + host: str, + username: str, + password: str, + port: int = 80, + timeout: float = 60.0, + group: str = DEFAULT_GROUP, + ) -> None: + super().__init__() + if not username or not password: + raise ValueError( + "OdysseyDriver requires both username and password. " + f"Use OdysseyDriver.from_env() to read them from " + f"{_CRED_ENV_USER}/{_CRED_ENV_PASS}." + ) + self._host = host + self._port = port + self._timeout_seconds = timeout + self._username = username + self._password = password + self._base_url = f"http://{host}:{port}" + self._auth = aiohttp.BasicAuth(username, password) + self._timeout = aiohttp.ClientTimeout(total=timeout) + self._session: Optional[aiohttp.ClientSession] = None + self._group = group + # Diagnostic caches — populated by methods, exposed through helpers. + self._last_status_html: str = "" + self._last_status_http: int = 0 + self._last_configure_url: str = "" + self._last_configure_http: int = 0 + self._last_configure_body: str = "" + # Never log the password. + logger.info( + "OdysseyDriver initialised: host=%s group=%s %s=%s", + host, group, _CRED_ENV_USER, username, + ) + + def serialize(self) -> dict: + return { + **super().serialize(), + "host": self._host, + "port": self._port, + "timeout": self._timeout_seconds, + "group": self._group, + } + + @classmethod + def from_env( + cls, + host: Optional[str] = None, + port: int = 80, + timeout: float = 60.0, + group: str = DEFAULT_GROUP, + ) -> "OdysseyDriver": + """Construct from ODYSSEY_USER / ODYSSEY_PASS environment variables. + + Raises ValueError if either is missing — the driver does not silently + fall back to default credentials. If ``host`` is None, ODYSSEY_HOST is + read from the environment too. + """ + username = os.environ.get(_CRED_ENV_USER, "") + password = os.environ.get(_CRED_ENV_PASS, "") + if not username or not password: + missing = [ + name for name, val in ( + (_CRED_ENV_USER, username), + (_CRED_ENV_PASS, password), + ) if not val + ] + raise ValueError( + f"Missing required environment variable(s): " + f"{', '.join(missing)}." + ) + if host is None: + host = os.environ.get("ODYSSEY_HOST", "") + if not host: + raise ValueError("No host provided and ODYSSEY_HOST is unset.") + return cls( + host=host, username=username, password=password, + port=port, timeout=timeout, group=group, + ) + + @property + def group(self) -> str: + return self._group + + @property + def base_url(self) -> str: + return self._base_url + + async def setup(self, backend_params: Optional[BackendParams] = None) -> None: + """Open the HTTP session and verify connectivity + auth. + + ``Connection: close`` is forced on every request: the Odyssey's + embedded Apache 1.3.27 doesn't always handle keep-alive cleanly + when a second request arrives before the first response has been + fully consumed. Closing per request is a couple of ms slower but + eliminates the inter-request races we see in the field. + """ + self._session = aiohttp.ClientSession( + auth=self._auth, + timeout=self._timeout, + headers={"Connection": "close"}, + ) + # Verify connectivity — the home page is unprotected. + async with self._session.get(self._base_url) as resp: + if resp.status != 200: + raise ConnectionError( + f"Cannot reach Odyssey at {self._base_url} (HTTP {resp.status})" + ) + # Verify auth — the scan page requires login. + url = f"{self._base_url}{_SCAN_BASE}/" + async with self._session.get(url) as resp: + if resp.status == 401: + raise ConnectionError( + "Authentication failed — check username/password " + "(realm: LICOR-Odyssey)" + ) + logger.info("Connected to Odyssey at %s", self._base_url) + + async def stop(self) -> None: + """Close the HTTP session.""" + if self._session is not None: + await self._session.close() + self._session = None + + def _check_session(self) -> aiohttp.ClientSession: + if self._session is None: + raise RuntimeError("OdysseyDriver not set up") + return self._session + + # -- Scan control -------------------------------------------------------- + + async def configure_scan(self, params: OdysseyScanningParams) -> str: + """POST scan parameters to configure.pl. + + On success: 302 redirect to initializing.pl (7 s countdown). + On error (scanner busy / name collision): 200 with HTML error page. + + The initializing.pl countdown takes ~7 seconds as the instrument + configures the DSP, laser voltages, and motor positions. + """ + session = self._check_session() + url = f"{self._base_url}{_CONFIGURE_URL_PATH}" + form_data = params.to_form_data() + logger.info( + "Configuring scan: name=%s group=%s res=%s quality=%s", + params.name, params.group, params.resolution, params.quality, + ) + logger.debug("Form data: %s", form_data) + + last_exc: Optional[Exception] = None + for attempt in range(_HTTP_RETRY_ATTEMPTS): + try: + async with session.post( + url, data=form_data, allow_redirects=False + ) as resp: + body = await resp.text() + self._last_configure_url = url + self._last_configure_http = resp.status + self._last_configure_body = body + logger.info( + "POST %s → HTTP %d, body length %d", + url, resp.status, len(body), + ) + if resp.status == 302: + redirect = resp.headers.get("Location", "") + logger.info("configure.pl redirect → %s", redirect) + # Follow the redirect — this triggers hardware initialization. + if redirect: + redirect_url = ( + redirect if redirect.startswith("http") + else f"{self._base_url}{redirect}" + ) + async with session.get( + redirect_url, allow_redirects=False, + ) as init_resp: + logger.info( + "Followed redirect → HTTP %d", init_resp.status, + ) + return body + # 4xx/5xx — server said no. + if resp.status >= 400: + raise OdysseyScanError( + f"Scanner rejected configuration: " + f"POST {url} → HTTP {resp.status}." + ) + # 2xx with an explicit error page in the body. The firmware + # embeds a structured ``message`` + # block; pull the short label and message out so callers see + # "Scan already exists: foo already exists." instead of HTML. + if "busy" in body.lower() or "Error" in body: + m = re.search( + r'\s*(.*?)\s*', + body, re.IGNORECASE | re.DOTALL, + ) + if m: + short = m.group(1).strip() + detail = re.sub(r"\s+", " ", m.group(2)).strip() + raise OdysseyScanError( + f"Scanner rejected configuration — {short}: {detail}" + ) + raise OdysseyScanError( + f"Scanner rejected configuration: {body[:500]}" + ) + return body + except _RETRYABLE_EXCEPTIONS as exc: + last_exc = exc + if attempt < _HTTP_RETRY_ATTEMPTS - 1: + logger.warning( + "configure_scan attempt %d/%d failed (%s) — retrying", + attempt + 1, _HTTP_RETRY_ATTEMPTS, exc, + ) + await asyncio.sleep(_HTTP_RETRY_DELAY) + continue + raise OdysseyScanError( + f"configure_scan failed after {_HTTP_RETRY_ATTEMPTS} attempts: " + f"{last_exc}" + ) from last_exc + + async def wait_initialization( + self, + scan_name: str, + group: str, + timeout_steps: int = _INIT_POLL_STEPS, + ) -> None: + """Wait through the 7→1 initialization countdown. + + The instrument prepares motors and laser voltages during this + window. Skipping the poll skips hardware prep and produces invalid + scans. + """ + session = self._check_session() + logger.info("Waiting %d seconds for hardware initialization...", + timeout_steps) + for t in range(timeout_steps, 0, -1): + url = f"{self._base_url}{_SCAN_BASE}/initializing.pl" + params = {"scan": scan_name, "scangroup": group, "timeout": str(t)} + logger.info("initializing.pl?timeout=%d", t) + async with session.get(url, params=params, allow_redirects=False) as resp: + if resp.status == 302 and t <= 1: + redirect = resp.headers.get("Location", "") + logger.info("Initialization complete → %s", redirect) + if redirect: + redirect_url = redirect if redirect.startswith("http") \ + else f"{self._base_url}{redirect}" + async with session.get( + redirect_url, allow_redirects=True + ) as _: + pass + return + await asyncio.sleep(1) + logger.info("Initialization wait complete") + + async def start_scan(self) -> str: + """Send start command. Scanner must be configured first.""" + return await self._scan_command("start") + + async def stop_scan(self) -> str: + """Send stop command (finishes scan, saves files).""" + return await self._scan_command("stop") + + async def pause_scan(self) -> str: + """Send pause command.""" + return await self._scan_command("pause") + + async def cancel_scan(self) -> str: + """Send cancel command (aborts scan, no save).""" + return await self._scan_command("cancel") + + async def _scan_command(self, action: str) -> str: + """Send command.pl?action=. + + On success: 302 redirect to console.pl. + On error (not configured): 200 with structured Error block. + """ + session = self._check_session() + url = f"{self._base_url}{_SCAN_BASE}/command.pl" + params = {"action": action} + logger.info("Scan command: %s", action) + + async with session.get(url, params=params, allow_redirects=False) as resp: + body = await resp.text() + logger.info("command.pl?action=%s → HTTP %d, body: %s", + action, resp.status, body[:300]) + if resp.status == 302: + return body + if " str: + """Get estimated scan time from time.pl. + + Returns the time string, e.g. "0 hours 2 minutes 15 seconds". + """ + session = self._check_session() + url = f"{self._base_url}{_SCAN_BASE}/time.pl" + async with session.get(url, params=params.to_time_params()) as resp: + html = await resp.text() + match = re.search( + r"Estimated Scan Time.*?(\d+ hours? \d+ minutes? \d+ seconds?)", + html, re.DOTALL | re.IGNORECASE, + ) + return match.group(1) if match else html + + # -- Status -------------------------------------------------------------- + + async def get_status(self) -> dict[str, str]: + """Fetch and parse the instrument status page. + + Returns dict with keys: state, current_user, progress, + time_remaining, lid_status. The most recent raw HTML response is + cached on ``self._last_status_html`` for diagnostic use. + """ + session = self._check_session() + url = f"{self._base_url}{_STATUS_BASE}/" + async with session.get(url) as resp: + html = await resp.text() + self._last_status_http = resp.status + self._last_status_html = html + parsed = self._parse_status_html(html) + if parsed["state"] == "Unknown": + logger.warning( + "Status parser missed 'Scanner Status' (HTTP %s, %d bytes).", + self._last_status_http, len(html), + ) + return parsed + + async def stop_from_status(self) -> str: + """Stop the scanner from the status/utilities page. + + The path to release a paused/stuck scanner without going through + the scan console. + """ + session = self._check_session() + url = f"{self._base_url}{_STATUS_BASE}/status" + data = {"formContext": "1", "action": "Stop"} + async with session.post(url, data=data) as resp: + return await resp.text() + + async def get_scan_progress( + self, scan_name: str, group: str + ) -> dict[str, str]: + """Fetch scan progress from the imaging info panel. + + Returns dict with: dimensions, file_size, time_left. + """ + session = self._check_session() + url = f"{self._base_url}{_IMAGE_BASE}/info.pl" + params = { + "scan": scan_name, + "group": group, + "update": "Off", + "console": "yes", + } + async with session.get(url, params=params) as resp: + html = await resp.text() + return self._parse_info_html(html) + + # -- Image retrieval ----------------------------------------------------- + + async def download_tiff( + self, group: str, scan_name: str, channel: int + ) -> bytes: + """Download a raw TIFF for one channel (700 or 800). + + URL: /scan/image/-.tif?xml= + + Retries up to 3 times on transient connection errors with a 0.25 s + backoff. HTTP 4xx/5xx fail immediately. Verifies the downloaded + byte count matches Content-Length when present. + """ + session = self._check_session() + xml = _tiff_xml(group, scan_name, channel) + url = ( + f"{self._base_url}/scan/image/" + f"{quote(scan_name)}-{channel}.tif" + ) + logger.info("Downloading TIFF: %s channel %d", scan_name, channel) + + last_exc: Optional[Exception] = None + for attempt in range(_HTTP_RETRY_ATTEMPTS): + try: + async with session.get(url, params={"xml": xml}) as resp: + if resp.status != 200: + raise OdysseyImageError( + f"TIFF download failed for {scan_name}-{channel}: " + f"HTTP {resp.status}" + ) + expected = resp.content_length # may be None + data = await resp.read() + if expected is not None and len(data) != expected: + raise IOError( + f"Truncated TIFF: got {len(data)} bytes, " + f"expected {expected} (Content-Length)" + ) + logger.info( + "Downloaded %s-%d.tif: %d bytes", + scan_name, channel, len(data), + ) + return data + except _RETRYABLE_EXCEPTIONS as exc: + last_exc = exc + if attempt < _HTTP_RETRY_ATTEMPTS - 1: + logger.warning( + "TIFF download attempt %d/%d for %s-%d failed (%s) — retrying", + attempt + 1, _HTTP_RETRY_ATTEMPTS, + scan_name, channel, exc, + ) + await asyncio.sleep(_HTTP_RETRY_DELAY) + continue + raise OdysseyImageError( + f"TIFF download for {scan_name}-{channel} failed after " + f"{_HTTP_RETRY_ATTEMPTS} attempts: {last_exc}" + ) from last_exc + + async def get_jpeg_preview( + self, + group: str, + scan_name: str, + contrast_700: int = 5, + contrast_800: int = 5, + channels: str = "700 800", + background: str = "black", + ) -> bytes: + """Fetch a JPEG preview with display settings applied server-side.""" + session = self._check_session() + xml = _jpeg_xml( + group, scan_name, + contrast_700=contrast_700, + contrast_800=contrast_800, + channels=channels, + background=background, + ) + url = f"{self._base_url}/scan/image" + async with session.get(url, params={"xml": xml}) as resp: + if resp.status != 200: + raise OdysseyImageError( + f"JPEG preview failed: HTTP {resp.status}" + ) + return await resp.read() + + async def download_scan_log(self, group: str, scan_name: str) -> str: + """Download the scan log for a completed scan.""" + session = self._check_session() + url = f"{self._base_url}{_IMAGE_BASE}/savelog.pl" + params = {"group": group, "scan": scan_name} + async with session.get(url, params=params) as resp: + return await resp.text() + + async def list_scan_groups(self) -> str: + """Fetch the scan setup page HTML. + + Contains a .""" + pattern = ( + rf']*name=["\']?{select_name}["\']?[^>]*>' + r"(.*?)" + ) + match = re.search(pattern, html, re.DOTALL | re.IGNORECASE) + if not match: + return [] + return re.findall( + r']*value=["\']?([^"\'>\s]+)', + match.group(1), + re.IGNORECASE, + ) diff --git a/pylabrobot/li_cor/odyssey/errors.py b/pylabrobot/li_cor/odyssey/errors.py new file mode 100644 index 00000000000..18e26b64baa --- /dev/null +++ b/pylabrobot/li_cor/odyssey/errors.py @@ -0,0 +1,54 @@ +"""Odyssey driver exceptions — dual-base for capability + vendor reach. + +Each backend-level error inherits from BOTH the vendor's driver-level +exception (:class:`OdysseyError`) AND the capability-generic exception +(:class:`ScanningError`, :class:`ImageRetrievalError`, +:class:`InstrumentStatusError`). A single raise is then catchable on +either axis:: + + try: + await odyssey.scanning.start() + except ScanningError: + ... # capability-generic recovery (works for any scanning backend) + except OdysseyError: + ... # vendor-specific debugging +""" + +from __future__ import annotations + +from pylabrobot.capabilities.scanning.image_retrieval import ImageRetrievalError +from pylabrobot.capabilities.scanning.instrument_status import InstrumentStatusError +from pylabrobot.capabilities.scanning.scanning import ScanningError + + +class OdysseyError(Exception): + """Base exception for the LI-COR Odyssey Classic driver. + + Raised at the connection / transport layer for protocol or HTTP + failures. Capability-specific raises use a subclass that also + inherits from the matching capability-generic exception. + """ + + +class OdysseyScanError(OdysseyError, ScanningError): + """Odyssey scanning capability failed. + + Catchable as either :class:`OdysseyError` (vendor-specific) or + :class:`ScanningError` (capability-generic). + """ + + +class OdysseyImageError(OdysseyError, ImageRetrievalError): + """Odyssey image retrieval failed (download / list / preview).""" + + +class OdysseyStatusError(OdysseyError, InstrumentStatusError): + """Odyssey status read failed.""" + + +__all__ = [ + "OdysseyError", + "OdysseyScanError", + "OdysseyImageError", + "OdysseyStatusError", +] diff --git a/pylabrobot/li_cor/odyssey/image_retrieval_backend.py b/pylabrobot/li_cor/odyssey/image_retrieval_backend.py new file mode 100644 index 00000000000..0eea12c8285 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/image_retrieval_backend.py @@ -0,0 +1,69 @@ +"""Odyssey image retrieval backend — concrete ImageRetrievalBackend over HTTP. + +Downloads scan TIFFs from the instrument's internal storage via the +/scan/image endpoint with XML query parameters, plus the scan-list +HTML at /scanapp/scan/nonjava/. +""" + +from __future__ import annotations + +import logging +from typing import List + +from pylabrobot.capabilities.scanning.image_retrieval import ImageRetrievalBackend + +from .driver import OdysseyDriver + +logger = logging.getLogger(__name__) + + +class OdysseyImageRetrievalBackend(ImageRetrievalBackend): + """Concrete image retrieval backend for the LI-COR Odyssey Classic.""" + + def __init__(self, driver: OdysseyDriver) -> None: + super().__init__() + self._driver = driver + + async def list_groups(self) -> List[str]: + html = await self._driver.list_scan_groups() + return self._driver.parse_select_options(html, "avail") + + async def list_scans(self, group: str) -> List[str]: + html = await self._driver.list_scan_groups() + return self._driver.parse_select_options(html, "preset") + + async def download(self, group: str, scan_name: str) -> bytes: + """Download both 700 and 800 nm TIFFs concatenated.""" + ch700 = await self._driver.download_tiff(group, scan_name, 700) + ch800 = await self._driver.download_tiff(group, scan_name, 800) + return ch700 + ch800 + + # -- Vendor extensions --------------------------------------------------- + + async def download_channel( + self, group: str, scan_name: str, channel: int + ) -> bytes: + """Download a single channel TIFF (700 or 800).""" + return await self._driver.download_tiff(group, scan_name, channel) + + async def get_preview( + self, + group: str, + scan_name: str, + contrast_700: int = 5, + contrast_800: int = 5, + channels: str = "700 800", + background: str = "black", + ) -> bytes: + """Fetch a JPEG preview rendered by the instrument.""" + return await self._driver.get_jpeg_preview( + group, scan_name, + contrast_700=contrast_700, + contrast_800=contrast_800, + channels=channels, + background=background, + ) + + async def download_scan_log(self, group: str, scan_name: str) -> str: + """Download the scan log for a completed scan.""" + return await self._driver.download_scan_log(group, scan_name) diff --git a/pylabrobot/li_cor/odyssey/instrument_status_backend.py b/pylabrobot/li_cor/odyssey/instrument_status_backend.py new file mode 100644 index 00000000000..f2b48131866 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/instrument_status_backend.py @@ -0,0 +1,89 @@ +"""Odyssey instrument status backend. + +Polls /scanapp/util/status/ and parses the HTML, normalising the raw +state string onto a canonical ``OdysseyState`` literal so downstream +consumers can rely on exact string equality. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from pylabrobot.capabilities.scanning.instrument_status import ( + InstrumentStatusBackend, + InstrumentStatusReading, +) + +from .driver import OdysseyDriver + +logger = logging.getLogger(__name__) + + +# Canonical Odyssey state literal exposed in :class:`InstrumentStatusReading`. +OdysseyState = Literal[ + "Idle", "Configured", "Initializing", "Scanning", + "Paused", "Stopped", "Completed", "Failed", +] + +# Map raw instrument strings (lowercase) to the canonical literal. +_STATE_MAP: dict[str, OdysseyState] = { + "idle": "Idle", + "configured": "Configured", + "initializing": "Initializing", + "scanning": "Scanning", + "escanning": "Scanning", # Odyssey firmware quirk + "paused": "Paused", + "stopped": "Stopped", + "completed": "Completed", + "failed": "Failed", + "error": "Failed", +} + + +def normalize_state(raw: str) -> OdysseyState: + """Map a raw instrument state string to the canonical literal. + + Unrecognized values fall back to ``"Idle"`` (rather than ``"Failed"``) + so a parser miss does not lock the UI into a phantom error state. + Callers needing fresh-vs-stale terminal-state semantics should + combine this with a transition-out guard. + """ + key = (raw or "").strip().lower() + if key in _STATE_MAP: + return _STATE_MAP[key] + logger.warning( + "Unknown instrument state %r — mapping to 'Idle' (parser miss?)", raw + ) + return "Idle" + + +class OdysseyInstrumentStatusBackend(InstrumentStatusBackend): + """Concrete status backend for the LI-COR Odyssey Classic.""" + + def __init__(self, driver: OdysseyDriver) -> None: + super().__init__() + self._driver = driver + + async def read_status(self) -> InstrumentStatusReading: + raw = await self._driver.get_status() + state = normalize_state(raw["state"]) + try: + progress = float(raw.get("progress") or 0.0) + except (ValueError, TypeError): + progress = 0.0 + lid_status = raw.get("lid_status", "closed") + return InstrumentStatusReading( + state=state, + current_user=raw.get("current_user", ""), + progress=progress, + time_remaining=raw.get("time_remaining", ""), + lid_open=lid_status.lower() != "closed", + ) + + # -- Vendor extensions --------------------------------------------------- + + async def force_stop(self) -> None: + """Stop the scanner via the status page.""" + logger.warning("Force-stopping scanner from status page") + await self._driver.stop_from_status() diff --git a/pylabrobot/li_cor/odyssey/odyssey.py b/pylabrobot/li_cor/odyssey/odyssey.py new file mode 100644 index 00000000000..32b721750d2 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/odyssey.py @@ -0,0 +1,210 @@ +"""LI-COR Odyssey Classic device class. + +Wires the Odyssey driver and three capability backends. The default +mode is real hardware (HTTP); pass ``chatterbox=True`` for an +in-memory chatterbox path that runs anywhere — useful for CI and +notebooks. + +Usage:: + + import asyncio + from pylabrobot.li_cor.odyssey import OdysseyClassic, OdysseyScanningParams + + async def main(): + # Chatterbox — no instrument required. + odyssey = OdysseyClassic(chatterbox=True) + async with odyssey: + await odyssey.scanning.configure(OdysseyScanningParams(name="demo")) + await odyssey.scanning.start() + # ... poll status, then download: + tiff = await odyssey.images.download("odyssey", "demo") + print(f"{len(tiff)} bytes") + + asyncio.run(main()) + +Real hardware reads ODYSSEY_USER / ODYSSEY_PASS from the environment:: + + odyssey = OdysseyClassic(host="169.254.206.190") # credentials from env + async with odyssey: + ... +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Awaitable, Callable, Optional + +from pylabrobot.capabilities.scanning.image_retrieval import ImageRetrieval +from pylabrobot.capabilities.scanning.instrument_status import ( + InstrumentStatus, + InstrumentStatusReading, +) +from pylabrobot.capabilities.scanning.scanning import Scanning +from pylabrobot.device import Device + +from .chatterbox import ( + OdysseyChatterboxDriver, + OdysseyImageRetrievalChatterboxBackend, + OdysseyInstrumentStatusChatterboxBackend, + OdysseyScanningChatterboxBackend, + _OdysseyChatterboxState, +) +from .driver import ( + DEFAULT_GROUP, + OdysseyDriver, + OdysseyScanningParams, +) +from .image_retrieval_backend import OdysseyImageRetrievalBackend +from .instrument_status_backend import ( + OdysseyInstrumentStatusBackend, + normalize_state, +) +from .scanning_backend import OdysseyScanningBackend + +logger = logging.getLogger(__name__) + +# Terminal states that end a scan session. ``Idle`` is included +# because the real Odyssey transitions back to Idle when a scan +# finishes — it never reports ``Completed``. The fresh-terminal-state +# guard in :meth:`OdysseyClassic.wait_until_done` makes Idle safe. +_TERMINAL_STATES = frozenset({"Idle", "Completed", "Stopped", "Failed"}) + + +class OdysseyClassic(Device): + """LI-COR Odyssey Classic (model 9120) infrared imaging system. + + Capabilities: + scanning: configure and control fluorescence scans. + images: download saved scan TIFFs. + status: poll the instrument's state machine. + + Pass ``chatterbox=True`` for an in-memory test path; otherwise the + device connects to the instrument over HTTP using credentials from + ODYSSEY_USER / ODYSSEY_PASS (or supplied directly). + """ + + def __init__( + self, + host: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + port: int = 80, + timeout: float = 60.0, + group: str = DEFAULT_GROUP, + chatterbox: bool = False, + ) -> None: + if chatterbox: + driver: OdysseyDriver = OdysseyChatterboxDriver() + state = _OdysseyChatterboxState() + scanning_backend = OdysseyScanningChatterboxBackend(state) + image_backend = OdysseyImageRetrievalChatterboxBackend(state) + status_backend = OdysseyInstrumentStatusChatterboxBackend(state) + else: + resolved_host = host or os.environ.get("ODYSSEY_HOST", "") + if not resolved_host: + raise ValueError( + "OdysseyClassic requires a host (or ODYSSEY_HOST env var)." + ) + resolved_user = username or os.environ.get("ODYSSEY_USER", "") + resolved_pass = password or os.environ.get("ODYSSEY_PASS", "") + if not resolved_user or not resolved_pass: + raise ValueError( + "OdysseyClassic requires both username and password " + "(or ODYSSEY_USER / ODYSSEY_PASS env vars)." + ) + driver = OdysseyDriver( + host=resolved_host, + username=resolved_user, + password=resolved_pass, + port=port, + timeout=timeout, + group=group, + ) + scanning_backend = OdysseyScanningBackend(driver) + image_backend = OdysseyImageRetrievalBackend(driver) + status_backend = OdysseyInstrumentStatusBackend(driver) + + super().__init__(driver=driver) + + self.scanning = Scanning(backend=scanning_backend) + self.images = ImageRetrieval(backend=image_backend) + self.status = InstrumentStatus(backend=status_backend) + self._capabilities = [self.scanning, self.images, self.status] + + # -- Convenience helpers ------------------------------------------------- + + async def wait_until_done( + self, + poll_interval: float = 1.0, + timeout: Optional[float] = None, + on_progress: Optional[Callable[[InstrumentStatusReading], None]] = None, + require_fresh: bool = True, + ) -> InstrumentStatusReading: + """Poll status until the instrument reaches a terminal state. + + With ``require_fresh=True`` (default) the method requires the + instrument to transition out of any initial terminal state before + accepting the next terminal as "this scan finished". Without this + guard, a caller racing against a just-completed scan would silently + receive that previous run's status. + + Set ``require_fresh=False`` when the caller knows the wait was + triggered by an action that just initiated a new run — :meth:`scan` + does this since it owns the configure-then-start sequence. + + Returns the final :class:`InstrumentStatusReading`. Raises + :class:`asyncio.TimeoutError` if ``timeout`` elapses first. + """ + loop = asyncio.get_event_loop() + deadline = None if timeout is None else loop.time() + timeout + + initial = await self.status.read_status() + if on_progress is not None: + on_progress(initial) + require_state_change = ( + require_fresh + and normalize_state(initial.state) in _TERMINAL_STATES + ) + + while True: + status = await self.status.read_status() + if on_progress is not None: + on_progress(status) + state = normalize_state(status.state) + if state not in _TERMINAL_STATES: + require_state_change = False + elif not require_state_change: + return status + if deadline is not None and loop.time() > deadline: + raise asyncio.TimeoutError( + f"Scan did not reach a " + f"{'fresh ' if require_fresh else ''}terminal state within " + f"{timeout:.0f} s (last state={status.state!r})" + ) + await asyncio.sleep(poll_interval) + + async def scan( + self, + backend_params: Optional[OdysseyScanningParams] = None, + poll_interval: float = 1.0, + on_progress: Optional[Callable[[InstrumentStatusReading], None]] = None, + ) -> InstrumentStatusReading: + """Configure → Start → wait for completion. Returns the final status. + + One-shot helper for the common notebook flow. Does not download + the result — call ``odyssey.images.download(group, name)`` + afterwards. + """ + await self.scanning.configure(backend_params=backend_params) + await self.scanning.start() + # scan() owns the configure+start sequence — no risk of latching + # onto a previous run's terminal state, so opt out of the + # fresh-terminal guard. Standalone wait_until_done() callers still + # get protection by default. + return await self.wait_until_done( + poll_interval=poll_interval, + on_progress=on_progress, + require_fresh=False, + ) diff --git a/pylabrobot/li_cor/odyssey/scanning_backend.py b/pylabrobot/li_cor/odyssey/scanning_backend.py new file mode 100644 index 00000000000..f8d5df046d8 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/scanning_backend.py @@ -0,0 +1,179 @@ +"""Odyssey scanning backend — concrete ScanningBackend over HTTP. + +Translates the capability's ``configure / start / stop / pause / +cancel`` verbs into the Odyssey CGI sequence: + + configure.pl → initializing.pl (7 s) → command.pl?action=start +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.scanning.scanning import ScanningBackend +from pylabrobot.serializer import SerializableMixin + +from .driver import OdysseyDriver, OdysseyScanningParams +from .errors import OdysseyScanError + +logger = logging.getLogger(__name__) + + +# Max seconds to wait for the instrument to settle at Idle after Stop. +_STOP_IDLE_TIMEOUT_SEC = 15.0 +_STOP_IDLE_POLL_SEC = 1.0 + + +@dataclass +class StopResult: + """Outcome of a graceful Stop. + + ``state`` is "Stopped"; ``partial`` is True if the instrument wrote + any channel TIFFs before the interrupt; ``channels_available`` lists + which channels were written. + """ + + state: str + partial: bool + channels_available: list[int] + + +class OdysseyScanningBackend(ScanningBackend): + """Concrete scanning backend for the LI-COR Odyssey Classic. + + Sends scan parameters via HTTP POST to configure.pl, polls + initializing.pl through the 7→1 countdown, then controls the scan + via command.pl?action=start|stop|pause|cancel. + """ + + def __init__(self, driver: OdysseyDriver) -> None: + super().__init__() + self._driver = driver + self._current_scan: str = "" + self._current_group: str = "" + + def _coerce_params( + self, backend_params: Optional[SerializableMixin] + ) -> OdysseyScanningParams: + """Accept None / OdysseyScanningParams; reject anything else.""" + if backend_params is None: + return OdysseyScanningParams() + if isinstance(backend_params, OdysseyScanningParams): + return backend_params + raise TypeError( + f"OdysseyScanningBackend.configure expects OdysseyScanningParams, " + f"got {type(backend_params).__name__}" + ) + + async def configure( + self, backend_params: Optional[SerializableMixin] = None + ) -> None: + params = self._coerce_params(backend_params) + status = await self._driver.get_status() + if status["state"].lower() == "paused": + logger.warning( + "Scanner is paused from a previous session — releasing first" + ) + await self._driver.stop_from_status() + + await self._driver.configure_scan(params) + self._current_scan = params.name + self._current_group = params.group + + await self._driver.wait_initialization(params.name, params.group) + + async def start(self) -> None: + await self._driver.start_scan() + + async def stop(self) -> None: + """Graceful stop — finish current line, save partial output.""" + await self._driver.stop_scan() + + async def pause(self) -> None: + await self._driver.pause_scan() + + async def cancel(self) -> None: + await self._driver.cancel_scan() + + async def _on_stop(self) -> None: + """Safety on lifecycle stop: cancel any in-flight scan.""" + try: + await self.cancel() + except Exception: + logger.exception("Failed to cancel scan during _on_stop") + + # -- Vendor extensions --------------------------------------------------- + + async def stop_and_save(self) -> StopResult: + """Graceful Stop that saves whatever has been acquired. + + 1. Issue command.pl?action=stop. + 2. Poll status until the instrument reports Idle (bounded by + _STOP_IDLE_TIMEOUT_SEC) — this is what makes the Stop + "auto-return to idle" semantics real. + 3. Probe which channel TIFFs were written. + + Raises :class:`OdysseyScanError` if the instrument does not + settle at Idle within the timeout. + """ + if not self._current_scan or not self._current_group: + await self._driver.stop_scan() + return StopResult( + state="Stopped", partial=False, channels_available=[] + ) + + await self._driver.stop_scan() + + deadline = asyncio.get_event_loop().time() + _STOP_IDLE_TIMEOUT_SEC + while True: + status = await self._driver.get_status() + state = (status.get("state") or "").strip().lower() + if state in ("idle", "stopped"): + break + if asyncio.get_event_loop().time() > deadline: + raise OdysseyScanError( + f"Instrument did not return to Idle within " + f"{_STOP_IDLE_TIMEOUT_SEC:.0f} s after Stop " + f"(last state={status.get('state')!r})" + ) + await asyncio.sleep(_STOP_IDLE_POLL_SEC) + + # Probe channel availability by attempting a small TIFF download + # for each. The instrument writes partial TIFFs at whatever row + # the scan reached; a failed download means the channel produced + # no data (e.g. channel disabled or stop too early). + channels_available: list[int] = [] + for ch in (700, 800): + try: + data = await self._driver.download_tiff( + self._current_group, self._current_scan, ch + ) + if data: + channels_available.append(ch) + except Exception as e: + logger.info("Channel %d not available after Stop: %s", ch, e) + + return StopResult( + state="Stopped", + partial=bool(channels_available), + channels_available=channels_available, + ) + + async def estimate_time( + self, backend_params: Optional[SerializableMixin] = None + ) -> str: + """Return the instrument's estimate for the configured scan.""" + params = self._coerce_params(backend_params) + return await self._driver.estimate_scan_time(params) + + async def get_progress(self) -> dict[str, str]: + """Return current scan progress (dimensions, file_size, time_left).""" + if self._current_scan: + return await self._driver.get_scan_progress( + self._current_scan, self._current_group + ) + return {"dimensions": "", "file_size": "", "time_left": ""} diff --git a/pylabrobot/li_cor/odyssey/tagging.py b/pylabrobot/li_cor/odyssey/tagging.py new file mode 100644 index 00000000000..4f746a460de --- /dev/null +++ b/pylabrobot/li_cor/odyssey/tagging.py @@ -0,0 +1,95 @@ +"""TIFF identity tagging for Odyssey scans. + +Embeds an identity payload (e.g. PIDInst Handle URI, landing page, +friendly name) into standard TIFF tags so a scan lifted out of its +surrounding metadata still resolves back to the instrument it came +from. Identity is supplied as a plain dict — populate per +deployment. + +Tags written: + +- ``270`` ImageDescription — JSON blob with the identity fields plus + optional ``scan_name`` / ``channel`` for self-describing scans. +- ``305`` Software — application name. + +The functions are no-ops when ``identity`` is empty AND no per-call +``scan_name`` / ``channel`` is supplied. They never raise on a parse +or save failure — the original bytes are returned so a download is +never lost to a tagging failure. +""" + +from __future__ import annotations + +import io +import json +import logging +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +DEFAULT_SOFTWARE_TAG = "PyLabRobot Odyssey" + + +def build_identity_description( + identity: Optional[dict[str, Any]] = None, + *, + scan_name: str = "", + channel: Optional[int] = None, + extra: Optional[dict[str, Any]] = None, +) -> str: + """Render the identity payload as a compact JSON string. + + Suitable for TIFF ImageDescription, PNG ``tEXt`` chunks, JSON + sidecars, or anywhere else a self-describing identity blob fits. + """ + payload: dict[str, Any] = dict(identity or {}) + if scan_name: + payload["scan_name"] = scan_name + if channel is not None: + payload["channel"] = channel + if extra: + payload.update(extra) + return json.dumps(payload, separators=(",", ":")) + + +def tag_tiff_with_identity( + raw_bytes: bytes, + identity: Optional[dict[str, Any]] = None, + *, + scan_name: str = "", + channel: Optional[int] = None, + software_tag: str = DEFAULT_SOFTWARE_TAG, +) -> bytes: + """Re-emit a TIFF with the identity payload in tags 270 + 305. + + Returns ``raw_bytes`` unchanged when no identity / scan_name / + channel are supplied (so unconfigured users see no behavior change), + when PIL is not importable, or when the TIFF fails to parse / save. + """ + if not raw_bytes: + return raw_bytes + if not (identity or scan_name or channel is not None): + return raw_bytes + try: + from PIL import Image # type: ignore[import-not-found] + except ImportError: + return raw_bytes + try: + img = Image.open(io.BytesIO(raw_bytes)) + img.load() + except Exception as e: + logger.info("TIFF re-tag skipped (parse failed): %s", e) + return raw_bytes + description = build_identity_description( + identity, scan_name=scan_name, channel=channel, + ) + try: + out = io.BytesIO() + img.save(out, format="TIFF", tiffinfo={ + 270: description, + 305: software_tag, + }) + return out.getvalue() + except Exception as e: + logger.info("TIFF re-tag skipped (save failed): %s", e) + return raw_bytes From f66983db3125ba2a11aaefce6774846d3f941add Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Sun, 3 May 2026 11:45:03 +0200 Subject: [PATCH 2/5] Add DeviceCard for instrument identity / provenance metadata DeviceCard is a machine-readable description attached to a Device via the HasDeviceCard mixin (same shape as HasLoadingTray). Two tiers: a model-base card ships with the device package; deployments populate an instance card with their unit's PIDInst Handle URI, landing page, and friendly name. base.merge(instance) produces the effective deployed card. Wires Odyssey as the first device. ODYSSEY_CLASSIC_BASE carries specs from the operator's manual; OdysseyClassic accepts card=DeviceCard.instance(identity={...}) and exposes the merged card on self.card. tagging.py overload accepts either a DeviceCard or a plain identity dict. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/device_card.py | 165 +++++++++++++++++++++++ pylabrobot/li_cor/odyssey/__init__.py | 2 + pylabrobot/li_cor/odyssey/device_card.py | 69 ++++++++++ pylabrobot/li_cor/odyssey/odyssey.py | 15 ++- pylabrobot/li_cor/odyssey/tagging.py | 40 ++++-- 5 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 pylabrobot/device_card.py create mode 100644 pylabrobot/li_cor/odyssey/device_card.py diff --git a/pylabrobot/device_card.py b/pylabrobot/device_card.py new file mode 100644 index 00000000000..d3ca709c542 --- /dev/null +++ b/pylabrobot/device_card.py @@ -0,0 +1,165 @@ +"""DeviceCard — machine-readable device description. + +A :class:`DeviceCard` is a metadata bag attached to a :class:`Device`. +It carries: + +- **Identity** — persistent identifier (e.g. PIDInst Handle URI), + landing page, friendly name. Empty in the model-base card; each + deployment populates it with its own unit's identity. +- **Capability specs** — operating ranges, supported settings, and + feature flags pulled from the operator's manual. Lets UIs and + validators reason about what a unit can actually do. +- **Connection metadata** — protocol, port, auth method, discovery + mechanism. Configuration the model defines once, used by every + deployment. + +Two-tier design:: + + base = ODYSSEY_CLASSIC_BASE # ships with the device package + instance = DeviceCard.instance(identity={ # per-deployment + "pid": "http://hdl.handle.net/21.11157/psf97-zv353", + "landing_page": "https://b2inst.gwdg.de/records/psf97-zv353", + "name": "Odyssey, Lab 3 (WUR HAP)", + }) + card = base.merge(instance) # effective deployed card + +Devices that carry a card declare the :class:`HasDeviceCard` mixin so +the attribute is discoverable via type checks. Tooling that consumes +identity (TIFF tagging, provenance writers, dataset registration) can +duck-type ``hasattr(device, "card")`` or rely on the mixin. +""" + +from __future__ import annotations + +import copy +import json +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass +class DeviceCard: + """Machine-readable device description with merge and introspection. + + Fields: + name: Friendly model name (e.g. "Odyssey Classic"). + vendor: Vendor name (e.g. "LI-COR Biosciences"). + model: Model number / SKU (e.g. "9120"). + capabilities: Per-capability spec sheet — keys are capability + names (``"scanning"``, ``"image_retrieval"``), values are dicts + of features / specs / ranges. + connection: Connection metadata (protocol, port, auth, network). + identity: Per-unit identity — PID, landing page, friendly name. + Empty in the model-base card; populated at the instance layer. + """ + + name: str = "" + vendor: str = "" + model: str = "" + capabilities: dict[str, dict[str, Any]] = field(default_factory=dict) + connection: dict[str, Any] = field(default_factory=dict) + identity: dict[str, Any] = field(default_factory=dict) + + @classmethod + def instance(cls, **kwargs: Any) -> "DeviceCard": + """Build a partial card for instance-level overrides. + + Use when populating per-deployment identity (PID, landing page) or + overriding a model-base spec for a non-standard unit. + """ + return cls(**kwargs) + + def merge(self, other: "DeviceCard") -> "DeviceCard": + """Deep-merge ``other`` on top of ``self``. Returns a new card. + + Merge rules: + - Scalar fields (name, vendor, model): ``other`` wins if non-empty. + - capabilities: per-capability shallow merge; ``other`` keys + override; new capabilities from ``other`` are added. + - connection, identity: shallow dict merge, ``other`` wins on key + collision. + + Neither input is mutated. + """ + merged_caps = copy.deepcopy(self.capabilities) + for cap_name, cap_data in other.capabilities.items(): + if cap_name in merged_caps: + merged_caps[cap_name].update(cap_data) + else: + merged_caps[cap_name] = copy.deepcopy(cap_data) + + return DeviceCard( + name=other.name or self.name, + vendor=other.vendor or self.vendor, + model=other.model or self.model, + capabilities=merged_caps, + connection={**self.connection, **other.connection}, + identity={**self.identity, **other.identity}, + ) + + def has(self, capability: str) -> bool: + """Return True if the card declares ``capability``.""" + return capability in self.capabilities + + def get(self, capability: str, key: str, default: Any = None) -> Any: + """Return a single feature / spec value for ``capability``.""" + return self.capabilities.get(capability, {}).get(key, default) + + def features(self, capability: str) -> dict[str, bool]: + """Return all boolean feature flags for ``capability``.""" + cap = self.capabilities.get(capability, {}) + return {k: v for k, v in cap.items() if isinstance(v, bool)} + + def specs(self, capability: str) -> dict[str, Any]: + """Return all non-boolean specs for ``capability``.""" + cap = self.capabilities.get(capability, {}) + return {k: v for k, v in cap.items() if not isinstance(v, bool)} + + def to_dict(self) -> dict: + """Serialize to a JSON-compatible dictionary.""" + return { + "name": self.name, + "vendor": self.vendor, + "model": self.model, + "capabilities": copy.deepcopy(self.capabilities), + "connection": copy.deepcopy(self.connection), + "identity": copy.deepcopy(self.identity), + } + + @classmethod + def from_dict(cls, data: dict) -> "DeviceCard": + """Reconstruct from a dictionary (e.g. loaded from JSON).""" + return cls( + name=data.get("name", ""), + vendor=data.get("vendor", ""), + model=data.get("model", ""), + capabilities=data.get("capabilities", {}), + connection=data.get("connection", {}), + identity=data.get("identity", {}), + ) + + def to_json(self, indent: int = 2) -> str: + """Serialize to JSON string.""" + return json.dumps(self.to_dict(), indent=indent) + + @classmethod + def from_json(cls, json_str: str) -> "DeviceCard": + """Reconstruct from JSON string.""" + return cls.from_dict(json.loads(json_str)) + + +class HasDeviceCard: + """Mixin for devices that carry a :class:`DeviceCard`. + + Devices that want a card declare this mixin and assign ``self.card`` + in their constructor. The mixin makes the attribute discoverable + via type checks:: + + if isinstance(device, HasDeviceCard): + embed_identity(device.card.identity) + + Same shape as :class:`pylabrobot.capabilities.loading_tray.HasLoadingTray` + — a Device-attribute marker mixin, not a Backend mixin. + """ + + card: Optional[DeviceCard] = None diff --git a/pylabrobot/li_cor/odyssey/__init__.py b/pylabrobot/li_cor/odyssey/__init__.py index 669a2e8bb33..ddbab4d23a9 100644 --- a/pylabrobot/li_cor/odyssey/__init__.py +++ b/pylabrobot/li_cor/odyssey/__init__.py @@ -6,6 +6,7 @@ OdysseyInstrumentStatusChatterboxBackend, OdysseyScanningChatterboxBackend, ) +from pylabrobot.li_cor.odyssey.device_card import ODYSSEY_CLASSIC_BASE from pylabrobot.li_cor.odyssey.driver import ( DEFAULT_GROUP, OdysseyDriver, @@ -40,6 +41,7 @@ "OdysseyClassic", "OdysseyDriver", "OdysseyScanningParams", + "ODYSSEY_CLASSIC_BASE", "OdysseyScanningBackend", "OdysseyImageRetrievalBackend", "OdysseyInstrumentStatusBackend", diff --git a/pylabrobot/li_cor/odyssey/device_card.py b/pylabrobot/li_cor/odyssey/device_card.py new file mode 100644 index 00000000000..08119ddb218 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/device_card.py @@ -0,0 +1,69 @@ +"""Odyssey Classic device card — model base. + +Specs sourced from the operator's manual (publication 984-11712, +version 3.0, edition D). The ``identity`` slot is empty: each +deployment populates it with its own unit's PIDInst Handle URI, +landing page, and friendly name via an instance card:: + + instance = DeviceCard.instance(identity={ + "pid": "http://hdl.handle.net/21.11157/...", + "landing_page": "https://b2inst.gwdg.de/records/...", + "name": "Odyssey, Lab 3", + }) + card = ODYSSEY_CLASSIC_BASE.merge(instance) +""" + +from __future__ import annotations + +from pylabrobot.device_card import DeviceCard + +ODYSSEY_CLASSIC_BASE = DeviceCard( + name="Odyssey Classic", + vendor="LI-COR Biosciences", + model="9120", + capabilities={ + "scanning": { + "resolutions_um": [21, 42, 84, 169, 337], + "quality_levels": ["lowest", "low", "medium", "high", "highest"], + "intensity_range": [0.5, 10.0], + "intensity_step": 0.5, + "low_intensity_range": [0.5, 2.0], # L0.5 to L2.0 + "scan_area_cm": [25, 25], + "focus_offset_range_mm": [0.0, 4.0], + "scanning_speed_cm_s": [5, 40], + }, + "channels": { + "700": { + "laser_wavelength_nm": 685, + "laser_type": "solid-state diode", + "laser_peak_power_mw": 80, + "detector": "silicon avalanche photodiode", + "dichroic_split_nm": 750, + }, + "800": { + "laser_wavelength_nm": 785, + "laser_type": "solid-state diode", + "laser_peak_power_mw": 80, + "detector": "silicon avalanche photodiode", + "dichroic_split_nm": 810, + }, + }, + "image_retrieval": { + "format": "TIFF", + "channels_per_file": 1, + "storage_gb": 25, + }, + "instrument_status": { + "states": ["Idle", "Scanning", "Paused"], + "lid_interlock": True, + }, + }, + connection={ + "protocol": "http", + "port": 80, + "auth": "basic", + "network": "10/100Base-T Ethernet", + "discovery": "rendezvous/mdns", + "cgi_base": "/scanapp/nonjava", + }, +) diff --git a/pylabrobot/li_cor/odyssey/odyssey.py b/pylabrobot/li_cor/odyssey/odyssey.py index 32b721750d2..f6222d6bad2 100644 --- a/pylabrobot/li_cor/odyssey/odyssey.py +++ b/pylabrobot/li_cor/odyssey/odyssey.py @@ -43,6 +43,7 @@ async def main(): ) from pylabrobot.capabilities.scanning.scanning import Scanning from pylabrobot.device import Device +from pylabrobot.device_card import DeviceCard, HasDeviceCard from .chatterbox import ( OdysseyChatterboxDriver, @@ -51,6 +52,7 @@ async def main(): OdysseyScanningChatterboxBackend, _OdysseyChatterboxState, ) +from .device_card import ODYSSEY_CLASSIC_BASE from .driver import ( DEFAULT_GROUP, OdysseyDriver, @@ -72,7 +74,7 @@ async def main(): _TERMINAL_STATES = frozenset({"Idle", "Completed", "Stopped", "Failed"}) -class OdysseyClassic(Device): +class OdysseyClassic(Device, HasDeviceCard): """LI-COR Odyssey Classic (model 9120) infrared imaging system. Capabilities: @@ -83,6 +85,11 @@ class OdysseyClassic(Device): Pass ``chatterbox=True`` for an in-memory test path; otherwise the device connects to the instrument over HTTP using credentials from ODYSSEY_USER / ODYSSEY_PASS (or supplied directly). + + ``card`` accepts an instance-level :class:`DeviceCard` (typically + carrying this unit's PIDInst identity). When provided, it is merged + on top of :data:`ODYSSEY_CLASSIC_BASE` and exposed as ``self.card``. + When omitted, ``self.card`` is the model-base card. """ def __init__( @@ -94,6 +101,7 @@ def __init__( timeout: float = 60.0, group: str = DEFAULT_GROUP, chatterbox: bool = False, + card: Optional[DeviceCard] = None, ) -> None: if chatterbox: driver: OdysseyDriver = OdysseyChatterboxDriver() @@ -128,6 +136,11 @@ def __init__( super().__init__(driver=driver) + self.card = ( + ODYSSEY_CLASSIC_BASE.merge(card) if card is not None + else ODYSSEY_CLASSIC_BASE + ) + self.scanning = Scanning(backend=scanning_backend) self.images = ImageRetrieval(backend=image_backend) self.status = InstrumentStatus(backend=status_backend) diff --git a/pylabrobot/li_cor/odyssey/tagging.py b/pylabrobot/li_cor/odyssey/tagging.py index 4f746a460de..373d25a2bfa 100644 --- a/pylabrobot/li_cor/odyssey/tagging.py +++ b/pylabrobot/li_cor/odyssey/tagging.py @@ -23,15 +23,32 @@ import io import json import logging -from typing import Any, Optional +from typing import Any, Optional, Union + +from pylabrobot.device_card import DeviceCard logger = logging.getLogger(__name__) DEFAULT_SOFTWARE_TAG = "PyLabRobot Odyssey" +def _resolve_identity( + source: Union[DeviceCard, dict[str, Any], None] +) -> dict[str, Any]: + """Coerce the identity source into a plain dict. + + Accepts a :class:`DeviceCard` (reads ``card.identity``), a plain + dict, or None. + """ + if source is None: + return {} + if isinstance(source, DeviceCard): + return dict(source.identity or {}) + return dict(source) + + def build_identity_description( - identity: Optional[dict[str, Any]] = None, + identity: Union[DeviceCard, dict[str, Any], None] = None, *, scan_name: str = "", channel: Optional[int] = None, @@ -41,8 +58,10 @@ def build_identity_description( Suitable for TIFF ImageDescription, PNG ``tEXt`` chunks, JSON sidecars, or anywhere else a self-describing identity blob fits. + ``identity`` may be a :class:`DeviceCard` (in which case its + ``identity`` slot is read) or a plain dict. """ - payload: dict[str, Any] = dict(identity or {}) + payload: dict[str, Any] = _resolve_identity(identity) if scan_name: payload["scan_name"] = scan_name if channel is not None: @@ -54,7 +73,7 @@ def build_identity_description( def tag_tiff_with_identity( raw_bytes: bytes, - identity: Optional[dict[str, Any]] = None, + identity: Union[DeviceCard, dict[str, Any], None] = None, *, scan_name: str = "", channel: Optional[int] = None, @@ -62,13 +81,16 @@ def tag_tiff_with_identity( ) -> bytes: """Re-emit a TIFF with the identity payload in tags 270 + 305. - Returns ``raw_bytes`` unchanged when no identity / scan_name / - channel are supplied (so unconfigured users see no behavior change), - when PIL is not importable, or when the TIFF fails to parse / save. + ``identity`` may be a :class:`DeviceCard` (its ``identity`` slot is + read) or a plain dict. Returns ``raw_bytes`` unchanged when no + identity / scan_name / channel are supplied (so unconfigured users + see no behavior change), when PIL is not importable, or when the + TIFF fails to parse / save. """ if not raw_bytes: return raw_bytes - if not (identity or scan_name or channel is not None): + resolved = _resolve_identity(identity) + if not (resolved or scan_name or channel is not None): return raw_bytes try: from PIL import Image # type: ignore[import-not-found] @@ -81,7 +103,7 @@ def tag_tiff_with_identity( logger.info("TIFF re-tag skipped (parse failed): %s", e) return raw_bytes description = build_identity_description( - identity, scan_name=scan_name, channel=channel, + resolved, scan_name=scan_name, channel=channel, ) try: out = io.BytesIO() From 9d5319defb92c3ffb8831d57d5683abb322459de Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Sun, 3 May 2026 11:58:18 +0200 Subject: [PATCH 3/5] Refactor Odyssey driver to transport-only (P-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push protocol encoding off the driver and into the capability backends. v1b1's P-06 — "driver is the wire, backend is the protocol" — was satisfied for class inheritance in the initial port, but driver methods like configure_scan / start_scan / download_tiff / get_status were still doing form encoding, redirect orchestration, error parsing, and HTML scraping. All of that now lives in the backends. Driver public surface shrinks to: setup / stop / post / get / get_bytes (each with optional with_retry) / serialize / from_env, plus shutdown_instrument and get_instrument_info as non-capability admin ops. ~600 LOC moved. Backends absorb: - ScanningBackend: configure (POST + redirect-follow + Error parse), the 7→1 initialization countdown, command.pl GETs for start/stop/ pause/cancel, estimate_time, get_progress, _parse_info_html. OdysseyScanningParams + DEFAULT_GROUP move here. - ImageRetrievalBackend: download_channel (with Content-Length verification), get_preview, download_scan_log, list_groups, list_scans. _tiff_xml + _jpeg_xml + _parse_select_options move here. - InstrumentStatusBackend: full read_status path (GET status page + HTML parse + state normalization), force_stop. _parse_status_html moves here. Last-HTML diagnostic cache moves here too. OdysseyClassic loses the dead `group` constructor parameter — the default group lives in OdysseyScanningParams now and is set per-scan. Chatterbox lifecycle re-verified end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- pylabrobot/li_cor/odyssey/__init__.py | 8 +- pylabrobot/li_cor/odyssey/chatterbox.py | 9 +- pylabrobot/li_cor/odyssey/driver.py | 764 ++++-------------- .../li_cor/odyssey/image_retrieval_backend.py | 132 ++- .../odyssey/instrument_status_backend.py | 87 +- pylabrobot/li_cor/odyssey/odyssey.py | 14 +- pylabrobot/li_cor/odyssey/scanning_backend.py | 341 ++++++-- 7 files changed, 625 insertions(+), 730 deletions(-) diff --git a/pylabrobot/li_cor/odyssey/__init__.py b/pylabrobot/li_cor/odyssey/__init__.py index ddbab4d23a9..634362a5d2c 100644 --- a/pylabrobot/li_cor/odyssey/__init__.py +++ b/pylabrobot/li_cor/odyssey/__init__.py @@ -7,11 +7,7 @@ OdysseyScanningChatterboxBackend, ) from pylabrobot.li_cor.odyssey.device_card import ODYSSEY_CLASSIC_BASE -from pylabrobot.li_cor.odyssey.driver import ( - DEFAULT_GROUP, - OdysseyDriver, - OdysseyScanningParams, -) +from pylabrobot.li_cor.odyssey.driver import OdysseyDriver from pylabrobot.li_cor.odyssey.errors import ( OdysseyError, OdysseyImageError, @@ -28,7 +24,9 @@ ) from pylabrobot.li_cor.odyssey.odyssey import OdysseyClassic from pylabrobot.li_cor.odyssey.scanning_backend import ( + DEFAULT_GROUP, OdysseyScanningBackend, + OdysseyScanningParams, StopResult, ) from pylabrobot.li_cor.odyssey.tagging import ( diff --git a/pylabrobot/li_cor/odyssey/chatterbox.py b/pylabrobot/li_cor/odyssey/chatterbox.py index 69b0082c72f..27f5914f479 100644 --- a/pylabrobot/li_cor/odyssey/chatterbox.py +++ b/pylabrobot/li_cor/odyssey/chatterbox.py @@ -24,8 +24,9 @@ from pylabrobot.capabilities.scanning.scanning import ScanningBackend from pylabrobot.serializer import SerializableMixin -from .driver import DEFAULT_GROUP, OdysseyDriver, OdysseyScanningParams +from .driver import OdysseyDriver from .instrument_status_backend import OdysseyState +from .scanning_backend import DEFAULT_GROUP, OdysseyScanningParams logger = logging.getLogger(__name__) @@ -72,12 +73,6 @@ def __init__(self) -> None: self._auth = None self._timeout = None self._session = None - self._group = DEFAULT_GROUP - self._last_status_html = "" - self._last_status_http = 0 - self._last_configure_url = "" - self._last_configure_http = 0 - self._last_configure_body = "" async def setup(self, backend_params: Optional[BackendParams] = None) -> None: return None diff --git a/pylabrobot/li_cor/odyssey/driver.py b/pylabrobot/li_cor/odyssey/driver.py index e6d7587f6a6..e30da17985d 100644 --- a/pylabrobot/li_cor/odyssey/driver.py +++ b/pylabrobot/li_cor/odyssey/driver.py @@ -1,33 +1,13 @@ -"""LI-COR Odyssey Classic HTTP driver. - -The Odyssey Classic (model 9120) runs an embedded Linux web server -(Apache/1.3.27 + mod_perl/1.23 on Red Hat Linux) that serves Perl CGI -scripts. This driver wraps an aiohttp session with HTTP Basic Auth -and provides typed methods for each endpoint. - -API reverse-engineered from HAR captures of the browser interface. - -Endpoints: - Scan setup: - POST /scanapp/scan/nonjava/configure.pl — configure scan parameters - GET /scanapp/scan/nonjava/command.pl — ?action=start|stop|pause|cancel - GET /scanapp/scan/nonjava/console.pl — scan console page - GET /scanapp/scan/nonjava/initializing.pl — ?scan=...&scangroup=...&timeout=... - GET /scanapp/scan/nonjava/time.pl — scan time estimate - Imaging: - GET /scanapp/imaging/nonjava/info.pl — scan progress - POST /scanapp/imaging/nonjava/openimage.pl — render JPEG preview - GET /scan/image?xml= — fetch JPEG preview - GET /scan/image/-.tif?xml= — download raw TIFF - GET /scanapp/imaging/nonjava/savelog.pl — download scan log - Status: - GET /scanapp/util/status/ — instrument status page - POST /scanapp/util/status/status — stop scan from status page - Admin: - POST /scanapp/admin/admin/index — ?action=InitiateShutdown - -Auth: HTTP Basic Auth, realm "LICOR-Odyssey". -Transport: TCP/IP, 10/100Base-T Ethernet. +"""LI-COR Odyssey Classic HTTP transport driver. + +Generic HTTP transport over Basic Auth for the Odyssey Classic +embedded web server. Vendor-specific protocol — CGI paths, form +encoding, response parsing — lives in the capability backends; this +driver only ships bytes back and forth. + +Server: Apache/1.3.27 (Unix) (Red-Hat/Linux) mod_perl/1.23 +Auth realm: LICOR-Odyssey +Transport: HTTP over 10/100Base-T Ethernet """ from __future__ import annotations @@ -35,24 +15,18 @@ import asyncio import logging import os -import re -from dataclasses import dataclass -from typing import Any, Optional -from urllib.parse import quote +from typing import Optional import aiohttp from pylabrobot.capabilities.capability import BackendParams from pylabrobot.device import Driver -from .errors import OdysseyImageError, OdysseyScanError - logger = logging.getLogger(__name__) # Transport errors worth retrying — connection-level transients only. -# Server-level failures (4xx / 5xx) are NOT retried. -_RETRYABLE_EXCEPTIONS = ( +RETRYABLE_EXCEPTIONS = ( aiohttp.ClientConnectionError, asyncio.TimeoutError, ConnectionResetError, @@ -61,164 +35,18 @@ _HTTP_RETRY_ATTEMPTS = 3 _HTTP_RETRY_DELAY = 0.25 -_SCAN_BASE = "/scanapp/scan/nonjava" -_IMAGE_BASE = "/scanapp/imaging/nonjava" -_STATUS_BASE = "/scanapp/util/status" - -# Hardware protocol invariants — contracts with the Odyssey Classic -# firmware 2.1.12. Do NOT change without re-verifying against the unit. -_CONFIGURE_URL_PATH = f"{_SCAN_BASE}/configure.pl" -_CHANNEL_SENTINEL = "x" # hidden 'channel' field must be literal "x" -_INIT_POLL_STEPS = 7 # full 7→1 countdown after configure -DEFAULT_GROUP = "odyssey" # default operational group - -# Environment variables for HTTP Basic Auth credentials. +# Environment variables for credentials. _CRED_ENV_USER = "ODYSSEY_USER" _CRED_ENV_PASS = "ODYSSEY_PASS" -@dataclass -class OdysseyScanningParams(BackendParams): - """Scan parameters for the Odyssey Classic. - - Field names align with the configure.pl form, captured from the - browser interface. Pass an instance to :meth:`Scanning.configure` — - the backend coerces it and forwards to the driver. - - Attributes: - name: Scan name. Avoid ;/?:@=&<>"#%{}|^~[]. - group: Scan group name (e.g. "odyssey", "public"). - resolution: Resolution in micrometers — "21", "42", "84", "169", - "337", or "preview". - quality: "lowest", "low", "medium", "high", "highest". - intensity_700: 700 nm channel intensity. L2, L1.5, L1, L0.5, - 0.5, 1, 1.5, 2, ..., 10 (in 0.5 steps). - intensity_800: 800 nm channel intensity (same range). - channel_700: Enable 700 nm acquisition. - channel_800: Enable 800 nm acquisition. - origin_x: Scan origin X in cm (0–25). - origin_y: Scan origin Y in cm (0–25). - width: Scan width in cm; origin_x + width <= 25. - height: Scan height in cm; origin_y + height <= 25. - focus: Focus offset in mm (0.0–4.0). 0 for membranes, - ~1.0 for gels, 3.0 for microplates. - comment: Free-text comment. - preset: Preset name to load (empty for manual config). - """ - - name: str = "scan" - group: str = DEFAULT_GROUP - resolution: str = "169" - quality: str = "medium" - intensity_700: str = "5" - intensity_800: str = "5" - channel_700: bool = True - channel_800: bool = True - origin_x: int = 0 - origin_y: int = 0 - width: int = 10 - height: int = 10 - focus: float = 0.0 - comment: str = "" - preset: str = "" - - def to_form_data(self) -> dict[str, str]: - """Render as the form-data dict POSTed to configure.pl. - - Form field names match the HTML form exactly: - channel, scan, scangroup, avail, preset, resolution, quality, - intensity700, intensity800, chan700, chan800, x0, y0, width, - height, x1, y1, focus, comment, prename - """ - data: dict[str, str] = { - "channel": _CHANNEL_SENTINEL, # firmware quirk; must be literal "x" - "scan": self.name, - "scangroup": self.group, - "avail": self.group, - "preset": self.preset, - "resolution": str(self.resolution), - "quality": self.quality, - "intensity700": str(self.intensity_700), - "intensity800": str(self.intensity_800), - "x0": str(self.origin_x), - "y0": str(self.origin_y), - "width": str(self.width), - "height": str(self.height), - "x1": str(self.origin_x + self.width), - "y1": str(self.origin_y + self.height), - "focus": str(self.focus), - "comment": self.comment, - "prename": "", - } - if self.channel_700: - data["chan700"] = "chan700" - if self.channel_800: - data["chan800"] = "chan800" - return data - - def to_time_params(self) -> dict[str, str]: - """Query params for the time.pl scan-time estimate.""" - return { - "resolution": str(self.resolution), - "quality": self.quality, - "x0": str(self.origin_x), - "y0": str(self.origin_y), - "x1": str(self.origin_x + self.width), - "y1": str(self.origin_y + self.height), - } - - -def _tiff_xml(group: str, scan_name: str, channel: int) -> str: - """Build the XML query string for TIFF download.""" - return ( - f"" - f"{group}" - f"{scan_name}" - f"tiff" - f"{channel}" - f"0000" - f"" - ) - - -def _jpeg_xml( - group: str, - scan_name: str, - contrast_700: int = 5, - contrast_800: int = 5, - channels: str = "700 800", - background: str = "black", - clip: tuple[int, int, int, int] = (0, 0, 0, 0), - vflip: bool = True, - hflip: bool = True, - zoom: int = 1, -) -> str: - """Build the XML query string for JPEG preview.""" - x0, x1, y0, y1 = clip - return ( - f"" - f"{group}" - f"{scan_name}" - f"{zoom}" - f"{contrast_700}" - f"{contrast_800}" - f"{channels}" - f"{background}" - f"{x0}{x1}{y0}{y1}" - f"{'true' if vflip else 'false'}" - f"{'true' if hflip else 'false'}" - f"" - ) - - class OdysseyDriver(Driver): - """Driver (transport) for the LI-COR Odyssey Classic infrared imager. + """HTTP transport for the LI-COR Odyssey Classic. - Wraps an aiohttp.ClientSession with HTTP Basic Auth. The capability - backends share a single OdysseyDriver instance. - - Server: Apache/1.3.27 (Unix) (Red-Hat/Linux) mod_perl/1.23 - Auth realm: LICOR-Odyssey + Wraps an aiohttp.ClientSession with HTTP Basic Auth. Capability + backends share a single OdysseyDriver instance and call + :meth:`post` / :meth:`get` / :meth:`get_bytes` to exchange bytes + with the embedded web server. """ def __init__( @@ -228,7 +56,6 @@ def __init__( password: str, port: int = 80, timeout: float = 60.0, - group: str = DEFAULT_GROUP, ) -> None: super().__init__() if not username or not password: @@ -246,17 +73,9 @@ def __init__( self._auth = aiohttp.BasicAuth(username, password) self._timeout = aiohttp.ClientTimeout(total=timeout) self._session: Optional[aiohttp.ClientSession] = None - self._group = group - # Diagnostic caches — populated by methods, exposed through helpers. - self._last_status_html: str = "" - self._last_status_http: int = 0 - self._last_configure_url: str = "" - self._last_configure_http: int = 0 - self._last_configure_body: str = "" - # Never log the password. logger.info( - "OdysseyDriver initialised: host=%s group=%s %s=%s", - host, group, _CRED_ENV_USER, username, + "OdysseyDriver initialised: host=%s %s=%s", + host, _CRED_ENV_USER, username, ) def serialize(self) -> dict: @@ -265,7 +84,6 @@ def serialize(self) -> dict: "host": self._host, "port": self._port, "timeout": self._timeout_seconds, - "group": self._group, } @classmethod @@ -274,13 +92,12 @@ def from_env( host: Optional[str] = None, port: int = 80, timeout: float = 60.0, - group: str = DEFAULT_GROUP, ) -> "OdysseyDriver": - """Construct from ODYSSEY_USER / ODYSSEY_PASS environment variables. + """Construct from ODYSSEY_USER / ODYSSEY_PASS env vars. - Raises ValueError if either is missing — the driver does not silently - fall back to default credentials. If ``host`` is None, ODYSSEY_HOST is - read from the environment too. + Raises ValueError if either is missing — the driver does not + silently fall back to default credentials. If ``host`` is None, + ODYSSEY_HOST is read from the environment too. """ username = os.environ.get(_CRED_ENV_USER, "") password = os.environ.get(_CRED_ENV_PASS, "") @@ -292,8 +109,7 @@ def from_env( ) if not val ] raise ValueError( - f"Missing required environment variable(s): " - f"{', '.join(missing)}." + f"Missing required environment variable(s): {', '.join(missing)}." ) if host is None: host = os.environ.get("ODYSSEY_HOST", "") @@ -301,45 +117,35 @@ def from_env( raise ValueError("No host provided and ODYSSEY_HOST is unset.") return cls( host=host, username=username, password=password, - port=port, timeout=timeout, group=group, + port=port, timeout=timeout, ) - @property - def group(self) -> str: - return self._group - @property def base_url(self) -> str: return self._base_url async def setup(self, backend_params: Optional[BackendParams] = None) -> None: - """Open the HTTP session and verify connectivity + auth. + """Open the HTTP session and verify reachability. - ``Connection: close`` is forced on every request: the Odyssey's - embedded Apache 1.3.27 doesn't always handle keep-alive cleanly - when a second request arrives before the first response has been - fully consumed. Closing per request is a couple of ms slower but + ``Connection: close`` is forced on every request: the embedded + Apache 1.3.27 doesn't always handle keep-alive cleanly when a + second request arrives before the first response is fully + consumed. Closing per request is a couple of ms slower but eliminates the inter-request races we see in the field. + + Auth verification is intentionally NOT done here. Backends + perform the first auth-protected call; a 401 surfaces there. """ self._session = aiohttp.ClientSession( auth=self._auth, timeout=self._timeout, headers={"Connection": "close"}, ) - # Verify connectivity — the home page is unprotected. async with self._session.get(self._base_url) as resp: if resp.status != 200: raise ConnectionError( f"Cannot reach Odyssey at {self._base_url} (HTTP {resp.status})" ) - # Verify auth — the scan page requires login. - url = f"{self._base_url}{_SCAN_BASE}/" - async with self._session.get(url) as resp: - if resp.status == 401: - raise ConnectionError( - "Authentication failed — check username/password " - "(realm: LICOR-Odyssey)" - ) logger.info("Connected to Odyssey at %s", self._base_url) async def stop(self) -> None: @@ -353,434 +159,136 @@ def _check_session(self) -> aiohttp.ClientSession: raise RuntimeError("OdysseyDriver not set up") return self._session - # -- Scan control -------------------------------------------------------- + # -- Generic transport --------------------------------------------------- - async def configure_scan(self, params: OdysseyScanningParams) -> str: - """POST scan parameters to configure.pl. + async def post( + self, + path: str, + form_data: Optional[dict[str, str]] = None, + *, + allow_redirects: bool = False, + with_retry: bool = False, + ) -> tuple[int, str, dict[str, str]]: + """POST ``form_data`` to ``path``. Returns (status, body, headers).""" + if with_retry: + return await self._retry(self._post_once, path, form_data, allow_redirects) + return await self._post_once(path, form_data, allow_redirects) + + async def _post_once( + self, + path: str, + form_data: Optional[dict[str, str]], + allow_redirects: bool, + ) -> tuple[int, str, dict[str, str]]: + session = self._check_session() + url = f"{self._base_url}{path}" + async with session.post( + url, data=form_data, allow_redirects=allow_redirects + ) as resp: + body = await resp.text() + return resp.status, body, dict(resp.headers) - On success: 302 redirect to initializing.pl (7 s countdown). - On error (scanner busy / name collision): 200 with HTML error page. + async def get( + self, + path: str, + params: Optional[dict[str, str]] = None, + *, + allow_redirects: bool = True, + with_retry: bool = False, + ) -> tuple[int, str, dict[str, str]]: + """GET ``path`` with ``params``. Returns (status, body, headers).""" + if with_retry: + return await self._retry(self._get_once, path, params, allow_redirects) + return await self._get_once(path, params, allow_redirects) + + async def _get_once( + self, + path: str, + params: Optional[dict[str, str]], + allow_redirects: bool, + ) -> tuple[int, str, dict[str, str]]: + session = self._check_session() + url = f"{self._base_url}{path}" + async with session.get( + url, params=params, allow_redirects=allow_redirects + ) as resp: + body = await resp.text() + return resp.status, body, dict(resp.headers) - The initializing.pl countdown takes ~7 seconds as the instrument - configures the DSP, laser voltages, and motor positions. + async def get_bytes( + self, + path: str, + params: Optional[dict[str, str]] = None, + *, + with_retry: bool = False, + ) -> tuple[int, bytes, dict[str, str], Optional[int]]: + """GET binary content. Returns (status, bytes, headers, content_length). + + ``content_length`` is the server-advertised ``Content-Length`` + header (or None). Truncation checks belong on the caller — this + method just returns whatever the socket delivered. """ + if with_retry: + return await self._retry_bytes(path, params) + return await self._get_bytes_once(path, params) + + async def _get_bytes_once( + self, + path: str, + params: Optional[dict[str, str]], + ) -> tuple[int, bytes, dict[str, str], Optional[int]]: session = self._check_session() - url = f"{self._base_url}{_CONFIGURE_URL_PATH}" - form_data = params.to_form_data() - logger.info( - "Configuring scan: name=%s group=%s res=%s quality=%s", - params.name, params.group, params.resolution, params.quality, - ) - logger.debug("Form data: %s", form_data) + url = f"{self._base_url}{path}" + async with session.get(url, params=params) as resp: + data = await resp.read() + return resp.status, data, dict(resp.headers), resp.content_length + async def _retry(self, method, *args): last_exc: Optional[Exception] = None for attempt in range(_HTTP_RETRY_ATTEMPTS): try: - async with session.post( - url, data=form_data, allow_redirects=False - ) as resp: - body = await resp.text() - self._last_configure_url = url - self._last_configure_http = resp.status - self._last_configure_body = body - logger.info( - "POST %s → HTTP %d, body length %d", - url, resp.status, len(body), - ) - if resp.status == 302: - redirect = resp.headers.get("Location", "") - logger.info("configure.pl redirect → %s", redirect) - # Follow the redirect — this triggers hardware initialization. - if redirect: - redirect_url = ( - redirect if redirect.startswith("http") - else f"{self._base_url}{redirect}" - ) - async with session.get( - redirect_url, allow_redirects=False, - ) as init_resp: - logger.info( - "Followed redirect → HTTP %d", init_resp.status, - ) - return body - # 4xx/5xx — server said no. - if resp.status >= 400: - raise OdysseyScanError( - f"Scanner rejected configuration: " - f"POST {url} → HTTP {resp.status}." - ) - # 2xx with an explicit error page in the body. The firmware - # embeds a structured ``message`` - # block; pull the short label and message out so callers see - # "Scan already exists: foo already exists." instead of HTML. - if "busy" in body.lower() or "Error" in body: - m = re.search( - r'\s*(.*?)\s*', - body, re.IGNORECASE | re.DOTALL, - ) - if m: - short = m.group(1).strip() - detail = re.sub(r"\s+", " ", m.group(2)).strip() - raise OdysseyScanError( - f"Scanner rejected configuration — {short}: {detail}" - ) - raise OdysseyScanError( - f"Scanner rejected configuration: {body[:500]}" - ) - return body - except _RETRYABLE_EXCEPTIONS as exc: + return await method(*args) + except RETRYABLE_EXCEPTIONS as exc: last_exc = exc if attempt < _HTTP_RETRY_ATTEMPTS - 1: logger.warning( - "configure_scan attempt %d/%d failed (%s) — retrying", - attempt + 1, _HTTP_RETRY_ATTEMPTS, exc, + "%s attempt %d/%d failed (%s) — retrying", + method.__name__, attempt + 1, _HTTP_RETRY_ATTEMPTS, exc, ) await asyncio.sleep(_HTTP_RETRY_DELAY) continue - raise OdysseyScanError( - f"configure_scan failed after {_HTTP_RETRY_ATTEMPTS} attempts: " - f"{last_exc}" - ) from last_exc - - async def wait_initialization( - self, - scan_name: str, - group: str, - timeout_steps: int = _INIT_POLL_STEPS, - ) -> None: - """Wait through the 7→1 initialization countdown. - - The instrument prepares motors and laser voltages during this - window. Skipping the poll skips hardware prep and produces invalid - scans. - """ - session = self._check_session() - logger.info("Waiting %d seconds for hardware initialization...", - timeout_steps) - for t in range(timeout_steps, 0, -1): - url = f"{self._base_url}{_SCAN_BASE}/initializing.pl" - params = {"scan": scan_name, "scangroup": group, "timeout": str(t)} - logger.info("initializing.pl?timeout=%d", t) - async with session.get(url, params=params, allow_redirects=False) as resp: - if resp.status == 302 and t <= 1: - redirect = resp.headers.get("Location", "") - logger.info("Initialization complete → %s", redirect) - if redirect: - redirect_url = redirect if redirect.startswith("http") \ - else f"{self._base_url}{redirect}" - async with session.get( - redirect_url, allow_redirects=True - ) as _: - pass - return - await asyncio.sleep(1) - logger.info("Initialization wait complete") - - async def start_scan(self) -> str: - """Send start command. Scanner must be configured first.""" - return await self._scan_command("start") - - async def stop_scan(self) -> str: - """Send stop command (finishes scan, saves files).""" - return await self._scan_command("stop") - - async def pause_scan(self) -> str: - """Send pause command.""" - return await self._scan_command("pause") - - async def cancel_scan(self) -> str: - """Send cancel command (aborts scan, no save).""" - return await self._scan_command("cancel") - - async def _scan_command(self, action: str) -> str: - """Send command.pl?action=. - - On success: 302 redirect to console.pl. - On error (not configured): 200 with structured Error block. - """ - session = self._check_session() - url = f"{self._base_url}{_SCAN_BASE}/command.pl" - params = {"action": action} - logger.info("Scan command: %s", action) - - async with session.get(url, params=params, allow_redirects=False) as resp: - body = await resp.text() - logger.info("command.pl?action=%s → HTTP %d, body: %s", - action, resp.status, body[:300]) - if resp.status == 302: - return body - if " str: - """Get estimated scan time from time.pl. - - Returns the time string, e.g. "0 hours 2 minutes 15 seconds". - """ - session = self._check_session() - url = f"{self._base_url}{_SCAN_BASE}/time.pl" - async with session.get(url, params=params.to_time_params()) as resp: - html = await resp.text() - match = re.search( - r"Estimated Scan Time.*?(\d+ hours? \d+ minutes? \d+ seconds?)", - html, re.DOTALL | re.IGNORECASE, - ) - return match.group(1) if match else html - - # -- Status -------------------------------------------------------------- - - async def get_status(self) -> dict[str, str]: - """Fetch and parse the instrument status page. - - Returns dict with keys: state, current_user, progress, - time_remaining, lid_status. The most recent raw HTML response is - cached on ``self._last_status_html`` for diagnostic use. - """ - session = self._check_session() - url = f"{self._base_url}{_STATUS_BASE}/" - async with session.get(url) as resp: - html = await resp.text() - self._last_status_http = resp.status - self._last_status_html = html - parsed = self._parse_status_html(html) - if parsed["state"] == "Unknown": - logger.warning( - "Status parser missed 'Scanner Status' (HTTP %s, %d bytes).", - self._last_status_http, len(html), - ) - return parsed - - async def stop_from_status(self) -> str: - """Stop the scanner from the status/utilities page. - - The path to release a paused/stuck scanner without going through - the scan console. - """ - session = self._check_session() - url = f"{self._base_url}{_STATUS_BASE}/status" - data = {"formContext": "1", "action": "Stop"} - async with session.post(url, data=data) as resp: - return await resp.text() - - async def get_scan_progress( - self, scan_name: str, group: str - ) -> dict[str, str]: - """Fetch scan progress from the imaging info panel. - - Returns dict with: dimensions, file_size, time_left. - """ - session = self._check_session() - url = f"{self._base_url}{_IMAGE_BASE}/info.pl" - params = { - "scan": scan_name, - "group": group, - "update": "Off", - "console": "yes", - } - async with session.get(url, params=params) as resp: - html = await resp.text() - return self._parse_info_html(html) - - # -- Image retrieval ----------------------------------------------------- - - async def download_tiff( - self, group: str, scan_name: str, channel: int - ) -> bytes: - """Download a raw TIFF for one channel (700 or 800). - - URL: /scan/image/-.tif?xml= - - Retries up to 3 times on transient connection errors with a 0.25 s - backoff. HTTP 4xx/5xx fail immediately. Verifies the downloaded - byte count matches Content-Length when present. - """ - session = self._check_session() - xml = _tiff_xml(group, scan_name, channel) - url = ( - f"{self._base_url}/scan/image/" - f"{quote(scan_name)}-{channel}.tif" - ) - logger.info("Downloading TIFF: %s channel %d", scan_name, channel) + assert last_exc is not None + raise last_exc + async def _retry_bytes(self, path, params): last_exc: Optional[Exception] = None for attempt in range(_HTTP_RETRY_ATTEMPTS): try: - async with session.get(url, params={"xml": xml}) as resp: - if resp.status != 200: - raise OdysseyImageError( - f"TIFF download failed for {scan_name}-{channel}: " - f"HTTP {resp.status}" - ) - expected = resp.content_length # may be None - data = await resp.read() - if expected is not None and len(data) != expected: - raise IOError( - f"Truncated TIFF: got {len(data)} bytes, " - f"expected {expected} (Content-Length)" - ) - logger.info( - "Downloaded %s-%d.tif: %d bytes", - scan_name, channel, len(data), - ) - return data - except _RETRYABLE_EXCEPTIONS as exc: + return await self._get_bytes_once(path, params) + except RETRYABLE_EXCEPTIONS as exc: last_exc = exc if attempt < _HTTP_RETRY_ATTEMPTS - 1: logger.warning( - "TIFF download attempt %d/%d for %s-%d failed (%s) — retrying", - attempt + 1, _HTTP_RETRY_ATTEMPTS, - scan_name, channel, exc, + "GET bytes %s attempt %d/%d failed (%s) — retrying", + path, attempt + 1, _HTTP_RETRY_ATTEMPTS, exc, ) await asyncio.sleep(_HTTP_RETRY_DELAY) continue - raise OdysseyImageError( - f"TIFF download for {scan_name}-{channel} failed after " - f"{_HTTP_RETRY_ATTEMPTS} attempts: {last_exc}" - ) from last_exc + assert last_exc is not None + raise last_exc - async def get_jpeg_preview( - self, - group: str, - scan_name: str, - contrast_700: int = 5, - contrast_800: int = 5, - channels: str = "700 800", - background: str = "black", - ) -> bytes: - """Fetch a JPEG preview with display settings applied server-side.""" - session = self._check_session() - xml = _jpeg_xml( - group, scan_name, - contrast_700=contrast_700, - contrast_800=contrast_800, - channels=channels, - background=background, - ) - url = f"{self._base_url}/scan/image" - async with session.get(url, params={"xml": xml}) as resp: - if resp.status != 200: - raise OdysseyImageError( - f"JPEG preview failed: HTTP {resp.status}" - ) - return await resp.read() - - async def download_scan_log(self, group: str, scan_name: str) -> str: - """Download the scan log for a completed scan.""" - session = self._check_session() - url = f"{self._base_url}{_IMAGE_BASE}/savelog.pl" - params = {"group": group, "scan": scan_name} - async with session.get(url, params=params) as resp: - return await resp.text() - - async def list_scan_groups(self) -> str: - """Fetch the scan setup page HTML. - - Contains a .""" - pattern = ( - rf']*name=["\']?{select_name}["\']?[^>]*>' - r"(.*?)" - ) - match = re.search(pattern, html, re.DOTALL | re.IGNORECASE) - if not match: - return [] - return re.findall( - r']*value=["\']?([^"\'>\s]+)', - match.group(1), - re.IGNORECASE, - ) + """Fetch instrument info page (serial, software version).""" + _, body, _ = await self.get("/scanapp/help/instinfo.pl") + return body diff --git a/pylabrobot/li_cor/odyssey/image_retrieval_backend.py b/pylabrobot/li_cor/odyssey/image_retrieval_backend.py index 0eea12c8285..cf713563be0 100644 --- a/pylabrobot/li_cor/odyssey/image_retrieval_backend.py +++ b/pylabrobot/li_cor/odyssey/image_retrieval_backend.py @@ -1,22 +1,90 @@ -"""Odyssey image retrieval backend — concrete ImageRetrievalBackend over HTTP. +"""Odyssey image retrieval backend — protocol logic for /scan/image. -Downloads scan TIFFs from the instrument's internal storage via the -/scan/image endpoint with XML query parameters, plus the scan-list -HTML at /scanapp/scan/nonjava/. +Owns the image-retrieval protocol: XML query encoding, TIFF / +JPEG / log GETs, and HTML parsing of the scan-list dropdown. The +driver only ships the bytes back. """ from __future__ import annotations import logging +import re from typing import List +from urllib.parse import quote from pylabrobot.capabilities.scanning.image_retrieval import ImageRetrievalBackend from .driver import OdysseyDriver +from .errors import OdysseyImageError logger = logging.getLogger(__name__) +_IMAGE_BASE = "/scanapp/imaging/nonjava" +_SCAN_LIST_PATH = "/scanapp/scan/nonjava/" +_SCAN_IMAGE_PATH = "/scan/image" +_SAVELOG_URL_PATH = f"{_IMAGE_BASE}/savelog.pl" + + +def _tiff_xml(group: str, scan_name: str, channel: int) -> str: + """Build the XML query string for a TIFF download.""" + return ( + f"" + f"{group}" + f"{scan_name}" + f"tiff" + f"{channel}" + f"0000" + f"" + ) + + +def _jpeg_xml( + group: str, + scan_name: str, + contrast_700: int = 5, + contrast_800: int = 5, + channels: str = "700 800", + background: str = "black", + clip: tuple[int, int, int, int] = (0, 0, 0, 0), + vflip: bool = True, + hflip: bool = True, + zoom: int = 1, +) -> str: + """Build the XML query string for a JPEG preview.""" + x0, x1, y0, y1 = clip + return ( + f"" + f"{group}" + f"{scan_name}" + f"{zoom}" + f"{contrast_700}" + f"{contrast_800}" + f"{channels}" + f"{background}" + f"{x0}{x1}{y0}{y1}" + f"{'true' if vflip else 'false'}" + f"{'true' if hflip else 'false'}" + f"" + ) + + +def _parse_select_options(html: str, select_name: str) -> List[str]: + """Extract