From 08cf0349bdc47a6e17e7cc07cd0d6b6fbac7e4be Mon Sep 17 00:00:00 2001 From: menzel Date: Mon, 17 Aug 2026 19:56:17 +0200 Subject: [PATCH] fix(canon): query info.cgi without the session; drop mjpeg; add Stage 7 check Three changes, all driven by what the camera actually did. info.cgi must not carry the session id -------------------------------------- Hardware run on 2026-08-17: get_camera_info() succeeded but every position lookup raised "axis 'pan' (c.1.pan) not in info.cgi response". Bare info.cgi returns the full ~32 kB device state; `info.cgi?s=` returns only the session-scoped `s.*` subset -- which contains s.hardware and s.firmware, so identity worked while nothing under c.1.* existed. The session parameter is now omitted for info.cgi (and open.cgi, as before); control CGIs still carry it. This is what unblocks Stages 2 and 3. Drop the mjpeg acquisition mode ------------------------------- The CR-N300 implements no MJPEG operator -- nphMotionJpeg, GetLiveImage and the rest all answer "Unknown Operator" -- so the mode could never have produced a frame here. Removed rather than left as a config value that silently does nothing: ACQUISITION_MODES is now (jpeg_poll, jpeg_on_demand), and MJPEGHttpSource, the stream_url key and DEFAULT_STREAM_CGI go with it. Re-adding a streaming mode means writing one FrameSource; the worker, ring buffer and grab API are indifferent to how frames arrive. The JPEG path is 1280x720 at up to 12.5 fps (w.3); 4K is H.264-only, so nobody should expect full-resolution frames through the grab API. acquisition_check.py -- the Stage 7 tool ----------------------------------------- Drives the production acquisition path (SingleJpegPollSource + StreamWorker + FrameRingBuffer) and reports establishment time, achieved rate, measured bandwidth, grab latency and -- with --decode -- a real cv2 decode with its resolution. Standard-library only and runnable from a copied canon/ directory, like probe.py, so it works before BEC is installed on a beamline machine. It is read-only, which means **Stage 7 does not depend on Stage 4**: the whole acquisition path can be closed with the camera still on the bench, waiting to be mounted. COMMISSIONING gains this as step 7.0b. Tests: 86. New coverage for the sessionless info.cgi (regression for the exact hardware failure), mjpeg now being rejected at construction, and the Stage 7 tool end-to-end against the mock camera including its non-zero exit when nothing answers. Co-Authored-By: Claude Opus 5 (1M context) --- csaxs_bec/device_configs/canon_crn300.yaml | 16 +- csaxs_bec/devices/canon/COMMISSIONING.md | 44 +-- csaxs_bec/devices/canon/README.md | 50 +++- .../devices/canon/acquisition/__init__.py | 9 +- .../canon/acquisition/stream_worker.py | 72 +---- csaxs_bec/devices/canon/acquisition_check.py | 274 ++++++++++++++++++ csaxs_bec/devices/canon/canon_ptz.py | 70 ++--- csaxs_bec/devices/canon/transport/xc.py | 14 +- tests/tests_devices/test_canon_device.py | 27 +- tests/tests_devices/test_canon_probe.py | 51 ++++ .../tests_devices/test_canon_stream_worker.py | 25 -- tests/tests_devices/test_canon_transport.py | 30 ++ 12 files changed, 476 insertions(+), 206 deletions(-) create mode 100644 csaxs_bec/devices/canon/acquisition_check.py diff --git a/csaxs_bec/device_configs/canon_crn300.yaml b/csaxs_bec/device_configs/canon_crn300.yaml index f4002047..1ee750f0 100644 --- a/csaxs_bec/device_configs/canon_crn300.yaml +++ b/csaxs_bec/device_configs/canon_crn300.yaml @@ -10,7 +10,7 @@ ptz_cam: description: Canon CR-N300 PTZ remote camera (endstation overview) deviceClass: csaxs_bec.devices.canon.canon_ptz.CanonPTZCamera deviceConfig: - host: 192.168.0.100 # camera IP / hostname + host: 129.129.122.151 # camera IP / hostname (X12SA, DHCP lease) port: 80 # XC HTTP port (default 80) timeout: 5.0 # per-request transport timeout [s] # cgi_prefix: "-wvhttp-01-" # XC CGI path prefix (verify vs BPE-7216-005) @@ -19,21 +19,19 @@ ptz_cam: pan_speed: 60 # optional default move speed [deg/s] tilt_speed: 60 # --- Phase-2 acquisition (safe to leave at defaults for control-only use) --- - # acquisition_mode picks the network footprint; the grab API is the same for - # mjpeg and jpeg_poll: - # jpeg_poll one still JPEG every 1/poll_rate s. Low bandwidth, idle CPU - # ~0, full grab API, frame may be <= 1 poll interval old. - # mjpeg continuous MJPEG connection held open all session. Lowest - # latency and full frame rate, but continuous 4K bandwidth -- - # pick this only when high rate is genuinely needed. + # acquisition_mode picks the network footprint: + # jpeg_poll one still JPEG every 1/poll_rate s. Low bandwidth (~0.26 + # Mbit/s at 2 Hz, measured), idle CPU ~0, full grab API, + # frame may be <= 1 poll interval old. # jpeg_on_demand no worker at all; each get_latest() fetches one still. Zero # idle cost, but no history: get_latest_n/get_nearest raise. + # The camera's JPEG stream is 1280x720 at up to 12.5 fps; 4K is H.264-only, + # and this firmware implements no MJPEG endpoint. acquisition_mode: "jpeg_poll" poll_rate: 2.0 # [Hz] jpeg_poll only stream_start: "off" # off | connect | stage (when to start acquisition) buffer_maxlen: 64 # ring-buffer depth (compressed frames) # still_url: http://192.168.0.100/-wvhttp-01-/image.cgi # jpeg_poll URL override - # stream_url: http://192.168.0.100/-wvhttp-01-/image.cgi # mjpeg URL override deviceTags: - camera - ptz diff --git a/csaxs_bec/devices/canon/COMMISSIONING.md b/csaxs_bec/devices/canon/COMMISSIONING.md index 924e7b0d..88fb68a9 100644 --- a/csaxs_bec/devices/canon/COMMISSIONING.md +++ b/csaxs_bec/devices/canon/COMMISSIONING.md @@ -144,10 +144,12 @@ footprint behind one grab API. Default is **`jpeg_poll`**. | Mode | What `start_stream()` does | Idle bandwidth | `get_latest()` | History API | |---|---|---|---|---| -| `jpeg_poll` *(default)* | starts a worker polling the still endpoint at `poll_rate` Hz | low | O(1), ≤ 1 poll old | ✅ | -| `mjpeg` | opens the continuous MJPEG stream once, starts the worker | high (continuous 4K) | O(1), sub-ms | ✅ | +| `jpeg_poll` *(default)* | starts a worker polling the still endpoint at `poll_rate` Hz | ~0.26 Mbit/s at 2 Hz | O(1), ≤ 1 poll old | ✅ | | `jpeg_on_demand` | prepares the buffer only — **no worker, no connection held** | none | one synchronous HTTP fetch | ❌ raises | +> A `mjpeg` mode existed until 2026-08-17; this firmware implements no MJPEG +> operator, so it was removed. The JPEG path is 1280×720 at up to 12.5 fps. + - **`start_stream()`** — idempotent. ([R10](#r10) governs the endpoints.) - **`get_latest()`** — returns a `Frame` (compressed bytes + monotonic & wall-clock timestamps + source id); `decode=True` decodes **only that frame** to an RGB array @@ -172,7 +174,7 @@ footprint behind one grab API. Default is **`jpeg_poll`**. ## 3. What has already been tested -**76 automated tests, no hardware required** (`pytest tests/tests_devices/test_canon_*.py +**86 automated tests, no hardware required** (`pytest tests/tests_devices/test_canon_*.py --random-order`, run under `OPHYD_CONTROL_LAYER=dummy`). They prove the **Python logic** using the in-memory `FakeTransport` and a **mocked HTTP opener** — i.e. they confirm the device builds the requests we *intend* and parses the response format we *assume*. They @@ -230,7 +232,7 @@ invalid mode/`poll_rate` rejected at construction; `jpeg_poll` fills the buffer the **full** grab API incl. `get_nearest`; `jpeg_poll` targets the **still** endpoint (not the stream URL); `jpeg_on_demand` starts **no worker**, fetches fresh bytes **per grab**, works **without** `start_stream()`, and **raises** on `get_latest_n`/`get_nearest`; -`mjpeg` still streams and honours a `stream_url` override. +the removed `mjpeg` mode is now rejected at construction. --- @@ -249,8 +251,8 @@ is the actual subject of [the test regime in §6](#6-commissioning-test-regime). | **Focus AF modes** | That `continuous_af`/`face_af`/`tracking_af` engage as labelled — [R6](#r6). | | **Presets** | That `recall_preset(id)` recalls the expected stored position — [R3](#r3). | | **Still endpoint** | That the camera serves a complete standalone JPEG there — both default modes rest on it — [R10](#r10). | -| **Acquisition establishment & latency** | The real setup cost (~2 s for `mjpeg`, one round-trip for `jpeg_poll`), steady frame rate, and grab latency — [R10](#r10). | -| **Actual bandwidth per mode** | That `jpeg_poll` really is the light option on this network, and what `mjpeg` costs — measurable only against the camera. | +| **Acquisition establishment & latency** | The real setup cost (one round-trip for `jpeg_poll`), achieved frame rate, and grab latency — [R10](#r10). | +| **Actual bandwidth** | That `jpeg_poll` really is light on this network — measurable only against the camera (`acquisition_check.py` reports it). | | **JPEG decode of real frames** | `decode=True` against real camera JPEGs (sim uses non-image bytes). | | **BEC integration** | Entry-point resolution, Redis, scan staging, GUI preview, file-writer delivery (Phase 2). | | **Multi-client contention** | claim/yield against another client already holding control. | @@ -315,7 +317,6 @@ constants in `xc.py`, both defaulting to `image.cgi`; on real firmware they may | Constant | Used by | Default | Symptom if wrong | |---|---|---|---| -| `DEFAULT_STREAM_CGI` | `acquisition_mode: mjpeg` — parsed as multipart MJPEG split on `FFD8…FFD9` | `image.cgi` | worker never produces a frame | | `DEFAULT_STILL_CGI` (also `_CGI["still"]`) | `jpeg_poll` (polled over HTTP) and `jpeg_on_demand` (via `transport.get_still_jpeg()`) | `image.cgi` | `CanonCommandError: … did not return a JPEG` | *Confirm:* both real URLs, and that MJPEG frames are standalone JPEGs. *Fix:* the two @@ -353,7 +354,7 @@ the bench**; Stage 1+ need it on the network. ### Stage 0 — Pre-hardware (no camera needed) — do this first 0.1 **Run the suite.** `OPHYD_CONTROL_LAYER=dummy pytest tests/tests_devices/test_canon_*.py ---random-order`. *Expect 76 passed.* If not, stop — the environment is wrong, not the +--random-order`. *Expect 86 passed.* If not, stop — the environment is wrong, not the camera. 0.2 **Point the editable install at *this* worktree — before starting BEC.** `csaxs_bec` @@ -410,7 +411,7 @@ cp csaxs_bec/devices/canon/probe.py /tmp/ && (cd /tmp && python3 probe.py --host `# SPEC:` tables in `transport/xc.py` you can already confirm from the document. *Every correction here saves a hardware round-trip.* -**Gate:** 76 green + the canon worktree is the live install + sim device usable in BEC + +**Gate:** 86 green + the canon worktree is the live install + sim device usable in BEC + probe rehearsal shows a full session + SPEC tables reviewed. → proceed. ### Stage 0.5 — First contact (hardware just arrived) — **start here** @@ -496,7 +497,7 @@ too) and that the camera is drivable headless, not just from its own JavaScript. 0.5.5 **Correct the SPEC tables, then re-probe.** Edit the marked tables at the top of [`transport/xc.py`](./transport/xc.py) — `DEFAULT_CGI_PREFIX`, `_CGI`, `DEFAULT_STILL_CGI` -/ `DEFAULT_STREAM_CGI`, `_PARAM`, `_AXIS_ENCODING` — to match what you observed. Re-run +, `_PARAM`, `_AXIS_ENCODING` — to match what you observed. Re-run the probe until step 5 reports a session. **Commit the corrected tables with the evidence in the message** (the observed URL for each change); they are the documented truth for the next deployment. @@ -616,11 +617,21 @@ data = t.get_still_jpeg(); print(len(data), data[:4]) disk and open it. → `CanonCommandError: … did not return a JPEG` or a 404 ⇒ [R10](#r10) `DEFAULT_STILL_CGI`. **This one call de-risks `jpeg_poll` and `jpeg_on_demand` together.** +7.0b **Run the acquisition check.** One command covers 7.1, 7.2, 7.3 and 7.6, using the +production poll source, worker and ring buffer, without BEC or a venv: +```bash +cd ~/scratch/csaxs_bec/csaxs_bec/devices/canon +python3 acquisition_check.py --host --duration 30 --decode \ + --save /tmp/frame.jpg --json /tmp/stage7.json +``` +It is read-only, so it is safe **before** the camera is mounted — Stage 7 does not +depend on Stage 4. Work through the steps below only if something it reports looks off. + 7.1 **Establish.** `dev.ptz_cam.start_stream()`, then `dev.ptz_cam.stream_stats()`. *Confirm* `mode` is what you configured, `worker.establishment_time_s` is populated, and `buffer.size` grows. In `jpeg_poll` establishment should be ~one HTTP round-trip (not -~2 s) and `buffer.size` should grow at about `poll_rate` Hz; in `mjpeg` expect the ~2 s -cost **once**. → never any frame ⇒ [R10](#r10). *(In `jpeg_on_demand` there is no worker: +~2 s) and `buffer.size` should grow at about `poll_rate` Hz. → never any frame ⇒ +[R10](#r10). *(In `jpeg_on_demand` there is no worker: `stream_stats()` has no `worker` key — that is correct, skip to 7.3.)* 7.2 **Grab latency.** Time `dev.ptz_cam.get_latest()` — in the buffered modes expect @@ -642,11 +653,10 @@ incrementing, `healthy` recovering, and **DAQ never blocks** (last good frames s served). In `jpeg_poll` a failed poll is logged and retried with backoff. 7.6 **Bandwidth check (the reason `jpeg_poll` is the default).** With the stream running, -measure actual traffic to the camera (e.g. `iftop`/`nload` on the device-server host, or -switch port counters). *Confirm* `jpeg_poll` sits at roughly `poll_rate × JPEG size` and -that `mjpeg`, if you try it, is the order-of-magnitude heavier continuous load the -default exists to avoid. Record both numbers — they are the evidence for the deployed -mode choice. +run `acquisition_check.py`, which reports mean frame size, achieved rate and the +resulting Mbit/s directly. *Confirm* it sits near `poll_rate × JPEG size` — about +0.26 Mbit/s at 2 Hz — against the camera's 20 Mbit/s H.264 stream (`w.1.cbr:=20000`). +Record the number; it is the evidence for the deployed mode. **Gate:** the still endpoint serves real JPEGs, the configured mode establishes and grabs cheaply, decode works, reconnect is fail-soft, and measured bandwidth is acceptable to the diff --git a/csaxs_bec/devices/canon/README.md b/csaxs_bec/devices/canon/README.md index 24781e54..4dcfaf99 100644 --- a/csaxs_bec/devices/canon/README.md +++ b/csaxs_bec/devices/canon/README.md @@ -10,7 +10,7 @@ that DAQ can grab from on demand (Phase 2). | Phase | Scope | State | |-------|-------|-------| | **1** | Standalone control: parameters + PTZ/zoom/focus motion, no imaging stack needed | **implemented** | -| **2** | Persistent acquisition worker + ring buffer + grab API + preview/file-writer delivery | **seams in place; network pull + decode to be finished against hardware** | +| **2** | Acquisition worker + ring buffer + grab API | **implemented**; preview / file-writer delivery still outstanding | ## Architecture @@ -24,10 +24,11 @@ devices/canon/ ├── acquisition/ # imaging plane — pure stdlib, no ophyd, no transport import │ ├── ring_buffer.py # FrameRingBuffer — bounded, stores *compressed* frames │ └── stream_worker.py # StreamWorker + FrameSource (persistent, fail-soft) -│ # sources: MJPEGHttpSource, SingleJpegPollSource, Iterable… +│ # sources: SingleJpegPollSource, IterableFrameSource ├── positioner.py # CanonAxis — transport-backed ophyd positioner ├── canon_ptz.py # CanonPTZCamera — the BEC ophyd device -└── probe.py # operator tool: read-only hardware first-contact probe +├── probe.py # operator tool: read-only first-contact probe +└── acquisition_check.py # operator tool: Stage 7 acquisition & bandwidth check ``` Key boundaries: @@ -118,7 +119,7 @@ full real-camera and simulation examples. Minimal real-camera entry: ptz_cam: deviceClass: csaxs_bec.devices.canon.canon_ptz.CanonPTZCamera deviceConfig: - host: 192.168.0.100 + host: 129.129.122.151 port: 80 enabled: true readoutPriority: on_request @@ -138,7 +139,7 @@ ptz_cam_sim: `deviceConfig` keys: `host`, `port`, `timeout`, `cgi_prefix`, `simulation`, `gradual_motion`, `username_env`, `password_env`, `pan_speed`, `tilt_speed`, `buffer_maxlen`, `stream_start` (`off` | `connect` | `stage`), `acquisition_mode` -(`mjpeg` | `jpeg_poll` | `jpeg_on_demand`), `poll_rate`, `still_url`, `stream_url`. +(`jpeg_poll` | `jpeg_on_demand`), `poll_rate`, `still_url`. ## Supported commands @@ -188,19 +189,15 @@ arrive is a `FrameSource` choice, not a redesign. Set it with `acquisition_mode` | Mode | Idle bandwidth | Idle CPU | Grab latency | Grab API | Establish cost | |------|----------------|----------|--------------|----------|----------------| -| `jpeg_poll` **(default)** | low (one JPEG × `poll_rate`) | ~0 | sub-ms (≤ 1 poll old) | full | none/cheap | -| `mjpeg` | high (continuous 4K) | ~0 | sub-ms | full | ~2 s, once | +| `jpeg_poll` **(default)** | ~0.26 Mbit/s at 2 Hz (measured) | ~0 | sub-ms (≤ 1 poll old) | full | one round-trip | | `jpeg_on_demand` | none | none | one HTTP round-trip per grab | `get_latest` only | none | -* **`jpeg_poll`** polls the camera's single-JPEG endpoint at `poll_rate` Hz (default 2). +* **`jpeg_poll`** polls the camera's single-JPEG endpoint at `poll_rate` Hz (default 2; + the camera caps the JPEG path at 1280×720 and 12.5 fps). It is the default because it keeps the entire buffer-and-grab benefit — O(1) grabs, - `get_nearest` timestamp correlation, fail-soft reconnect — at a small fraction of MJPEG - bandwidth. The only cost is temporal resolution: the newest frame may be up to one poll - interval old. -* **`mjpeg`** holds one connection open for the whole session, paying the ~2 s - establishment up front (never inside a scan). Choose it deliberately, when genuine - high-rate/low-latency capture is needed — a 4K/30 fps camera then streams continuously - whether or not anyone grabs. + `get_nearest` timestamp correlation, fail-soft reconnect — for about 1.3% of the + bandwidth of the camera's continuous H.264 stream. The only cost is temporal + resolution: the newest frame may be up to one poll interval old. * **`jpeg_on_demand`** starts no worker and buffers nothing ahead: each `get_latest()` performs one synchronous still fetch through the already-authenticated control session (`transport.get_still_jpeg()`). Zero idle footprint, but there is no history — so @@ -232,7 +229,7 @@ grab API alone. > Multiple cameras = multiple devices, each with its own worker + buffer. An inter-frame > codec (H.264/H.265 over RTSP) would force continuous decoding in the worker thread and -> is deliberately out of scope; the JPEG/MJPEG path keeps frames independently decodable. +> is deliberately out of scope; the JPEG path keeps frames independently decodable. ## Tests @@ -248,3 +245,24 @@ reconnect, poll pacing/interruptibility, no thread leak), and the ophyd device ( status, limits, parameter read-back verification, focus-mode gating, all three acquisition modes, credential handling). ``` + +> A continuous-MJPEG mode existed until the protocol was measured on 2026-08-17: this +> camera implements no MJPEG operator, so the mode could never have produced a frame and +> was removed rather than left as a config value that silently does nothing. Adding a +> streaming mode back means writing one `FrameSource`; the worker, ring buffer and grab +> API are indifferent to how frames arrive. + +## Commissioning tools + +Two read-only operator tools, both standard-library only and runnable from a copied +`canon/` directory with nothing installed: + +```bash +python3 probe.py --host # first contact: reachability, CGIs, session +python3 acquisition_check.py --host # Stage 7: establish, rate, bandwidth, grabs +``` + +`acquisition_check.py` drives the production acquisition path — the same poll source, +worker and ring buffer the device uses — and reports establishment time, achieved rate, +measured bandwidth, grab latency and (with `--decode`) a real JPEG decode. It never sends +a control or motion command, so it is safe before the camera is mounted. diff --git a/csaxs_bec/devices/canon/acquisition/__init__.py b/csaxs_bec/devices/canon/acquisition/__init__.py index d1f00a1d..052e2b82 100644 --- a/csaxs_bec/devices/canon/acquisition/__init__.py +++ b/csaxs_bec/devices/canon/acquisition/__init__.py @@ -8,20 +8,13 @@ so standalone control keeps working when the imaging stack is absent or disabled from __future__ import annotations from .ring_buffer import Frame, FrameRingBuffer -from .stream_worker import ( - FrameSource, - IterableFrameSource, - MJPEGHttpSource, - SingleJpegPollSource, - StreamWorker, -) +from .stream_worker import FrameSource, IterableFrameSource, SingleJpegPollSource, StreamWorker __all__ = [ "Frame", "FrameRingBuffer", "FrameSource", "IterableFrameSource", - "MJPEGHttpSource", "SingleJpegPollSource", "StreamWorker", ] diff --git a/csaxs_bec/devices/canon/acquisition/stream_worker.py b/csaxs_bec/devices/canon/acquisition/stream_worker.py index 287d20f5..38948a27 100644 --- a/csaxs_bec/devices/canon/acquisition/stream_worker.py +++ b/csaxs_bec/devices/canon/acquisition/stream_worker.py @@ -18,14 +18,16 @@ Frame sources -- the network-footprint seam it does not care how the bytes arrive. That makes the *bandwidth profile* a choice of :class:`FrameSource`, not a redesign: -* :class:`MJPEGHttpSource` -- holds one HTTP connection open and reads continuously. - Lowest latency, but continuous bandwidth (a 4K/30 fps camera held open all session). -* :class:`SingleJpegPollSource` -- fetches one still JPEG per poll interval. Bandwidth - is one JPEG times a few Hz instead of 30 fps, while the ring buffer and the whole - O(1) grab API (``get_latest`` / ``get_latest_n`` / ``get_nearest``) keep working; - the only cost is that the newest frame may be up to one poll interval old. +* :class:`SingleJpegPollSource` -- fetches one still JPEG per poll interval, so + bandwidth is one JPEG times a few Hz while the ring buffer and the whole O(1) grab + API (``get_latest`` / ``get_latest_n`` / ``get_nearest``) keep working; the only + cost is that the newest frame may be up to one poll interval old. * :class:`IterableFrameSource` -- synthetic/offline feed for simulation and tests. +A continuous-MJPEG source lived here until the CR-N300 protocol was measured: that +camera implements no MJPEG operator, so it was removed rather than kept as dead code. +Adding one back is a single class implementing this interface -- nothing else changes. + A third, *worker-less* mode ("fetch now") lives in the ophyd device rather than here: it has no source and no buffer-ahead, so there is nothing to keep alive. @@ -58,9 +60,8 @@ except Exception: # pragma: no cover from .ring_buffer import FrameRingBuffer -# JPEG start-of-image / end-of-image markers, used to split an MJPEG byte stream. +# JPEG start-of-image marker, used to sanity-check fetched payloads. _JPEG_SOI = b"\xff\xd8" -_JPEG_EOI = b"\xff\xd9" def _build_opener(url: str, auth: tuple[str, str] | None) -> urllib.request.OpenerDirector: @@ -139,59 +140,12 @@ class IterableFrameSource(FrameSource): self._closed = True -class MJPEGHttpSource(FrameSource): - """Read an MJPEG (multipart JPEG) HTTP stream and yield individual JPEG frames. - - Standard-library only (``urllib``). Splits the byte stream on JPEG SOI/EOI - markers -- the same approach the repo's ``WebcamViewer`` uses -- but, crucially, - yields the *compressed* JPEG bytes without decoding them. Decoding is deferred to - grab time. - """ - - def __init__(self, url: str, *, chunk_size: int = 4096, auth: tuple[str, str] | None = None): - self.url = url - self.chunk_size = chunk_size - self._auth = auth - self._response = None - self._buffer = b"" - - def open(self, *, timeout: float | None = None) -> None: - self._response = _build_opener(self.url, self._auth).open(self.url, timeout=timeout) - self._buffer = b"" - - def frames(self) -> Iterator[bytes]: - if self._response is None: - raise RuntimeError("MJPEGHttpSource.open() must be called before frames()") - while True: - chunk = self._response.read(self.chunk_size) - if not chunk: - break - self._buffer += chunk - while True: - start = self._buffer.find(_JPEG_SOI) - end = self._buffer.find(_JPEG_EOI, start + 2) if start != -1 else -1 - if start == -1 or end == -1: - break - jpg = self._buffer[start : end + 2] - self._buffer = self._buffer[end + 2 :] - yield jpg - - def close(self) -> None: - if self._response is not None: - try: - self._response.close() - except Exception: # pylint: disable=broad-except - pass - self._response = None - self._buffer = b"" - - class SingleJpegPollSource(FrameSource): """Poll a *single-JPEG* (still-image) endpoint at a fixed rate. - The network-friendly alternative to :class:`MJPEGHttpSource`: instead of holding a - continuous video stream open, this fetches one complete JPEG per poll interval. - Bandwidth becomes ``one JPEG x rate`` rather than the camera's full frame rate, + Instead of holding a continuous video stream open, this fetches one complete JPEG + per poll interval. Bandwidth becomes ``one JPEG x rate`` rather than the camera's + full frame rate, idle CPU stays ~0, and -- because the frames still land in the ring buffer -- the whole grab API (``get_latest`` / ``get_latest_n`` / ``get_nearest``) keeps working at O(1). The trade-off is temporal resolution: the newest buffered frame may be up @@ -219,7 +173,7 @@ class SingleJpegPollSource(FrameSource): ----- Each HTTP poll is a fresh request (``urllib`` does not pool connections), so at high rates the per-request TCP/auth setup starts to dominate -- this source is - aimed at the few-Hz regime it was designed for, not at replacing MJPEG at 30 fps. + aimed at the few-Hz regime it was designed for. A failing fetch propagates to :class:`StreamWorker`, which logs it and retries with backoff; the last good frames keep being served meanwhile. """ diff --git a/csaxs_bec/devices/canon/acquisition_check.py b/csaxs_bec/devices/canon/acquisition_check.py new file mode 100644 index 00000000..8c1a473c --- /dev/null +++ b/csaxs_bec/devices/canon/acquisition_check.py @@ -0,0 +1,274 @@ +"""Commissioning Stage 7 -- acquisition & grab, measured against real hardware. + +Companion to :mod:`probe.py`. Where the probe answers "can we talk to it at all", +this answers "does the buffer-and-grab path actually work, and what does it cost". +It exercises the **production acquisition path** -- the same +:class:`SingleJpegPollSource` + :class:`StreamWorker` + :class:`FrameRingBuffer` the +ophyd device uses -- without ophyd, BEC or a venv, so it can run before the BEC stack +is installed on a beamline machine. + +Usage:: + + python3 acquisition_check.py --host 129.129.122.151 + python3 acquisition_check.py --host --rate 5 --duration 30 --decode + python3 acquisition_check.py --host --save /tmp/frame.jpg --json stage7.json + +Credentials come from ``CANON_PROBE_USER`` / ``CANON_PROBE_PASSWORD`` (pass them via +the environment, not argv -- other users can read a command line via ``ps``). + +Read-only: it issues GETs to the still endpoint and never sends a control or motion +command, so it is safe with the camera unmounted and is **not** gated on Stage 4. + +What it covers, mapped to COMMISSIONING.md +------------------------------------------ +* **7.1 establish** -- time to first frame, and that the buffer keeps filling +* **7.2 grab latency** -- ``get_latest`` / ``get_latest_n`` / ``get_nearest`` timed + over many calls; these must be O(1) and far below the poll interval +* **7.3 decode** -- optional; decodes one real frame via cv2 and reports its size +* **7.5 resilience** -- reports reconnects and health as observed during the run +* **7.6 bandwidth** -- measured bytes/second, the evidence for the chosen mode + +Requires only the standard library. ``--decode`` additionally needs cv2 and numpy; +without them the check still runs and says so. +""" + +from __future__ import annotations + +import argparse +import json +import os +import statistics +import sys +import time + +_DEFAULT_STILL_PATH = "/-wvhttp-01-/image.cgi" + + +def _import_acquisition(): + """Import the acquisition subsystem, whether packaged or run as a loose file. + + Mirrors probe.py: the ordinary package import pulls in ophyd via the device + class, so fall back to importing ``acquisition/`` as a top-level package. That + subsystem is standard-library only, so a copied ``canon/`` directory works on a + bare system python3. + """ + try: + from .acquisition import ( # pylint: disable=import-outside-toplevel + FrameRingBuffer, + SingleJpegPollSource, + StreamWorker, + ) + + return FrameRingBuffer, SingleJpegPollSource, StreamWorker + except ImportError: + pass + here = os.path.dirname(os.path.abspath(__file__)) + if here not in sys.path: + sys.path.insert(0, here) + # Resolved at runtime via the sys.path insert above; unresolvable statically. + from acquisition import ( # pylint: disable=import-outside-toplevel,import-error + FrameRingBuffer, + SingleJpegPollSource, + StreamWorker, + ) + + return FrameRingBuffer, SingleJpegPollSource, StreamWorker + + +def _fmt_bytes(n: float) -> str: + for unit in ("B", "kB", "MB"): + if n < 1024 or unit == "MB": + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} MB" + + +def run(args) -> dict: + FrameRingBuffer, SingleJpegPollSource, StreamWorker = _import_acquisition() + + url = args.url or f"http://{args.host}:{args.port}{args.still_path}" + user = os.environ.get("CANON_PROBE_USER") + password = os.environ.get("CANON_PROBE_PASSWORD") + auth = (user, password) if user is not None else None + + report: dict = { + "url": url, + "rate_hz": args.rate, + "duration_s": args.duration, + "buffer_maxlen": args.maxlen, + "authenticated": auth is not None, + } + + print("=" * 72) + print(f"Canon CR-N300 acquisition check -- {url}") + print(f"poll rate {args.rate} Hz · {args.duration} s · buffer {args.maxlen} frames") + print("=" * 72) + + # -- 7.1 establish ----------------------------------------------------- + print("\n[1] Establish -- start the worker and wait for the first frame") + buffer = FrameRingBuffer(args.maxlen, source_id="stage7") + source = SingleJpegPollSource(url, rate=args.rate, auth=auth, timeout=args.timeout) + worker = StreamWorker(source, buffer, name="stage7") + + t0 = time.monotonic() + worker.start() + if not worker.wait_for_first_frame(timeout=args.timeout + 5.0): + print(" FAIL no frame arrived. Check the still endpoint and credentials.") + worker.stop() + report["ok"] = False + return report + print(f" OK first frame after {worker.establishment_time:.3f} s") + print(f" (one HTTP round-trip -- there is no stream to establish)") + + # -- collect ----------------------------------------------------------- + print(f"\n[2] Collect for {args.duration} s") + deadline = time.monotonic() + args.duration + while time.monotonic() < deadline: + time.sleep(0.25) + elapsed = time.monotonic() - t0 + + stats = worker.stats() + bstats = buffer.stats() + frames = stats["frames_received"] + measured_rate = frames / elapsed if elapsed else 0.0 + print(f" frames received : {frames} in {elapsed:.1f} s -> {measured_rate:.2f} Hz") + print(f" requested rate : {args.rate} Hz") + print( + f" buffer : {bstats['size']}/{bstats['maxlen']} held, " + f"{bstats['dropped_unread']} cycled out" + ) + print(f" healthy : {stats['healthy']} · reconnects: {stats['reconnects']}") + + if measured_rate < args.rate * 0.7: + print(" WARN well below the requested rate -- the camera or network may be") + print(" slower than one JPEG per interval. Lower --rate for production.") + + # -- 7.6 bandwidth ----------------------------------------------------- + snapshot = buffer.snapshot() + sizes = [f.nbytes for f in snapshot] + mean_size = statistics.fmean(sizes) if sizes else 0.0 + bw_bytes = mean_size * measured_rate + print("\n[3] Bandwidth (7.6) -- the evidence for the acquisition mode") + print(f" mean frame : {_fmt_bytes(mean_size)}") + print(f" measured : {_fmt_bytes(bw_bytes)}/s = {bw_bytes * 8 / 1e6:.2f} Mbit/s") + print(f" at 2 Hz default : {mean_size * 2 * 8 / 1e6:.2f} Mbit/s") + print(" compare h264 overview stream: 20 Mbit/s (w.1.cbr:=20000)") + + # -- 7.2 grab latency -------------------------------------------------- + print("\n[4] Grab latency (7.2) -- must be O(1), far below the poll interval") + lat: dict[str, float] = {} + for label, call in ( + ("get_latest", lambda: buffer.get_latest()), + ("get_latest_n(5)", lambda: buffer.get_latest_n(5)), + ("get_nearest(now)", lambda: buffer.get_nearest(time.time())), + ): + samples = [] + for _ in range(args.grabs): + g0 = time.perf_counter() + call() + samples.append((time.perf_counter() - g0) * 1e6) + median = statistics.median(samples) + lat[label] = median + print(f" {label:<18} median {median:8.1f} us max {max(samples):8.1f} us") + interval_us = 1e6 / args.rate + if max(lat.values()) > interval_us * 0.01: + print(" WARN a grab is taking >1% of the poll interval; expected microseconds.") + else: + print(f" OK all grabs are far below the {interval_us / 1000:.0f} ms poll interval") + + # -- frame metadata ---------------------------------------------------- + newest = buffer.get_latest() + age = time.time() - newest.wall_ts + print("\n[5] Newest frame") + print(f" seq {newest.seq} · {_fmt_bytes(newest.nbytes)} · age {age * 1000:.0f} ms") + print(f" JPEG framing : starts {newest.data[:2].hex()} ends {newest.data[-2:].hex()}") + if not newest.data.startswith(b"\xff\xd8"): + print(" WARN payload is not JPEG-framed -- check the still endpoint.") + + # -- 7.3 decode -------------------------------------------------------- + decoded = None + if args.decode: + print("\n[6] Decode (7.3) -- a real camera JPEG through cv2") + try: + import cv2 # pylint: disable=import-outside-toplevel + import numpy as np # pylint: disable=import-outside-toplevel + + image = cv2.imdecode(np.frombuffer(newest.data, np.uint8), cv2.IMREAD_COLOR) + if image is None: + print(" FAIL cv2 could not decode the frame.") + else: + decoded = {"height": int(image.shape[0]), "width": int(image.shape[1])} + print(f" OK {image.shape[1]}x{image.shape[0]}, {image.shape[2]} channels") + print(" (expect 1280x720 -- w.3 is the only JPEG stream)") + except ImportError as exc: + print(f" SKIP cv2/numpy unavailable here ({exc}). Re-run where BEC is installed.") + + if args.save: + with open(args.save, "wb") as handle: + handle.write(newest.data) + print(f"\n frame written to {args.save} -- open it and confirm it looks right") + + worker.stop() + print(f"\n worker stopped cleanly · running={worker.running}") + + report.update( + { + "ok": True, + "establishment_time_s": worker.establishment_time, + "frames_received": frames, + "elapsed_s": elapsed, + "measured_rate_hz": measured_rate, + "mean_frame_bytes": mean_size, + "bandwidth_mbit_s": bw_bytes * 8 / 1e6, + "grab_latency_us": lat, + "buffer": bstats, + "worker": stats, + "decoded": decoded, + } + ) + + print("\n" + "=" * 72) + print("VERDICT: acquisition path works end to end.") + print( + f" {measured_rate:.2f} Hz, {bw_bytes * 8 / 1e6:.2f} Mbit/s, " + f"grabs in microseconds." + ) + print(" Next: COMMISSIONING.md 7.5 (pull the network briefly) and Stage 8.") + print("=" * 72) + return report + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Stage 7 acquisition & grab check for the Canon CR-N300.", + epilog="Read-only: GETs to the still endpoint, never a control command.", + ) + parser.add_argument("--host", help="camera IP or hostname") + parser.add_argument("--port", type=int, default=80) + parser.add_argument("--still-path", default=_DEFAULT_STILL_PATH, help="still endpoint path") + parser.add_argument("--url", default=None, help="full still URL, overrides host/port/path") + parser.add_argument("--rate", type=float, default=2.0, help="poll rate in Hz (default 2)") + parser.add_argument("--duration", type=float, default=20.0, help="collect seconds") + parser.add_argument("--maxlen", type=int, default=64, help="ring-buffer depth") + parser.add_argument("--timeout", type=float, default=5.0, help="per-request timeout") + parser.add_argument("--grabs", type=int, default=200, help="grab-latency samples") + parser.add_argument("--decode", action="store_true", help="decode one frame via cv2") + parser.add_argument("--save", default=None, help="write the newest frame to this file") + parser.add_argument("--json", default=None, help="write the full report here") + args = parser.parse_args(argv) + + if not args.url and not args.host: + parser.error("either --host or --url is required") + if args.rate <= 0: + parser.error("--rate must be > 0") + + report = run(args) + if args.json: + with open(args.json, "w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2, default=str) + print(f"\nfull report written to {args.json}") + return 0 if report.get("ok") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/csaxs_bec/devices/canon/canon_ptz.py b/csaxs_bec/devices/canon/canon_ptz.py index 3f77bacd..890ac6ec 100644 --- a/csaxs_bec/devices/canon/canon_ptz.py +++ b/csaxs_bec/devices/canon/canon_ptz.py @@ -3,7 +3,7 @@ Phase 1 (this module): standalone control -- parameter read/write and PTZ/zoom/focus motion -- working with the imaging/streaming stack absent. Phase 2 adds the buffer-and-grab path: an acquisition worker fills a ring buffer that DAQ grabs from on -demand, in one of three network-footprint modes (see :data:`ACQUISITION_MODES`). The +demand, in one of two network-footprint modes (see :data:`ACQUISITION_MODES`). The acquisition subsystem is imported lazily and guarded, so control never depends on it. Still outstanding in Phase 2: frame *delivery into a scan* (file-writer references via ``SUB_FILE_EVENT``, and driving the ``preview`` signal) -- the component is declared but @@ -39,7 +39,7 @@ from .positioner import CanonAxis from .transport.base import Axis, CameraTransport, FocusMode from .transport.errors import CanonParameterError from .transport.fake import FakeTransport -from .transport.xc import DEFAULT_CGI_PREFIX, DEFAULT_STILL_CGI, DEFAULT_STREAM_CGI, XCTransport +from .transport.xc import DEFAULT_CGI_PREFIX, DEFAULT_STILL_CGI, XCTransport if TYPE_CHECKING: # pragma: no cover from bec_lib.devicemanager import ScanInfo @@ -62,21 +62,23 @@ _PRESET_STATE_PARAM = "preset_state" # Acquisition modes -- how frames reach the ring buffer, and at what network cost. # -# ``mjpeg`` hold one continuous MJPEG connection open for the whole session. -# Lowest latency and full frame rate, but continuous bandwidth -- -# a 4K/30 fps camera streams all session whether or not anyone -# grabs. Choose deliberately, for genuine high-rate needs. -# ``jpeg_poll`` fetch one still JPEG every ``1/poll_rate`` seconds. Bandwidth is -# a small fraction of MJPEG, idle CPU ~0, and the full grab API -# still works at O(1); the newest frame may be up to one poll -# interval old. This is the default: it is the network-friendly -# option that keeps buffer-and-grab intact. +# ``jpeg_poll`` fetch one still JPEG every ``1/poll_rate`` seconds. Low bandwidth, +# idle CPU ~0, and the full grab API works at O(1); the newest frame +# may be up to one poll interval old. This is the default. # ``jpeg_on_demand`` no worker and no buffer-ahead -- ``get_latest()`` performs one # synchronous fetch through the control session. Zero idle # bandwidth and CPU, but each grab pays an HTTP round-trip and # there is no history, so ``get_latest_n`` / ``get_nearest`` are # unavailable (they would silently return stale or absent data). -ACQUISITION_MODES = ("mjpeg", "jpeg_poll", "jpeg_on_demand") +# +# A continuous-MJPEG mode existed until the protocol was measured on 2026-08-17: the +# CR-N300 implements no MJPEG operator at all (nphMotionJpeg and friends answer +# "Unknown Operator"), so the mode could never have produced a frame. It was removed +# rather than left as a config value that silently does nothing. The JPEG path is +# 1280x720 at up to 12.5 fps, well above the default poll rate; 4K is H.264-only. +# Re-adding a streaming mode means writing one FrameSource -- the worker, ring buffer +# and grab API are all indifferent to how frames arrive. +ACQUISITION_MODES = ("jpeg_poll", "jpeg_on_demand") _ON_DEMAND = "jpeg_on_demand" @@ -202,7 +204,6 @@ class CanonPTZCamera(PSIDeviceBase): password_env: str | None = None, gradual_motion: bool = False, buffer_maxlen: int = 64, - stream_url: str | None = None, stream_start: str = "off", acquisition_mode: str = "jpeg_poll", poll_rate: float = 2.0, @@ -224,10 +225,9 @@ class CanonPTZCamera(PSIDeviceBase): username_env/password_env: env-var names holding XC credentials. gradual_motion: simulate finite-speed travel in the fake transport. buffer_maxlen: ring-buffer depth (Phase 2). - stream_url: MJPEG stream URL (``mjpeg`` mode). Derived if omitted. stream_start: one of ``off`` | ``connect`` | ``stage`` -- when to start the persistent acquisition worker. - acquisition_mode: ``mjpeg`` | ``jpeg_poll`` | ``jpeg_on_demand`` -- how + acquisition_mode: ``jpeg_poll`` | ``jpeg_on_demand`` -- how frames are acquired, and at what network cost. See :data:`ACQUISITION_MODES` above for the trade-offs. Defaults to ``jpeg_poll``, the network-friendly mode that keeps the full grab API. @@ -249,7 +249,6 @@ class CanonPTZCamera(PSIDeviceBase): raise ValueError("host is required unless simulation=True") self._simulation = simulation - self._stream_url = stream_url self._stream_start = stream_start self._buffer_maxlen = buffer_maxlen self._acquisition_mode = acquisition_mode @@ -423,10 +422,6 @@ class CanonPTZCamera(PSIDeviceBase): prefix = getattr(self.transport, "cgi_prefix", DEFAULT_CGI_PREFIX) return f"http://{host}:{port}/{prefix}/{cgi}" - def _default_stream_url(self) -> str: - """MJPEG (continuous) stream URL used by ``acquisition_mode: mjpeg``.""" - return self._image_url(DEFAULT_STREAM_CGI) - def _default_still_url(self) -> str: """Single-JPEG URL polled by ``acquisition_mode: jpeg_poll``.""" return self._image_url(DEFAULT_STILL_CGI) @@ -449,29 +444,15 @@ class CanonPTZCamera(PSIDeviceBase): def _make_source(self): """Build the :class:`FrameSource` for the configured acquisition mode.""" - from .acquisition import ( # pylint: disable=import-outside-toplevel - IterableFrameSource, - MJPEGHttpSource, - SingleJpegPollSource, - ) + from .acquisition import SingleJpegPollSource # pylint: disable=import-outside-toplevel - if self._acquisition_mode == "jpeg_poll": - if self._simulation: - # Route the synthetic stills through the fake transport so the poll - # path itself -- not just the buffer -- is exercised offline. - return SingleJpegPollSource( - fetch=self.transport.get_still_jpeg, rate=self._poll_rate - ) - return SingleJpegPollSource( - self._still_url or self._default_still_url(), - rate=self._poll_rate, - auth=getattr(self.transport, "_auth", None), - ) - # mjpeg if self._simulation: - return IterableFrameSource(self._synthetic_jpeg, delay=0.05) - return MJPEGHttpSource( - self._stream_url or self._default_stream_url(), + # Route the synthetic stills through the fake transport so the poll path + # itself -- not just the buffer -- is exercised offline. + return SingleJpegPollSource(fetch=self.transport.get_still_jpeg, rate=self._poll_rate) + return SingleJpegPollSource( + self._still_url or self._default_still_url(), + rate=self._poll_rate, auth=getattr(self.transport, "_auth", None), ) @@ -517,11 +498,6 @@ class CanonPTZCamera(PSIDeviceBase): self._worker = None logger.info(f"{self.name}: stream worker stopped") - @staticmethod - def _synthetic_jpeg() -> bytes: - """Minimal JPEG-framed payload for simulation (not a valid image).""" - return b"\xff\xd8" + b"SIMFRAME" + b"\xff\xd9" - def _require_buffer(self): if self._buffer is None: raise RuntimeError( @@ -543,7 +519,7 @@ class CanonPTZCamera(PSIDeviceBase): f"{self.name}: {call} needs buffered frame history, which " f"acquisition_mode={_ON_DEMAND!r} does not keep (it fetches one frame " "per grab). Use get_latest(), or configure acquisition_mode " - "'jpeg_poll' or 'mjpeg'." + "'jpeg_poll'." ) def _fetch_still_frame(self): diff --git a/csaxs_bec/devices/canon/transport/xc.py b/csaxs_bec/devices/canon/transport/xc.py index 42da5e01..27b61740 100644 --- a/csaxs_bec/devices/canon/transport/xc.py +++ b/csaxs_bec/devices/canon/transport/xc.py @@ -280,10 +280,22 @@ class XCTransport(CameraTransport): # -- URL / request plumbing ------------------------------------------- + # CGIs that must NOT carry the session id. + # + # MEASURED (CR-N300 fw 1.4.0, 2026-08-17): `info.cgi` answers with the **full** + # device state (~32 kB: s.*, v.*, w.*, c.1.*, p.*, t.*, f.*) when called bare, but + # with only the session-scoped `s.*` subset when called as `info.cgi?s=`. That + # made get_camera_info() succeed -- s.hardware/s.firmware are in both -- while every + # position and parameter lookup failed with "not in info.cgi response". Querying it + # without the session is what the probe does, and what returns the whole state. + # + # `open.cgi` is here for the obvious reason: it is what issues the session. + _SESSIONLESS_CGI = frozenset({"open", "info"}) + def _url(self, cgi_key: str, params: dict[str, object] | None = None) -> str: base = f"http://{self.host}:{self.port}/{self.cgi_prefix}/{_CGI[cgi_key]}" query = dict(params or {}) - if self._session_id is not None and cgi_key not in ("open",): + if self._session_id is not None and cgi_key not in self._SESSIONLESS_CGI: query.setdefault(_PARAM["session"], self._session_id) if query: base = f"{base}?{urllib.parse.urlencode(query)}" diff --git a/tests/tests_devices/test_canon_device.py b/tests/tests_devices/test_canon_device.py index 735a48ea..c8563a0a 100644 --- a/tests/tests_devices/test_canon_device.py +++ b/tests/tests_devices/test_canon_device.py @@ -147,6 +147,9 @@ def test_default_acquisition_mode_is_the_network_friendly_one(cam): def test_invalid_acquisition_mode_and_poll_rate_rejected(): with pytest.raises(ValueError): CanonPTZCamera(name="badmode", simulation=True, acquisition_mode="rtsp") + # mjpeg was removed once the camera proved to implement no MJPEG operator. + with pytest.raises(ValueError): + CanonPTZCamera(name="nomjpeg", simulation=True, acquisition_mode="mjpeg") with pytest.raises(ValueError): CanonPTZCamera(name="badrate", simulation=True, poll_rate=0) @@ -221,30 +224,6 @@ def test_jpeg_on_demand_rejects_history_grabs_loudly(): device.destroy() -def test_mjpeg_mode_still_available_for_high_rate_needs(): - device = CanonPTZCamera(name="mjpeg_sim", simulation=True, acquisition_mode="mjpeg") - device.wait_for_connection() - try: - device.start_stream() - assert device._worker.wait_for_first_frame(timeout=2.0) - assert device.stream_stats()["mode"] == "mjpeg" - assert "poll_rate_hz" not in device.stream_stats() - finally: - device.stop_stream() - device.destroy() - - -def test_mjpeg_mode_uses_the_stream_url_override(): - device = CanonPTZCamera( - name="mjpeg_url", - simulation=False, - host="cam.test", - acquisition_mode="mjpeg", - stream_url="http://cam.test/custom/stream", - ) - assert device._make_source().url == "http://cam.test/custom/stream" - - def test_non_simulation_requires_host(): with pytest.raises(ValueError): CanonPTZCamera(name="nohost", simulation=False) diff --git a/tests/tests_devices/test_canon_probe.py b/tests/tests_devices/test_canon_probe.py index db7ec80e..08a38a63 100644 --- a/tests/tests_devices/test_canon_probe.py +++ b/tests/tests_devices/test_canon_probe.py @@ -18,6 +18,7 @@ from __future__ import annotations import base64 import importlib +import json import os import pathlib import subprocess @@ -376,3 +377,53 @@ if __name__ == "__main__": # rehearsal mode -- serve a fake camera and wait time.sleep(1) except KeyboardInterrupt: pass + + +# --------------------------------------------------------------------------- # +# Stage 7 acquisition check, against the same mock camera # +# --------------------------------------------------------------------------- # + + +def test_acquisition_check_runs_end_to_end(camera, tmp_path, monkeypatch): + """The Stage 7 tool must exercise the real acquisition path, not a stub.""" + from csaxs_bec.devices.canon.acquisition_check import main + + # The tool takes credentials from the environment, never from argv. + monkeypatch.setenv("CANON_PROBE_USER", USER) + monkeypatch.setenv("CANON_PROBE_PASSWORD", PASSWORD) + + saved = tmp_path / "frame.jpg" + out = tmp_path / "stage7.json" + rc = main( + [ + "--url", + f"http://127.0.0.1:{camera.port}/{PREFIX}/image.cgi", + "--rate", + "50", + "--duration", + "1", + "--grabs", + "20", + "--save", + str(saved), + "--json", + str(out), + ] + ) + assert rc == 0 + report = json.loads(out.read_text()) + assert report["ok"] + assert report["frames_received"] > 0 + assert report["establishment_time_s"] is not None + assert report["mean_frame_bytes"] == len(FAKE_JPEG) + # Grabs must be O(1) -- microseconds, not milliseconds. + assert max(report["grab_latency_us"].values()) < 5000 + assert saved.read_bytes() == FAKE_JPEG + + +def test_acquisition_check_reports_failure_when_nothing_answers(tmp_path): + """A dead endpoint must exit non-zero rather than hang or claim success.""" + from csaxs_bec.devices.canon.acquisition_check import main + + rc = main(["--url", "http://127.0.0.1:9/image.cgi", "--duration", "1", "--timeout", "1"]) + assert rc == 1 diff --git a/tests/tests_devices/test_canon_stream_worker.py b/tests/tests_devices/test_canon_stream_worker.py index 06a68026..c9d20eb2 100644 --- a/tests/tests_devices/test_canon_stream_worker.py +++ b/tests/tests_devices/test_canon_stream_worker.py @@ -9,7 +9,6 @@ from csaxs_bec.devices.canon.acquisition.ring_buffer import FrameRingBuffer from csaxs_bec.devices.canon.acquisition.stream_worker import ( FrameSource, IterableFrameSource, - MJPEGHttpSource, SingleJpegPollSource, StreamWorker, ) @@ -82,30 +81,6 @@ def test_worker_start_is_idempotent(): worker.stop() -def test_mjpeg_source_splits_frames_without_decoding(): - # Build a fake multipart MJPEG byte stream with two JPEG frames split across reads. - stream = b"--bound\r\n\xff\xd8AAA\xff\xd9garbage\xff\xd8BBBB\xff\xd9tail" - - class FakeResponse: - def __init__(self, data, chunk): - self._data = data - self._chunk = chunk - self._pos = 0 - - def read(self, n): - chunk = self._data[self._pos : self._pos + self._chunk] - self._pos += self._chunk - return chunk - - def close(self): - pass - - src = MJPEGHttpSource("http://x/image.cgi", chunk_size=5) - src._response = FakeResponse(stream, 5) - got = list(src.frames()) - assert got == [b"\xff\xd8AAA\xff\xd9", b"\xff\xd8BBBB\xff\xd9"] - - def test_poll_source_yields_one_frame_per_interval(): calls = [] diff --git a/tests/tests_devices/test_canon_transport.py b/tests/tests_devices/test_canon_transport.py index cd41eb9f..a3adf55b 100644 --- a/tests/tests_devices/test_canon_transport.py +++ b/tests/tests_devices/test_canon_transport.py @@ -336,6 +336,36 @@ def test_zoom_encoding_is_a_non_linear_fov_conversion(): assert enc.to_native(20.0) < enc.to_native(2.0) +def test_info_cgi_is_queried_without_the_session_id(): + """MEASURED: info.cgi?s= returns only the session-scoped s.* subset. + + Regression for the failure seen on hardware 2026-08-17: get_camera_info() + succeeded (s.hardware is in both responses) while every position lookup raised + "axis 'pan' (c.1.pan) not in info.cgi response". Bare info.cgi returns the full + ~32 kB device state; the session parameter narrows it. + """ + opener = RecordingOpener( + bodies={ + "open.cgi": REAL_OPEN_BODY, + "info.cgi": "c.1.pan:=-1559\ns.hardware:=Canon CR-N300\n", + } + ) + t = make_xc(opener) + t.connect() + t.claim_control() + assert t.get_position(Axis.PAN) == pytest.approx(-15.59) + + info_urls = [u for u in opener.requests if "info.cgi" in u] + assert info_urls, "info.cgi should have been requested" + assert all("s=" not in u.split("?", 1)[-1] for u in info_urls), ( + "info.cgi must not carry the session id, or the camera answers with the " + "session subset and every c.1.* lookup fails" + ) + # Control CGIs still carry it. + control_urls = [u for u in opener.requests if "claim.cgi" in u] + assert any("s=8091-50cdfb31" in u for u in control_urls) + + def test_is_moving_reads_the_measured_status_field(): """R8: c.1..status is the moving flag we were unsure existed.""" opener = RecordingOpener(