fix(canon): query info.cgi without the session; drop mjpeg; add Stage 7 check
CI for csaxs_bec / test (push) Failing after 41s

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=<id>` 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:56:17 +02:00
co-authored by Claude Opus 5
parent 327bfaf5ff
commit 08cf0349bd
12 changed files with 476 additions and 206 deletions
+7 -9
View File
@@ -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
+27 -17
View File
@@ -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 <ip> --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
+34 -16
View File
@@ -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 <ip> # first contact: reachability, CGIs, session
python3 acquisition_check.py --host <ip> # 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.
@@ -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",
]
@@ -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.
"""
@@ -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 <ip> --rate 5 --duration 30 --decode
python3 acquisition_check.py --host <ip> --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())
+23 -47
View File
@@ -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):
+13 -1
View File
@@ -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=<id>`. 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)}"
+3 -24
View File
@@ -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)
+51
View File
@@ -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
@@ -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 = []
@@ -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=<id> 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.<axis>.status is the moving flag we were unsure existed."""
opener = RecordingOpener(