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 is busy') + self._form = form + self.state = "Initializing" + self.progress = 0 + return HTTPResponse(302, b"", {"location": f"{protocol._INITIALIZING_URL_PATH}?timeout=7"}) + if path == protocol._INITIALIZING_URL_PATH: + if query.get("timeout") == "1": + self.state = "Configured" + return HTTPResponse(302, b"", {"location": f"{protocol._SCAN_BASE}/console.pl"}) + return self._response("Initializing") + if path == f"{protocol._SCAN_BASE}/console.pl": + return self._response("Console") + if path == protocol._COMMAND_URL_PATH or path == protocol._STOP_FROM_STATUS_PATH: + action = query.get("action", form.get("action", "")).lower() + if action == "start": + if self.state not in ("Configured", "Paused"): + return self._response('Configure first') + self.state = "Scanning" + elif action == "pause": + if self.state == "Scanning": + self.state = "Paused" + elif action == "stop": + if self.state in ("Scanning", "Paused"): + self._save_scan() + self.state = "Stopped" + elif action == "cancel": + self.state = "Idle" + self.progress = 0 + self._form = {} + else: + return self._response("Unknown action", 400) + return HTTPResponse(302, b"", {"location": f"{protocol._SCAN_BASE}/console.pl"}) + if path == protocol._STATUS_URL_PATH: + if self.state == "Scanning": + self.progress = min(100, self.progress + 25) + if self.progress == 100: + self._save_scan() + self.state = "Idle" + return self._response( + f"

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"\s*error|\bbusy\b|not configured", body, re.I): + raise OdysseyScanError(error or body[:500]) + + async def _follow_redirect(self, response: HTTPResponse, source_path: str) -> None: + """Follow the configure/initialization redirect within the device origin.""" + if response.status == 302: + location = response.headers.get("location") + if not location: + raise OdysseyScanError("Odyssey returned a redirect without a Location header") + redirected = await self._request("GET", urljoin(source_path, location), allow_redirects=False) + self._check_scan_response(redirected) + + async def _scan_command(self, action: str) -> None: + """Send a scan-console action once; state-changing requests are not retried.""" + logger.info("Odyssey scan command: %s", action) + response = await self._request( + "GET", protocol._COMMAND_URL_PATH, params={"action": action}, allow_redirects=False + ) + self._check_scan_response(response) + + async def start_scan(self) -> None: + """Start a configured scan, or resume a paused scan.""" + if self._current_scan is None: + raise OdysseyScanError("Configure a scan before starting acquisition") + await self._scan_command("start") + + async def stop_scan(self) -> None: + """Finish the current line and retain the partially acquired image.""" + await self._scan_command("stop") + + async def pause_scan(self) -> None: + """Pause acquisition; resume with start_scan().""" + await self._scan_command("pause") + + async def cancel_scan(self) -> None: + """Abort acquisition and discard partial output.""" + await self._scan_command("cancel") + self._current_scan = None + self._channels = () + + async def force_stop(self) -> None: + """Stop a paused or stuck scanner through the status-page control.""" + response = await self._request( + "POST", + protocol._STOP_FROM_STATUS_PATH, + form={"formContext": "1", "action": "Stop"}, + allow_redirects=False, + ) + self._check_scan_response(response) + + async def request_status(self) -> OdysseyStatus: + """Read the status page, rejecting unknown states instead of reporting false completion.""" + response = await self._request("GET", protocol._STATUS_URL_PATH) + self._last_status_html = response.text() + self._last_status_http = response.status + parsed = protocol._parse_status_html(self._last_status_html) + state = normalize_state(parsed["state"]) + progress_text = parsed["progress"].rstrip("% ") + try: + progress = float(progress_text) if progress_text else 0.0 + except ValueError as error: + raise OdysseyStatusError(f"Invalid progress: {parsed['progress']!r}") from error + if not math.isfinite(progress) or not 0 <= progress <= 100: + raise OdysseyStatusError(f"Invalid progress: {progress}") + lid = parsed["lid_status"].lower() + return OdysseyStatus( + state=state, + current_user=parsed["current_user"], + progress=progress, + time_remaining=parsed["time_remaining"], + lid_open=True if lid == "open" else False if lid == "closed" else None, + ) + + @property + def last_status_html(self) -> str: + """The most recently parsed status-page HTML, for diagnostics.""" + return self._last_status_html + + @property + def last_status_http(self) -> int: + """The HTTP status of the most recently parsed status page.""" + return self._last_status_http + + async def request_progress(self) -> dict[str, str]: + """Read the configured scan's dimensions, file size, and remaining-time text.""" + if self._current_scan is None: + raise OdysseyScanError("No scan has been configured") + group, name = self._current_scan + response = await self._request( + "GET", + protocol._INFO_URL_PATH, + params={"scan": name, "group": group, "update": "Off", "console": "yes"}, + ) + return protocol._parse_info_html(response.text()) + + async def estimate_time( + self, + *, + resolution: float = 0.169, + quality: str = "medium", + origin_x: float = 0, + origin_y: float = 0, + width: float = 100, + height: float = 100, + ) -> str: + """Request the firmware's scan-time estimate; lengths and resolution are in mm.""" + response = await self._request( + "GET", + protocol._TIME_URL_PATH, + params={ + "resolution": f"{resolution * 1000:g}", + "quality": quality, + "x0": f"{origin_x / 10:g}", + "y0": f"{origin_y / 10:g}", + "x1": f"{(origin_x + width) / 10:g}", + "y1": f"{(origin_y + height) / 10:g}", + }, + ) + match = re.search( + r"Estimated Scan Time.*?(\d+ hours? \d+ minutes? \d+ seconds?)", + response.text(), + re.DOTALL | re.IGNORECASE, + ) + return match.group(1) if match else response.text() + + async def list_groups(self) -> list[str]: + """List saved scan groups from the instrument's scan page.""" + response = await self._request("GET", protocol._SCAN_LIST_PATH) + return protocol._parse_select_options(response.text(), "avail") + + async def list_scans(self, group: str) -> list[str]: + """List scans in a group using the scan page's group selector.""" + response = await self._request("GET", protocol._SCAN_LIST_PATH, params={"avail": group}) + return protocol._parse_select_options(response.text(), "preset") + + async def download_channel(self, group: str, scan_name: str, channel: int) -> bytes: + """Download one complete TIFF for the 700 or 800 nm channel.""" + if channel not in (700, 800): + raise ValueError("channel must be 700 or 800") + response = await self._request( + "GET", + f"{protocol._SCAN_IMAGE_PATH}/{quote(scan_name, safe='')}-{channel}.tif", + params={"xml": protocol._tiff_xml(group, scan_name, channel)}, + ) + length = response.headers.get("content-length") + if length is not None and len(response.body) != int(length): + raise OdysseyImageError(f"Truncated TIFF: expected {length} bytes, got {len(response.body)}") + if response.body[:4] not in (b"II*\x00", b"MM\x00*", b"II+\x00", b"MM\x00+"): + raise OdysseyImageError("The instrument did not return a TIFF image") + logger.info( + "Downloaded Odyssey scan %s channel %d: %d bytes", scan_name, channel, len(response.body) + ) + return response.body + + async def download(self, group: str, scan_name: str) -> dict[int, bytes]: + """Download both channels as separate TIFF files, keyed by wavelength.""" + return { + channel: await self.download_channel(group, scan_name, channel) for channel in (700, 800) + } + + 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.""" + response = await self._request( + "GET", + protocol._SCAN_IMAGE_PATH, + params={ + "xml": protocol._jpeg_xml( + group, + scan_name, + contrast_700=contrast_700, + contrast_800=contrast_800, + channels=channels, + background=background, + ) + }, + ) + return response.body + + async def download_scan_log(self, group: str, scan_name: str) -> str: + """Download the saved scan log.""" + response = await self._request( + "GET", protocol._SAVELOG_URL_PATH, params={"group": group, "scan": scan_name} + ) + return response.text() + + async def wait_until_done( + self, + *, + timeout: float = 3600, + poll_interval: float = 1, + on_progress: Optional[Callable[[OdysseyStatus], None]] = None, + require_fresh: bool = True, + ) -> OdysseyStatus: + """Wait for a terminal state, optionally requiring a transition out of an initial idle state.""" + if ( + not math.isfinite(timeout) + or timeout <= 0 + or not math.isfinite(poll_interval) + or poll_interval <= 0 + ): + raise ValueError("timeout and poll_interval must be finite and positive") + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + require_transition = require_fresh + while True: + reading = await self.request_status() + if on_progress is not None: + on_progress(reading) + if reading.state == "Failed": + raise OdysseyScanError("Odyssey reported a failed scan") + if reading.state not in _TERMINAL_STATES: + require_transition = False + elif not require_transition: + return reading + remaining = deadline - loop.time() + if remaining <= 0: + raise TimeoutError( + f"Scan did not complete within {timeout:g} s (last state={reading.state})" + ) + await asyncio.sleep(min(poll_interval, remaining)) + + async def scan( + self, + *, + timeout: float = 3600, + poll_interval: float = 1, + on_progress: Optional[Callable[[OdysseyStatus], None]] = None, + ) -> OdysseyStatus: + """Start the configured scan and wait for completion. Download its images separately.""" + await self.start_scan() + return await self.wait_until_done( + timeout=timeout, poll_interval=poll_interval, on_progress=on_progress, require_fresh=False + ) + + async def stop_and_save(self, timeout: float = 15, poll_interval: float = 1) -> StopResult: + """Stop acquisition, wait for idle, and report which configured channels have TIFFs.""" + await self.stop_scan() + await self.wait_until_done(timeout=timeout, poll_interval=poll_interval, require_fresh=False) + available = [] + if self._current_scan is not None: + group, name = self._current_scan + for channel in self._channels: + try: + await self.download_channel(group, name, channel) + available.append(channel) + except OdysseyError as error: + logger.info("Channel %d unavailable after stop: %s", channel, error) + return StopResult(state="Stopped", partial=bool(available), channels_available=available) + + async def shutdown_instrument(self) -> str: + """Power off the instrument. Restarting the hardware can take 30 minutes.""" + logger.warning("Shutting down Odyssey instrument") + response = await self._request( + "GET", "/scanapp/admin/admin/index", params={"action": "InitiateShutdown"} + ) + return response.text() + + async def get_instrument_info(self) -> str: + """Read the instrument's serial number and software-version page as HTML.""" + response = await self._request("GET", "/scanapp/help/instinfo.pl") + return response.text() diff --git a/pylabrobot/li_cor/odyssey/odyssey_tests.py b/pylabrobot/li_cor/odyssey/odyssey_tests.py new file mode 100644 index 00000000000..fc2adc70f46 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/odyssey_tests.py @@ -0,0 +1,294 @@ +"""Odyssey wire-protocol and lifecycle tests; no real instrument is contacted.""" + +import io +import json +import unittest +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import parse_qs + +from pylabrobot.io.http import HTTP, HTTPResponse +from pylabrobot.li_cor import OdysseyChatterbox, OdysseyClassic +from pylabrobot.li_cor.odyssey import ( + OdysseyError, + OdysseyImageError, + OdysseyScanError, + OdysseyStatusError, +) +from pylabrobot.li_cor.odyssey.chatterbox import _tiff +from pylabrobot.li_cor.odyssey.tagging import build_identity_description, tag_tiff_with_identity + +try: + from PIL import Image # type: ignore[import-not-found] +except ImportError: + Image = None + + +class OdysseyProtocolTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.io = MagicMock(spec=HTTP) + self.io.base_url = "http://odyssey.test" + self.io.setup = AsyncMock() + self.io.stop = AsyncMock() + self.io.request_raw = AsyncMock(return_value=self.response("

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('In use') + with self.assertRaisesRegex(OdysseyScanError, "Busy"): + await self.device.configure_scan() + self.io.request_raw.assert_awaited_once() + with self.assertRaises(OdysseyScanError): + await self.device.start_scan() + + async def test_invalid_configuration_sends_nothing(self): + for invalid in ( + {"width": 251}, + {"focus": float("nan")}, + {"resolution": 169}, + {"channel_700": False, "channel_800": False}, + {"name": "../scan"}, + ): + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + await self.device.configure_scan(**invalid) + self.io.request_raw.assert_not_awaited() + + async def test_scan_commands_keep_wire_actions_and_do_not_retry(self): + await self.configure() + self.io.request_raw.reset_mock() + for method, action in [ + (self.device.start_scan, "start"), + (self.device.pause_scan, "pause"), + (self.device.stop_scan, "stop"), + (self.device.cancel_scan, "cancel"), + ]: + await method() + self.assertEqual( + self.io.request_raw.call_args.args[:2], + ("GET", f"/scanapp/scan/nonjava/command.pl?action={action}"), + ) + self.assertEqual(self.io.request_raw.await_count, 4) + self.io.request_raw.side_effect = OSError("connection lost") + with self.assertRaises(OSError): + await self.device.stop_scan() + self.assertEqual(self.io.request_raw.await_count, 5) + + async def test_http_error_does_not_report_success(self): + self.io.request_raw.return_value = self.response("unauthorized", 401) + with self.assertRaisesRegex(OdysseyError, "401"): + await self.device.request_status() + self.io.request_raw.return_value = self.response("server failure", 500) + with self.assertRaisesRegex(OdysseyError, "500"): + await self.device.stop_scan() + + async def test_status_parses_table_markup_and_percent(self): + self.io.request_raw.return_value = self.response( + "Scanner Status:escanning" + "Current User:operator" + "Percent Complete:12.5%" + "Time Remaining:2 minutes" + "Lid Status:Closed" + ) + status = await self.device.request_status() + self.assertEqual(status.state, "Scanning") + self.assertEqual(status.progress, 12.5) + self.assertFalse(status.lid_open) + self.assertEqual(status.current_user, "operator") + + async def test_unrecognized_status_is_not_idle(self): + for html in ("login page", "Scanner Status: Mystery"): + self.io.request_raw.return_value = self.response(html) + with self.assertRaises(OdysseyStatusError): + await self.device.request_status() + + async def test_wait_observes_scan_and_returns_idle(self): + self.io.request_raw.side_effect = [ + self.response(f"Scanner Status: {state}") for state in ("Idle", "Scanning", "Idle") + ] + reading = await self.device.wait_until_done(poll_interval=0.001) + self.assertEqual(reading.state, "Idle") + self.assertEqual(self.io.request_raw.await_count, 3) + + async def test_wait_times_out_on_unchanging_idle(self): + with self.assertRaises(TimeoutError): + await self.device.wait_until_done(timeout=0.01, poll_interval=0.001) + + async def test_wait_raises_on_failed_scan(self): + self.io.request_raw.return_value = self.response("Scanner Status: Failed") + with self.assertRaises(OdysseyScanError): + await self.device.wait_until_done() + + async def test_download_encodes_xml_and_preserves_channel_files(self): + raw = _tiff() + self.io.request_raw.return_value = HTTPResponse(200, raw, {"content-length": str(len(raw))}) + result = await self.device.download("group & one", "name with space") + self.assertEqual(result, {700: raw, 800: raw}) + path = self.io.request_raw.call_args_list[0].args[1] + self.assertTrue(path.startswith("/scan/image/name%20with%20space-700.tif?")) + xml = parse_qs(path.split("?", 1)[1])["xml"][0] + self.assertIn("group & one", xml) + self.assertIn("700", xml) + + async def test_download_rejects_truncated_tiff_and_html(self): + for response in ( + HTTPResponse(200, _tiff(), {"content-length": "9999"}), + self.response("login"), + ): + self.io.request_raw.return_value = response + with self.assertRaises(OdysseyImageError): + await self.device.download_channel("odyssey", "test", 700) + + async def test_stop_closes_connection_without_sending_scan_command(self): + await self.device.stop() + self.io.request_raw.assert_not_awaited() + self.assertFalse(self.device.connected) + with self.assertRaises(RuntimeError): + await self.device.list_groups() + + async def test_setup_failure_closes_transport(self): + await self.device.stop() + self.io.request_raw.return_value = self.response("unauthorized", 401) + self.io.stop.reset_mock() + with self.assertRaises(OdysseyError): + await self.device.setup() + self.assertFalse(self.device.connected) + self.io.stop.assert_awaited_once() + + def test_credentials_stay_out_of_serialized_settings(self): + device = OdysseyClassic(host="scanner", username="user", password="secret") + self.assertEqual(device.serialize(), {"host": "scanner", "port": 80, "timeout": 60}) + self.assertEqual(device.io.headers["Authorization"], "Basic dXNlcjpzZWNyZXQ=") + self.assertEqual(device.io.headers["Connection"], "close") + + +class OdysseyChatterboxTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.io = OdysseyChatterbox() + self.device = OdysseyClassic(io=self.io) + await self.device.setup() + + async def asyncTearDown(self): + await self.device.stop() + + async def configure(self, **kwargs): + with patch("pylabrobot.li_cor.odyssey.odyssey.asyncio.sleep", new_callable=AsyncMock): + await self.device.configure_scan(**kwargs) + + async def test_full_scan_and_separate_downloads(self): + await self.configure(name="membrane", group="lab") + result = await self.device.scan(poll_interval=0.001) + self.assertEqual((result.state, result.progress), ("Idle", 100)) + self.assertEqual(await self.device.list_scans("lab"), ["membrane"]) + self.assertNotIn("membrane", await self.device.list_scans("odyssey")) + images = await self.device.download("lab", "membrane") + self.assertEqual(set(images), {700, 800}) + self.assertTrue(images[700].startswith(b"II*\x00")) + + async def test_pause_resume_and_partial_stop(self): + await self.configure(name="partial", channel_800=False) + await self.device.start_scan() + before = await self.device.request_status() + await self.device.pause_scan() + paused = await self.device.request_status() + self.assertEqual(paused.progress, before.progress) + self.assertEqual(paused.state, "Paused") + await self.device.start_scan() + result = await self.device.stop_and_save(poll_interval=0.001) + self.assertEqual(result.channels_available, [700]) + self.assertTrue(result.partial) + + async def test_cancel_discards_output(self): + await self.configure(name="cancelled") + await self.device.start_scan() + await self.device.cancel_scan() + self.assertNotIn("cancelled", await self.device.list_scans("odyssey")) + self.assertEqual((await self.device.request_status()).state, "Idle") + + async def test_no_network_for_chatterbox(self): + with patch("urllib.request.build_opener", side_effect=AssertionError("network attempted")): + await self.configure(name="offline") + await self.device.scan(poll_interval=0.001) + self.assertTrue(await self.device.download_channel("odyssey", "offline", 700)) + + +class TaggingTests(unittest.TestCase): + def test_description_accepts_plain_identity_dict(self): + identity = {"pid": "https://example.org/instruments/1"} + result = json.loads(build_identity_description(identity, scan_name="scan", channel=700)) + self.assertEqual(result, {**identity, "scan_name": "scan", "channel": 700}) + self.assertEqual(identity, {"pid": "https://example.org/instruments/1"}) + + def test_empty_identity_and_bad_image_preserve_original_bytes(self): + self.assertEqual(tag_tiff_with_identity(b"bad image"), b"bad image") + self.assertEqual(tag_tiff_with_identity(b"bad image", {"pid": "unit"}), b"bad image") + + @unittest.skipIf(Image is None, "Pillow is not installed") + def test_tiff_tagging_preserves_pixels(self): + raw = _tiff() + tagged = tag_tiff_with_identity(raw, {"pid": "unit"}, channel=700) + with Image.open(io.BytesIO(raw)) as original, Image.open(io.BytesIO(tagged)) as image: + self.assertEqual(image.tobytes(), original.tobytes()) + self.assertEqual(json.loads(image.tag_v2[270]), {"pid": "unit", "channel": 700}) + self.assertEqual(image.tag_v2[305], "PyLabRobot Odyssey") diff --git a/pylabrobot/li_cor/odyssey/protocol.py b/pylabrobot/li_cor/odyssey/protocol.py new file mode 100644 index 00000000000..1ed94024b6a --- /dev/null +++ b/pylabrobot/li_cor/odyssey/protocol.py @@ -0,0 +1,169 @@ +"""CGI paths, XML queries, and HTML parsers for the Odyssey Classic.""" + +import re +from html.parser import HTMLParser +from typing import Optional +from xml.sax.saxutils import escape + +_SCAN_BASE = "/scanapp/scan/nonjava" + +_CONFIGURE_URL_PATH = f"{_SCAN_BASE}/configure.pl" + +_COMMAND_URL_PATH = f"{_SCAN_BASE}/command.pl" + +_INITIALIZING_URL_PATH = f"{_SCAN_BASE}/initializing.pl" + +_TIME_URL_PATH = f"{_SCAN_BASE}/time.pl" + +_INFO_URL_PATH = "/scanapp/imaging/nonjava/info.pl" + + +def _parse_error_block(body: str) -> Optional[str]: + """Extract message if present.""" + 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() + return f"{short}: {detail}" + return None + + +def _parse_info_html(html: str) -> dict[str, str]: + """Parse the imaging info panel HTML.""" + + def _extract(label: str) -> str: + pattern = rf"{label}\s*[:]\s*(?:<[^>]+>)*\s*([^<\n]+)" + match = re.search(pattern, html, re.IGNORECASE) + return match.group(1).strip() if match else "" + + return { + "dimensions": _extract("Dimensions"), + "file_size": _extract("File Size"), + "time_left": _extract("Time Left"), + } + + +_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"{escape(group)}" + f"{escape(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"{escape(group)}" + f"{escape(scan_name)}" + f"{zoom}" + f"{contrast_700}" + f"{contrast_800}" + f"{escape(channels)}" + f"{escape(background)}" + f"{x0}{x1}{y0}{y1}" + f"{'true' if vflip else 'false'}" + f"{'true' if hflip else 'false'}" + f"" + ) + + +class _SelectParser(HTMLParser): + """Collect decoded option values from one named HTML select element.""" + + def __init__(self, name: str) -> None: + super().__init__(convert_charrefs=True) + self.name = name + self.selected = False + self.options: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: + """Track the selected dropdown and preserve spaces in quoted option values.""" + attributes = dict(attrs) + if tag == "select": + self.selected = attributes.get("name") == self.name + elif tag == "option" and self.selected: + value = attributes.get("value") + if value is not None: + self.options.append(value) + + def handle_endtag(self, tag: str) -> None: + """Finish collecting when the select element closes.""" + if tag == "select": + self.selected = False + + +def _parse_select_options(html: str, select_name: str) -> list[str]: + """Extract complete, HTML-decoded option values from a named select.""" + parser = _SelectParser(select_name) + parser.feed(html) + return parser.options + + +_STATUS_BASE = "/scanapp/util/status" + +_STATUS_URL_PATH = f"{_STATUS_BASE}/" + +_STOP_FROM_STATUS_PATH = f"{_STATUS_BASE}/status" + + +def _parse_status_html(html: str) -> dict[str, str]: + """Parse the instrument status page HTML. + + Robust to tag layout: finds the label anywhere in the HTML + (case-insensitive), then walks forward skipping any tags and + whitespace until it hits the first non-empty text run. Handles + plain text, tags between label and colon, and table layouts. + """ + + def _extract(label: str) -> str: + idx = html.lower().find(label.lower()) + if idx < 0: + return "" + rest = html[idx + len(label) :] + rest = re.sub(r"^(?:\s|<[^>]+>)*:?", "", rest, count=1) + text = re.sub(r"<[^>]+>", "|", rest) + for fragment in text.split("|"): + trimmed = fragment.strip() + if trimmed: + return trimmed.split("\n")[0].strip() + return "" + + return { + "state": _extract("Scanner Status") or "Unknown", + "current_user": _extract("Current User"), + "progress": _extract("Percent Complete"), + "time_remaining": _extract("Time Remaining"), + "lid_status": _extract("Lid Status"), + } diff --git a/pylabrobot/li_cor/odyssey/tagging.py b/pylabrobot/li_cor/odyssey/tagging.py new file mode 100644 index 00000000000..da2e6b2ee13 --- /dev/null +++ b/pylabrobot/li_cor/odyssey/tagging.py @@ -0,0 +1,114 @@ +"""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 _resolve_identity(source: Optional[dict[str, Any]]) -> dict[str, Any]: + """Coerce the identity source into a plain dict. + + Accepts a plain identity dictionary or None. + """ + if source is None: + return {} + return dict(source) + + +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. + ``identity`` is a dictionary of per-instrument metadata. + """ + payload: dict[str, Any] = _resolve_identity(identity) + 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. + + ``identity`` is a dictionary. 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 + 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] + 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( + resolved, + 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 diff --git a/pyproject.toml b/pyproject.toml index de14549cd74..240d8905561 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,8 @@ sila = ["zeroconf>=0.131.0", "grpcio"] cytation-microscopy = ["numpy>=1.26", "opencv-python", "PyGObject"] pico = ["PyLabRobot[sila]", "opencv-python", "numpy"] xarm = ["xarm-python-sdk"] -all = ["PyLabRobot[serial,usb,ftdi,hid,btx,modbus,websockets,visualizer,opentrons,sila,pico,xarm]"] +odyssey = ["Pillow"] +all = ["PyLabRobot[serial,usb,ftdi,hid,btx,modbus,websockets,visualizer,opentrons,sila,pico,xarm,odyssey]"] test = [ "pytest", ]