diff --git a/CHANGELOG.md b/CHANGELOG.md
index 870ab59605e..872a2729e5b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
+- LI-COR Odyssey Classic (model 9120) infrared imaging system at `pylabrobot.li_cor.odyssey`
- `StackerRetrieval` capability (`pylabrobot.capabilities.automated_retrieval.StackerRetrieval`) for sequential ("stacking access") plate storage: one or more single-ended LIFO `ResourceStack` stacks plus a loading tray, with `downstack`/`upstack` operations and a `StackerBackend` interface (plus `StackerChatterboxBackend`). Intended for devices like the Agilent BenchCel and HighRes MicroServe (#1113).
- `AutomatedRetrieval` base capability (`pylabrobot.capabilities.automated_retrieval.AutomatedRetrieval`) that owns the loading tray and the plate-movement plumbing shared by the random-access `RandomAccessRetrieval` and the sequential `StackerRetrieval`. The former random-access `AutomatedRetrieval` is now `RandomAccessRetrieval` and extends this base.
- HighRes Biosolutions MicroSpin centrifuge backend (`pylabrobot.centrifuge.highres.MicroSpinBackend`) speaking the device's ASCII command/response protocol over TCP/1000, plus a `MicroSpin(...)` factory.
diff --git a/docs/_exts/plr_devices/data.py b/docs/_exts/plr_devices/data.py
index 5a2113d56eb..f3a83b32343 100644
--- a/docs/_exts/plr_devices/data.py
+++ b/docs/_exts/plr_devices/data.py
@@ -26,6 +26,7 @@ class DeviceRegistryError(ValueError):
"fan",
"flow cytometer",
"heater shaker",
+ "imager",
"liquid handler",
"microscope",
"peeler",
diff --git a/docs/_static/devices.json b/docs/_static/devices.json
index 1335c70215c..72b2b9110a6 100644
--- a/docs/_static/devices.json
+++ b/docs/_static/devices.json
@@ -2036,5 +2036,26 @@
"doc_slug": "ufactory/xarm6/hello-world",
"manager": "https://discuss.pylabrobot.org/u/rickwierenga",
"oem": "https://www.ufactory.cc/xarm-collaborative-robot/"
+ },
+ {
+ "id": "li-cor-odyssey-classic",
+ "vendor": "LI-COR",
+ "name": "Odyssey Classic",
+ "models": [
+ {
+ "name": "9120",
+ "status": "wip"
+ }
+ ],
+ "kind": "imager",
+ "capabilities": [
+ "fluorescence"
+ ],
+ "status": "wip",
+ "api": "pylabrobot.li_cor.OdysseyClassic",
+ "api_version": "v1",
+ "code_slug": "li_cor/odyssey",
+ "doc_slug": "li_cor/odyssey/hello-world",
+ "notes": "HTTP scan control and TIFF retrieval; port pending hardware verification."
}
]
diff --git a/docs/api/pylabrobot.li_cor.rst b/docs/api/pylabrobot.li_cor.rst
new file mode 100644
index 00000000000..344da2bf625
--- /dev/null
+++ b/docs/api/pylabrobot.li_cor.rst
@@ -0,0 +1,23 @@
+.. currentmodule:: pylabrobot.li_cor.odyssey
+
+pylabrobot.li_cor package
+============================
+
+Odyssey Classic
+---------------
+
+.. autosummary::
+ :toctree: _autosummary
+ :nosignatures:
+ :recursive:
+
+ OdysseyClassic
+ OdysseyStatus
+ OdysseyChatterbox
+ StopResult
+ OdysseyError
+ OdysseyScanError
+ OdysseyImageError
+ OdysseyStatusError
+ build_identity_description
+ tag_tiff_with_identity
diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst
index 394622fb707..f10e32c0f78 100644
--- a/docs/api/pylabrobot.rst
+++ b/docs/api/pylabrobot.rst
@@ -32,6 +32,7 @@ Manufacturers
pylabrobot.inheco
pylabrobot.kbioscience
pylabrobot.kbiosystems
+ pylabrobot.li_cor
pylabrobot.mettler_toledo
pylabrobot.micronic
pylabrobot.molecular_devices
diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md
index 3bbce5af02e..dc5c79b6b38 100644
--- a/docs/user_guide/index.md
+++ b/docs/user_guide/index.md
@@ -40,6 +40,7 @@ high_res/index
inheco/index
kbioscience/index
kbiosystems/index
+li_cor/index
mettler_toledo/index
micronic/index
molecular_devices/index
diff --git a/docs/user_guide/li_cor/index.md b/docs/user_guide/li_cor/index.md
new file mode 100644
index 00000000000..6ee9e405eb0
--- /dev/null
+++ b/docs/user_guide/li_cor/index.md
@@ -0,0 +1,7 @@
+# LI-COR
+
+```{toctree}
+:maxdepth: 1
+
+odyssey/hello-world
+```
diff --git a/docs/user_guide/li_cor/odyssey/hello-world.ipynb b/docs/user_guide/li_cor/odyssey/hello-world.ipynb
new file mode 100644
index 00000000000..a156d7a1bcc
--- /dev/null
+++ b/docs/user_guide/li_cor/odyssey/hello-world.ipynb
@@ -0,0 +1,250 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "07ed52b8",
+ "metadata": {},
+ "source": [
+ "# LI-COR Odyssey Classic\n",
+ "\n",
+ "```{device-card} li-cor-odyssey-classic\n",
+ "```\n",
+ "\n",
+ "| Property | Value |\n",
+ "|---|---|\n",
+ "| Model | 9120 |\n",
+ "| Connection | Ethernet, HTTP Basic Auth |\n",
+ "| Acquisition | 700 and 800 nm fluorescence |\n",
+ "| Length units | millimeters, including resolution |\n",
+ "\n",
+ "**This port has not been verified on hardware.** Review the protocol and scan settings before using an instrument. These cells are not executed by the documentation build."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "42811c1a",
+ "metadata": {},
+ "source": [
+ "## Connection and sample preparation\n",
+ "\n",
+ "Connect the scanner and computer to the same network and use the instrument's configured IP address. The driver uses the embedded web interface; no vendor desktop application is required. Set `ODYSSEY_HOST`, `ODYSSEY_USER`, and `ODYSSEY_PASS` in your environment. Load the sample on the scan bed and close the lid before starting acquisition.\n",
+ "\n",
+ "The optional `pylabrobot[odyssey]` extra installs Pillow for TIFF identity tagging. Instrument communication uses the standard-library HTTP transport."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "cddf8a7b",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "Open the transport and read the authenticated status page."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4ddbaf39",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pylabrobot.li_cor import OdysseyClassic\n",
+ "\n",
+ "odyssey = OdysseyClassic() # Host and credentials come from the environment.\n",
+ "await odyssey.setup()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "097e5896",
+ "metadata": {},
+ "source": [
+ "## Read status\n",
+ "\n",
+ "Status includes scanner state, progress in percent, remaining-time text, and the lid state when reported by the firmware."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "a6a643f3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await odyssey.request_status()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d035ce02",
+ "metadata": {},
+ "source": [
+ "## Configure the scan\n",
+ "\n",
+ "The rectangle and resolution are in millimeters. This example scans a 100 × 100 mm region at 0.169 mm resolution with both channels enabled. Configuration includes the firmware's seven-step initialization sequence."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3ed2ca59",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await odyssey.configure_scan(\n",
+ " \"membrane_001\",\n",
+ " group=\"odyssey\",\n",
+ " width=100,\n",
+ " height=100,\n",
+ " resolution=0.169,\n",
+ " focus=0,\n",
+ " channel_700=True,\n",
+ " channel_800=True,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "93049201",
+ "metadata": {},
+ "source": [
+ "## Acquire\n",
+ "\n",
+ "Start the configured scan and wait up to one hour for completion. The instrument returns to `Idle` on completion. Unknown states and firmware errors raise exceptions."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b50d1e04",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "final_status = await odyssey.scan(timeout=3600)\n",
+ "final_status"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4bc334ca",
+ "metadata": {},
+ "source": [
+ "## Download the channel TIFFs\n",
+ "\n",
+ "Each channel remains a separate image. `download_channel(group, name, 700)` retrieves only one channel; `download` retrieves both."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b2a28342",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "\n",
+ "images = await odyssey.download(\"odyssey\", \"membrane_001\")\n",
+ "for channel, tiff in images.items():\n",
+ " Path(f\"membrane_001-{channel}.tif\").write_bytes(tiff)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "49f4b412",
+ "metadata": {},
+ "source": [
+ "## Optional instrument identity tags\n",
+ "\n",
+ "Supply the identifier for your instrument as a plain dictionary. No runtime DeviceCard object is required. The helper returns the original bytes if Pillow is unavailable or tagging fails."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "d6f18af7",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pylabrobot.li_cor.odyssey import tag_tiff_with_identity\n",
+ "\n",
+ "identity = {\"name\": \"My Odyssey\"} # Add your unit's persistent identifier as \"pid\", if available.\n",
+ "tagged = tag_tiff_with_identity(images[700], identity, scan_name=\"membrane_001\", channel=700)\n",
+ "Path(\"membrane_001-700-tagged.tif\").write_bytes(tagged)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "aef77986",
+ "metadata": {},
+ "source": [
+ "## Pause or interrupt an active acquisition\n",
+ "\n",
+ "For interactive control, configure a scan and use `await odyssey.start_scan()` to start without waiting. During acquisition:\n",
+ "\n",
+ "- `await odyssey.pause_scan()` pauses; `await odyssey.start_scan()` resumes.\n",
+ "- `await odyssey.stop_and_save()` gracefully stops and reports available channel TIFFs.\n",
+ "- `await odyssey.cancel_scan()` aborts and discards partial output.\n",
+ "\n",
+ "`await odyssey.wait_until_done()` polls for completion. It requires a fresh transition out of an initial terminal state by default; use `require_fresh=False` when you know which scan you are waiting for."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f1c89b35",
+ "metadata": {},
+ "source": [
+ "## Disconnect\n",
+ "\n",
+ "Closing the transport does not stop acquisition. Use `stop_and_save()` or `cancel_scan()` first when an active scan needs to be interrupted."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e70a0d68",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await odyssey.stop()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7ba6783b",
+ "metadata": {},
+ "source": [
+ "## Chatterbox\n",
+ "\n",
+ "The simulated HTTP transport runs the same scan-control code without opening a socket. Status reads advance progress and produce synthetic one-pixel TIFFs. It does not render JPEG previews."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "f0fad940",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pylabrobot.li_cor import OdysseyChatterbox\n",
+ "\n",
+ "async with OdysseyClassic(io=OdysseyChatterbox()) as simulated:\n",
+ " await simulated.configure_scan(\"demo\")\n",
+ " await simulated.scan(poll_interval=0.01)\n",
+ " demo_tiff = await simulated.download_channel(\"odyssey\", \"demo\", 700)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pylabrobot/io/http.py b/pylabrobot/io/http.py
index b905fbf5f52..80cfda27a36 100644
--- a/pylabrobot/io/http.py
+++ b/pylabrobot/io/http.py
@@ -1,4 +1,5 @@
import asyncio
+import base64
import json
import logging
import urllib.error
@@ -15,6 +16,54 @@
logger = logging.getLogger(__name__)
+def _origin(url: str) -> tuple[str, Optional[str], Optional[int]]:
+ """Compare origins with explicit and implicit default ports treated equally."""
+ parsed = urllib.parse.urlsplit(url)
+ port = parsed.port or {"http": 80, "https": 443}.get(parsed.scheme)
+ return parsed.scheme, parsed.hostname, port
+
+
+@dataclass(frozen=True)
+class HTTPResponse:
+ """HTTP status, lower-case response headers, and the unmodified response body."""
+
+ status: int
+ body: bytes
+ headers: Dict[str, str]
+
+ def text(self) -> str:
+ """Decode a text response using its declared charset, defaulting to UTF-8."""
+ from email.message import Message
+
+ message = Message()
+ message["Content-Type"] = self.headers.get("content-type", "")
+ return self.body.decode(message.get_content_charset() or "utf-8", errors="replace")
+
+
+class _RedirectHandler(urllib.request.HTTPRedirectHandler):
+ """Control redirects without forwarding device credentials to another origin."""
+
+ def __init__(self, allow_redirects: bool):
+ self.allow_redirects = allow_redirects
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ if not self.allow_redirects:
+ return None
+ if _origin(req.full_url) != _origin(newurl):
+ raise ValueError("HTTP redirect must stay on the device's origin")
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
+
+
+@dataclass
+class HTTPRawCommand(Command):
+ """A raw HTTP exchange; binary bodies are base64 encoded for capture."""
+
+ path: str
+ request_base64: Optional[str]
+ status: int
+ response_base64: str
+
+
class HTTPError(RuntimeError):
"""An HTTP response outside the 2xx range."""
@@ -49,7 +98,7 @@ def __init__(
class HTTP:
- """Asynchronous JSON-over-HTTP transport.
+ """Asynchronous HTTP transport for JSON, text, and binary responses.
The standard-library HTTP client is blocking, so requests run on a private
single-thread executor. The executor and a request lock keep a device's
@@ -151,3 +200,76 @@ async def request(
)
)
return response
+
+ def _make_raw_request(
+ self,
+ method: str,
+ path: str,
+ body: Optional[bytes],
+ headers: Mapping[str, str],
+ allow_redirects: bool,
+ ) -> HTTPResponse:
+ """Send bytes on the worker thread, retaining non-2xx responses for the caller."""
+ url = urllib.parse.urljoin(f"{self.base_url}/", path)
+ if _origin(self.base_url) != _origin(url):
+ raise ValueError("HTTP request must stay on the device's origin")
+ request = urllib.request.Request(
+ url, data=body, headers={**self.headers, **headers}, method=method
+ )
+ opener = urllib.request.build_opener(_RedirectHandler(allow_redirects))
+ try:
+ response = opener.open(request, timeout=self.timeout)
+ except urllib.error.HTTPError as error:
+ response = error
+ with response:
+ return HTTPResponse(
+ status=response.code,
+ body=response.read(),
+ headers={key.lower(): value for key, value in response.headers.items()},
+ )
+
+ async def request_raw(
+ self,
+ method: str,
+ path: str,
+ body: Optional[bytes] = None,
+ *,
+ headers: Optional[Mapping[str, str]] = None,
+ allow_redirects: bool = True,
+ ) -> HTTPResponse:
+ """Send raw bytes and return status, headers, and body without HTTP-status exceptions.
+
+ Requests are ordered with JSON requests on the same transport. No requests are retried.
+ Authentication headers are excluded from logs and captures.
+ """
+ if self._executor is None:
+ raise RuntimeError(
+ f"HTTP transport for '{self.human_readable_device_name}' is not set up; call setup() first"
+ )
+ method = method.upper()
+ async with self._request_lock:
+ response = await asyncio.get_running_loop().run_in_executor(
+ self._executor,
+ partial(self._make_raw_request, method, path, body, headers or {}, allow_redirects),
+ )
+ logger.log(
+ LOG_LEVEL_IO,
+ "[%s] %s %s: HTTP %d, %d bytes",
+ self.base_url,
+ method,
+ path,
+ response.status,
+ len(response.body),
+ )
+ capturer.record(
+ HTTPRawCommand(
+ module="http",
+ device_id=self.base_url,
+ action=method,
+ path=path,
+ request_base64=base64.b64encode(body).decode("ascii") if body is not None else None,
+ status=response.status,
+ response_base64=base64.b64encode(response.body).decode("ascii"),
+ )
+ )
+ return response
diff --git a/pylabrobot/io/http_tests.py b/pylabrobot/io/http_tests.py
index e97b68204e5..e9162f58117 100644
--- a/pylabrobot/io/http_tests.py
+++ b/pylabrobot/io/http_tests.py
@@ -65,3 +65,94 @@ async def test_http_error_includes_response_body(self) -> None:
if __name__ == "__main__":
unittest.main()
+
+
+class _RawResponse(_Response):
+ def __init__(self, body: bytes, code: int = 200, headers=None):
+ super().__init__(body)
+ self.code = code
+ self.headers = headers or {}
+
+
+class HTTPRawTests(unittest.IsolatedAsyncioTestCase):
+ async def asyncSetUp(self):
+ self.transport = HTTP(
+ "raw test", "http://device.local:80", headers={"Authorization": "Basic secret"}
+ )
+ await self.transport.setup()
+
+ async def asyncTearDown(self):
+ await self.transport.stop()
+
+ async def test_raw_post_preserves_form_status_and_headers(self):
+ with patch("urllib.request.build_opener") as build:
+ build.return_value.open.return_value = _RawResponse(b"redirect", 302, {"Location": "/next"})
+ response = await self.transport.request_raw(
+ "POST",
+ "/configure",
+ b"scan=a+name&channel=x",
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ allow_redirects=False,
+ )
+ request = build.return_value.open.call_args.args[0]
+ self.assertEqual(request.data, b"scan=a+name&channel=x")
+ self.assertEqual(request.headers["Authorization"], "Basic secret")
+ self.assertEqual(request.headers["Content-type"], "application/x-www-form-urlencoded")
+ self.assertEqual(response.status, 302)
+ self.assertEqual(response.headers, {"location": "/next"})
+ self.assertFalse(build.call_args.args[0].allow_redirects)
+
+ async def test_raw_response_retains_non_utf8_bytes(self):
+ data = b"II*\x00\xff\xfe"
+ with patch("urllib.request.build_opener") as build:
+ build.return_value.open.return_value = _RawResponse(data)
+ response = await self.transport.request_raw("GET", "/scan.tif")
+ self.assertEqual(response.body, data)
+
+ async def test_raw_request_returns_http_error_body_for_device_parser(self):
+ error = urllib.error.HTTPError(
+ "http://device.local/status", 401, "Denied", Message(), io.BytesIO(b"denied")
+ )
+ with patch("urllib.request.build_opener") as build:
+ build.return_value.open.side_effect = error
+ response = await self.transport.request_raw("GET", "/status")
+ self.assertEqual((response.status, response.body), (401, b"denied"))
+
+ async def test_raw_request_does_not_retry_connection_failure(self):
+ with patch("urllib.request.build_opener") as build:
+ build.return_value.open.side_effect = OSError("lost connection")
+ with self.assertRaises(OSError):
+ await self.transport.request_raw("POST", "/configure")
+ build.return_value.open.assert_called_once()
+
+ async def test_cross_origin_request_is_rejected_before_sending_credentials(self):
+ with patch("urllib.request.build_opener") as build:
+ with self.assertRaises(ValueError):
+ await self.transport.request_raw("GET", "http://another-device/path")
+ build.assert_not_called()
+
+ async def test_same_origin_absolute_url_accepts_implicit_default_port(self):
+ with patch("urllib.request.build_opener") as build:
+ build.return_value.open.return_value = _RawResponse(b"ok")
+ await self.transport.request_raw("GET", "http://device.local/path")
+ self.assertEqual(build.return_value.open.call_args.args[0].full_url, "http://device.local/path")
+
+ async def test_redirect_policy_blocks_cross_origin_and_allows_relative_redirect(self):
+ with patch("urllib.request.build_opener") as build:
+ build.return_value.open.return_value = _RawResponse(b"ok")
+ await self.transport.request_raw("GET", "/status")
+ handler = build.call_args.args[0]
+ request = urllib.request.Request(
+ "http://device.local:80/status", headers={"Authorization": "Basic secret"}
+ )
+ with self.assertRaises(ValueError):
+ handler.redirect_request(request, None, 302, "Found", {}, "http://another-device/path")
+ redirected = handler.redirect_request(
+ request, None, 302, "Found", {}, "http://device.local/path"
+ )
+ self.assertEqual(redirected.full_url, "http://device.local/path")
+
+ async def test_raw_request_requires_setup(self):
+ await self.transport.stop()
+ with self.assertRaisesRegex(RuntimeError, "setup"):
+ await self.transport.request_raw("GET", "/status")
diff --git a/pylabrobot/li_cor/__init__.py b/pylabrobot/li_cor/__init__.py
new file mode 100644
index 00000000000..276e2ac1372
--- /dev/null
+++ b/pylabrobot/li_cor/__init__.py
@@ -0,0 +1,3 @@
+"""LI-COR instruments."""
+
+from .odyssey import OdysseyChatterbox, OdysseyClassic, OdysseyStatus
diff --git a/pylabrobot/li_cor/odyssey/__init__.py b/pylabrobot/li_cor/odyssey/__init__.py
new file mode 100644
index 00000000000..d984f441a6b
--- /dev/null
+++ b/pylabrobot/li_cor/odyssey/__init__.py
@@ -0,0 +1,6 @@
+"""LI-COR Odyssey Classic control, simulation, and TIFF metadata helpers."""
+
+from .chatterbox import OdysseyChatterbox
+from .errors import OdysseyError, OdysseyImageError, OdysseyScanError, OdysseyStatusError
+from .odyssey import OdysseyClassic, OdysseyState, OdysseyStatus, StopResult, normalize_state
+from .tagging import 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..8ded0ed8070
--- /dev/null
+++ b/pylabrobot/li_cor/odyssey/chatterbox.py
@@ -0,0 +1,176 @@
+"""In-memory HTTP responses for exercising Odyssey control without an instrument."""
+
+from __future__ import annotations
+
+import logging
+import struct
+from html import escape
+from typing import Mapping, Optional
+from urllib.parse import parse_qs, urlsplit
+from xml.etree import ElementTree
+
+from pylabrobot.io.http import HTTP, HTTPResponse
+
+from . import protocol
+
+logger = logging.getLogger(__name__)
+
+
+def _tiff() -> bytes:
+ """Create a one-pixel, 16-bit TIFF without an imaging dependency."""
+ offset = 8 + 2 + 9 * 12 + 4
+ entries = [
+ (256, 4, 1),
+ (257, 4, 1),
+ (258, 3, 16),
+ (259, 3, 1),
+ (262, 3, 1),
+ (273, 4, offset),
+ (277, 3, 1),
+ (278, 4, 1),
+ (279, 4, 2),
+ ]
+ return (
+ b"II*\x00"
+ + struct.pack(" None:
+ super().__init__("Odyssey chatterbox", "http://chatterbox")
+ self._connected = False
+ self.state = "Idle"
+ self.progress = 0
+ self._form: dict[str, str] = {}
+ self.scans: dict[str, dict[str, dict[int, bytes]]] = {
+ "odyssey": {"test_scan": {700: _tiff(), 800: _tiff()}}
+ }
+
+ async def setup(self) -> None:
+ """Enable the simulated connection."""
+ self._connected = True
+
+ async def stop(self) -> None:
+ """Close the simulated connection."""
+ self._connected = False
+
+ def _save_scan(self) -> None:
+ """Store an image for each enabled channel of the configured scan."""
+ images = {ch: _tiff() for ch in (700, 800) if f"chan{ch}" in self._form}
+ self.scans.setdefault(self._form["scangroup"], {})[self._form["scan"]] = images
+
+ @staticmethod
+ def _response(body: str = "", status: int = 200) -> HTTPResponse:
+ """Build a text response from the simulated web server."""
+ return HTTPResponse(status, body.encode(), {"content-type": "text/html; charset=utf-8"})
+
+ async def request_raw(
+ self,
+ method: str,
+ path: str,
+ body: Optional[bytes] = None,
+ *,
+ headers: Optional[Mapping[str, str]] = None,
+ allow_redirects: bool = True,
+ ) -> HTTPResponse:
+ """Handle a scan-control, status, or image request entirely in memory."""
+ if not self._connected:
+ raise RuntimeError("Odyssey chatterbox is not set up")
+ parsed = urlsplit(path)
+ query = {key: values[0] for key, values in parse_qs(parsed.query).items()}
+ form = {key: values[0] for key, values in parse_qs((body or b"").decode()).items()}
+ path = parsed.path
+ logger.info("Odyssey chatterbox %s %s", method, path)
+ if path == protocol._CONFIGURE_URL_PATH and method == "POST":
+ if self.state in ("Scanning", "Paused"):
+ return self._response('
Scanner Status: {self.state}
Current User: chatterbox
" + f"Percent Complete: {self.progress}%
Time Remaining: 0 seconds
" + "Lid Status: Closed
" + ) + if path == protocol._SCAN_LIST_PATH: + group = query.get("avail", "odyssey") + groups = "".join(f'' for g in self.scans) + scans = "".join( + f'' for n in self.scans.get(group, {}) + ) + return self._response( + f'' + ) + if path.startswith(protocol._SCAN_IMAGE_PATH): + xml = ElementTree.fromstring(query["xml"]) + group = xml.findtext("in/scangroup", "") + name = xml.findtext("in/scan", "") + channel = xml.findtext("in/channel", "700") + images = self.scans.get(group, {}).get(name, {}) + if xml.findtext("in/format") != "tiff": + return self._response("JPEG rendering is not implemented by the chatterbox", 501) + if int(channel) not in images: + return self._response("Image not found", 404) + data = images[int(channel)] + return HTTPResponse( + 200, data, {"content-type": "image/tiff", "content-length": str(len(data))} + ) + if path == protocol._INFO_URL_PATH: + return self._response( + "Dimensions: 1 x 1
File Size: 124 bytes
Time Left: 0 seconds
" + ) + if path == protocol._TIME_URL_PATH: + return self._response("Estimated Scan Time: 0 hours 0 minutes 1 seconds") + if path == protocol._SAVELOG_URL_PATH: + return self._response("Chatterbox scan log") + if path == "/scanapp/help/instinfo.pl": + return self._response("Odyssey Classic chatterbox") + if path == "/scanapp/admin/admin/index": + self.state = "Idle" + return self._response("Chatterbox shut down") + return self._response("Unknown endpoint", 404) diff --git a/pylabrobot/li_cor/odyssey/errors.py b/pylabrobot/li_cor/odyssey/errors.py new file mode 100644 index 00000000000..09d04fe9910 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/errors.py @@ -0,0 +1,17 @@ +"""Errors reported while communicating with the Odyssey Classic.""" + + +class OdysseyError(RuntimeError): + """An Odyssey HTTP request or protocol operation failed.""" + + +class OdysseyScanError(OdysseyError): + """Scan configuration or acquisition failed.""" + + +class OdysseyImageError(OdysseyError): + """An image could not be retrieved completely.""" + + +class OdysseyStatusError(OdysseyError): + """The instrument status could not be read or interpreted.""" diff --git a/pylabrobot/li_cor/odyssey/odyssey.py b/pylabrobot/li_cor/odyssey/odyssey.py new file mode 100644 index 00000000000..a57bf118508 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/odyssey.py @@ -0,0 +1,552 @@ +"""Direct HTTP control of the LI-COR Odyssey Classic (model 9120).""" + +from __future__ import annotations + +import asyncio +import base64 +import logging +import math +import os +import re +from dataclasses import dataclass +from typing import Callable, Literal, Optional, Union +from urllib.parse import quote, urlencode, urljoin + +from pylabrobot.io.http import HTTP, HTTPResponse + +from . import protocol +from .errors import OdysseyError, OdysseyImageError, OdysseyScanError, OdysseyStatusError + +logger = logging.getLogger(__name__) + +OdysseyState = Literal[ + "Idle", "Configured", "Initializing", "Scanning", "Paused", "Stopped", "Completed", "Failed" +] +_STATE_MAP: dict[str, OdysseyState] = { + "idle": "Idle", + "configured": "Configured", + "initializing": "Initializing", + "scanning": "Scanning", + "escanning": "Scanning", + "paused": "Paused", + "stopped": "Stopped", + "completed": "Completed", + "failed": "Failed", + "error": "Failed", +} +_TERMINAL_STATES = {"Idle", "Stopped", "Completed", "Failed"} + + +@dataclass(frozen=True) +class OdysseyStatus: + """Instrument state, progress in percent, and the firmware's remaining-time text.""" + + state: OdysseyState + current_user: str = "" + progress: float = 0 + time_remaining: str = "" + lid_open: Optional[bool] = None + + +@dataclass(frozen=True) +class StopResult: + """Channel images available after a graceful stop; they may contain a partial scan.""" + + state: str + partial: bool + channels_available: list[int] + + +def normalize_state(raw: str) -> OdysseyState: + """Normalize known firmware spellings, rejecting unknown states.""" + try: + return _STATE_MAP[raw.strip().lower()] + except KeyError as error: + raise OdysseyStatusError(f"Unknown Odyssey scanner state: {raw!r}") from error + + +class OdysseyClassic: + """LI-COR Odyssey Classic infrared scanner using its embedded HTTP interface. + + Lengths, including resolution, are in millimeters. Credentials can be supplied explicitly or + through ODYSSEY_USER / ODYSSEY_PASS. ODYSSEY_HOST supplies the host when omitted. + Pass an HTTP transport as ``io`` to use a simulated or recorded connection. + """ + + def __init__( + self, + host: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + port: int = 80, + timeout: float = 60, + io: Optional[HTTP] = None, + ) -> None: + self.host = host or os.environ.get("ODYSSEY_HOST", "") + self.port = port + self.timeout = timeout + if not 1 <= port <= 65535: + raise ValueError("port must be between 1 and 65535") + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("timeout must be finite and greater than zero") + if io is None: + if not self.host or any(part in self.host for part in ("://", "/", "?", "#")): + raise ValueError("Provide an Odyssey hostname or IP address, without a URL scheme or path") + username = username or os.environ.get("ODYSSEY_USER", "") + password = password or os.environ.get("ODYSSEY_PASS", "") + if not username or not password: + raise ValueError("Provide username and password, or set ODYSSEY_USER / ODYSSEY_PASS") + if ":" in username: + raise ValueError("HTTP Basic Auth usernames cannot contain ':'") + credentials = base64.b64encode(f"{username}:{password}".encode()).decode("ascii") + io = HTTP( + human_readable_device_name="LI-COR Odyssey Classic", + base_url=f"http://{self.host}:{port}", + headers={"Authorization": f"Basic {credentials}", "Connection": "close"}, + timeout=timeout, + ) + self.io = io + self._connected = False + self._current_scan: Optional[tuple[str, str]] = None + self._channels: tuple[int, ...] = () + self._last_status_html = "" + self._last_status_http = 0 + + @property + def connected(self) -> bool: + """Whether setup has opened and checked the transport.""" + return self._connected + + async def setup(self) -> None: + """Open the HTTP transport and verify the authenticated status page.""" + if self._connected: + return + logger.warning( + "Odyssey Classic port is untested on hardware; please report verification results" + ) + await self.io.setup() + self._connected = True + try: + await self.request_status() + except BaseException: + await self.stop() + raise + logger.info("Connected to Odyssey at %s", self.io.base_url) + + async def stop(self) -> None: + """Close the connection. Use stop_scan or cancel_scan to interrupt acquisition.""" + try: + await self.io.stop() + finally: + self._connected = False + logger.info("Disconnected from Odyssey at %s", self.io.base_url) + + async def __aenter__(self) -> OdysseyClassic: + """Set up the instrument for an async context.""" + await self.setup() + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + """Close the transport when leaving an async context.""" + await self.stop() + + def serialize(self) -> dict: + """Return connection settings, excluding credentials.""" + return {"host": self.host, "port": self.port, "timeout": self.timeout} + + async def _request( + self, + method: str, + path: str, + *, + params: Optional[dict[str, str]] = None, + form: Optional[dict[str, str]] = None, + allow_redirects: bool = True, + ) -> HTTPResponse: + """Exchange an Odyssey form or query and reject HTTP and firmware errors.""" + if not self._connected: + raise RuntimeError("Odyssey is not connected; call setup() first") + if params: + path += ("&" if "?" in path else "?") + urlencode(params) + body = urlencode(form).encode("ascii") if form is not None else None + response = await self.io.request_raw( + method, + path, + body, + headers={"Content-Type": "application/x-www-form-urlencoded"} if form is not None else None, + allow_redirects=allow_redirects, + ) + if response.status >= 400 or response.status < 200: + raise OdysseyError(f"{method} {path.split('?')[0]} returned HTTP {response.status}") + if response.status not in range(200, 300) and response.status != 302: + raise OdysseyError(f"Unexpected HTTP {response.status} for {path.split('?')[0]}") + return response + + async def configure_scan( + self, + name: str = "scan", + *, + group: str = "odyssey", + resolution: Union[float, Literal["preview"]] = 0.169, + quality: Literal["lowest", "low", "medium", "high", "highest"] = "medium", + intensity_700: str = "5", + intensity_800: str = "5", + channel_700: bool = True, + channel_800: bool = True, + origin_x: float = 0, + origin_y: float = 0, + width: float = 100, + height: float = 100, + focus: float = 0, + comment: str = "", + preset: str = "", + ) -> None: + """Configure a scan and complete the firmware's seven-step initialization. + + The scan rectangle and focus are in mm. Resolutions are 0.021, 0.042, 0.084, 0.169, + or 0.337 mm, or "preview". Intensities accept L2/L1.5/L1/L0.5 or 0.5 through 10 in + steps of 0.5. Start acquisition separately with start_scan() or scan(). + """ + if not name or re.search(r'[;/?:@=&<>"#%{}|^~\[\]]', name): + raise ValueError("Scan name is empty or contains a character rejected by the firmware") + if not group: + raise ValueError("group cannot be empty") + if resolution != "preview" and resolution not in (0.021, 0.042, 0.084, 0.169, 0.337): + raise ValueError("Unsupported resolution in mm") + if quality not in ("lowest", "low", "medium", "high", "highest"): + raise ValueError("Unsupported scan quality") + intensities = {f"{n / 2:g}" for n in range(1, 21)} | {"L2", "L1.5", "L1", "L0.5"} + if intensity_700 not in intensities or intensity_800 not in intensities: + raise ValueError("Unsupported laser intensity") + if not channel_700 and not channel_800: + raise ValueError("At least one acquisition channel must be enabled") + if not all(math.isfinite(v) for v in (origin_x, origin_y, width, height, focus)): + raise ValueError("Scan dimensions and focus must be finite") + if min(origin_x, origin_y) < 0 or min(width, height) <= 0: + raise ValueError("Origins must be non-negative and scan dimensions must be positive") + if origin_x + width > 250 or origin_y + height > 250 or not 0 <= focus <= 4: + raise ValueError("Scan rectangle must fit within 250 x 250 mm; focus must be 0–4 mm") + resolution_text = "preview" if resolution == "preview" else f"{resolution * 1000:g}" + form = { + "channel": "x", + "scan": name, + "scangroup": group, + "avail": group, + "preset": preset, + "resolution": resolution_text, + "quality": quality, + "intensity700": intensity_700, + "intensity800": intensity_800, + "x0": f"{origin_x / 10:g}", + "y0": f"{origin_y / 10:g}", + "width": f"{width / 10:g}", + "height": f"{height / 10:g}", + "x1": f"{(origin_x + width) / 10:g}", + "y1": f"{(origin_y + height) / 10:g}", + "focus": f"{focus:g}", + "comment": comment, + "prename": "", + } + if channel_700: + form["chan700"] = "chan700" + if channel_800: + form["chan800"] = "chan800" + logger.info("Configuring Odyssey scan %s in %s", name, group) + self._current_scan = None + self._channels = () + response = await self._request( + "POST", protocol._CONFIGURE_URL_PATH, form=form, allow_redirects=False + ) + self._check_scan_response(response) + await self._follow_redirect(response, protocol._CONFIGURE_URL_PATH) + for step in range(7, 0, -1): + response = await self._request( + "GET", + protocol._INITIALIZING_URL_PATH, + params={"scan": name, "scangroup": group, "timeout": str(step)}, + allow_redirects=False, + ) + self._check_scan_response(response) + if step == 1 and response.status == 302: + await self._follow_redirect(response, protocol._INITIALIZING_URL_PATH) + else: + await asyncio.sleep(1) + self._current_scan = (group, name) + self._channels = tuple( + ch for ch, enabled in ((700, channel_700), (800, channel_800)) if enabled + ) + + @staticmethod + def _check_scan_response(response: HTTPResponse) -> None: + """Reject CGI error pages even when the HTTP status is 200.""" + body = response.text() + error = protocol._parse_error_block(body) + if error or re.search(r"Scanner Status: Idle
")) + self.device = OdysseyClassic(io=self.io) + await self.device.setup() + self.io.request_raw.reset_mock() + + @staticmethod + def response(body: str, status: int = 200, **headers): + return HTTPResponse(status, body.encode(), headers) + + async def asyncTearDown(self): + await self.device.stop() + + async def configure(self, **kwargs): + self.io.request_raw.return_value = self.response("") + with patch("pylabrobot.li_cor.odyssey.odyssey.asyncio.sleep", new_callable=AsyncMock): + await self.device.configure_scan(**kwargs) + + async def test_configuration_encodes_units_and_runs_all_initialization_steps(self): + await self.configure( + name="membrane", + width=125, + height=75, + origin_x=10, + origin_y=20, + resolution=0.042, + channel_800=False, + comment="a & b", + ) + calls = self.io.request_raw.call_args_list + method, path, body = calls[0].args + self.assertEqual((method, path), ("POST", "/scanapp/scan/nonjava/configure.pl")) + form = parse_qs(body.decode(), keep_blank_values=True) + expected = { + "channel": ["x"], + "resolution": ["42"], + "x0": ["1"], + "y0": ["2"], + "width": ["12.5"], + "height": ["7.5"], + "x1": ["13.5"], + "y1": ["9.5"], + "comment": ["a & b"], + "chan700": ["chan700"], + } + for key, value in expected.items(): + self.assertEqual(form[key], value) + self.assertNotIn("chan800", form) + self.assertEqual(len(calls), 8) + for call, step in zip(calls[1:], range(7, 0, -1)): + self.assertIn("initializing.pl", call.args[1]) + self.assertEqual(parse_qs(call.args[1].split("?", 1)[1])["timeout"], [str(step)]) + + async def test_configuration_follows_redirect_before_countdown(self): + self.io.request_raw.side_effect = [ + HTTPResponse(302, b"", {"location": "/prepare"}), + self.response(""), + *[self.response("") for _ in range(6)], + HTTPResponse(302, b"", {"location": "/console"}), + self.response(""), + ] + with patch("pylabrobot.li_cor.odyssey.odyssey.asyncio.sleep", new_callable=AsyncMock): + await self.device.configure_scan() + calls = self.io.request_raw.call_args_list + self.assertEqual(calls[1].args[1], "/prepare") + self.assertEqual(calls[-1].args[1], "/console") + + async def test_configuration_rejects_firmware_error_without_retrying(self): + self.io.request_raw.return_value = self.response('