fix(canon): query info.cgi without the session; drop mjpeg; add Stage 7 check
CI for csaxs_bec / test (push) Failing after 41s
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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):
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user