feat(canon): add Canon CR-N300 PTZ camera device (Phase 1)
CI for csaxs_bec / test (push) Successful in 1m31s
CI for csaxs_bec / test (push) Successful in 1m31s
Custom (non-EPICS) ophyd device for the Canon CR-N300 PTZ remote camera. Phase 1 (implemented): standalone control that works with the imaging stack absent. PTZ/zoom/focus exposed as positioners (move()/set() -> status, stop(), spec limits: pan +/-170 deg, tilt -30..+100 deg, 20x zoom); camera parameters (exposure/iris, gain, white balance, focus mode, presets) via read()/describe() with read-back verification; Manual + Continuous/Face/Tracking AF focus modes. Control plane (transport/, pure stdlib, no ophyd): - CameraTransport ABC; XCTransport implements the Canon XC Control Protocol (session open/claim/yield, HTTP/CGI) with an injectable opener; FakeTransport is a full in-memory simulator for offline mode and tests. Spec-defined CGI paths/params/axis encodings (BPE-7216-005) are isolated and flagged for hardware verification. Imaging plane (acquisition/, pure stdlib, no ophyd, not a control dependency): - FrameRingBuffer (bounded, thread-safe, stores compressed frames) and a persistent, fail-soft, instrumented StreamWorker. Phase-2 grab API (get_latest/get_latest_n/get_nearest, decode-on-grab) seamed in the device. Credentials come from env vars (CANON_<NAME>_USER/_PASSWORD), never YAML/Redis. Adds example device config (real + simulation), a README, and pytest coverage (transport mocked, ring buffer, stream worker, device logic) — no hardware required. No new third-party dependencies (Phase-2 decode uses cv2, already a csaxs_bec dependency). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# Example BEC device-config entries for the Canon CR-N300 PTZ camera.
|
||||
#
|
||||
# Include these in a session by merging this file into your active config, or copy an
|
||||
# entry into one of the bl_*.yaml configs. Credentials are NOT stored here: the device
|
||||
# reads CANON_<NAME>_USER / CANON_<NAME>_PASSWORD from the environment at connect
|
||||
# (NAME = the device name, upper-cased; e.g. CANON_PTZ_CAM_USER for ptz_cam).
|
||||
|
||||
# --- Real camera (Canon XC Control Protocol over HTTP) ---------------------------
|
||||
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
|
||||
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)
|
||||
# username_env: CANON_PTZ_CAM_USER # override the default env-var names
|
||||
# password_env: CANON_PTZ_CAM_PASSWORD
|
||||
pan_speed: 60 # optional default move speed [deg/s]
|
||||
tilt_speed: 60
|
||||
# --- Phase-2 streaming (safe to leave at defaults for control-only use) ---
|
||||
stream_start: "off" # off | connect | stage (when to open the stream)
|
||||
buffer_maxlen: 64 # ring-buffer depth (compressed frames)
|
||||
# stream_url: http://192.168.0.100/-wvhttp-01-/image.cgi # MJPEG URL override
|
||||
deviceTags:
|
||||
- camera
|
||||
- ptz
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: on_request
|
||||
softwareTrigger: false
|
||||
|
||||
# --- Simulation / offline (no hardware; uses FakeTransport) ----------------------
|
||||
ptz_cam_sim:
|
||||
description: Canon CR-N300 PTZ camera (simulation, no hardware required)
|
||||
deviceClass: csaxs_bec.devices.canon.canon_ptz.CanonPTZCamera
|
||||
deviceConfig:
|
||||
simulation: true
|
||||
gradual_motion: true # simulate finite-speed travel (exercise move status)
|
||||
buffer_maxlen: 32
|
||||
stream_start: "connect" # start the synthetic stream at connect
|
||||
deviceTags:
|
||||
- camera
|
||||
- ptz
|
||||
- simulation
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: on_request
|
||||
softwareTrigger: false
|
||||
@@ -0,0 +1,454 @@
|
||||
# Canon CR-N300 — Commissioning & Verification Guide
|
||||
|
||||
This document is the bring-up companion to the
|
||||
[`CanonPTZCamera`](./canon_ptz.py) device. It exists so that, when the device is
|
||||
deployed at the beamline against real Canon hardware, you can systematically confirm
|
||||
**what works as designed** and pinpoint **where reality deviates from the assumptions
|
||||
baked into the code** — without reverse-engineering the source.
|
||||
|
||||
It contains:
|
||||
|
||||
1. [How to read this guide](#1-how-to-read-this-guide)
|
||||
2. [The UI surface — every function and its intended effect](#2-the-ui-surface)
|
||||
3. [What has already been tested (and how)](#3-what-has-already-been-tested)
|
||||
4. [What remains to be tested (needs hardware)](#4-what-remains-to-be-tested)
|
||||
5. [The deviation-risk map — protocol assumptions to confirm](#5-deviation-risk-map)
|
||||
6. [The commissioning test regime — a gated schedule](#6-commissioning-test-regime)
|
||||
7. [Recording template](#7-recording-template)
|
||||
|
||||
---
|
||||
|
||||
## 1. How to read this guide
|
||||
|
||||
Every function below is tagged with a **verification status**:
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| 🟢 **VERIFIED (sim)** | Exercised by the automated test-suite against the fake transport / mocked HTTP. The *Python logic* is proven; the *protocol wire-compatibility* is not. |
|
||||
| 🟡 **LOGIC-ONLY** | The code path runs in sim, but its real effect depends on a protocol assumption that only hardware can confirm (flagged with the relevant [risk item](#5-deviation-risk-map)). |
|
||||
| 🔴 **UNTESTED** | No automated coverage; requires hardware or a running BEC stack. |
|
||||
|
||||
A 🟢 tag means *"if the camera speaks the protocol we assume, this works."* The job of
|
||||
hardware commissioning is to confirm that assumption, function by function. When a 🟡/🔴
|
||||
step fails, the [deviation-risk map](#5-deviation-risk-map) tells you **which constant in
|
||||
the code to adjust** — almost always a single table entry in
|
||||
[`transport/xc.py`](./transport/xc.py), not application logic.
|
||||
|
||||
Throughout, commands are shown in the BEC client form `dev.<name>.…`. The example device
|
||||
name used is **`ptz_cam`** (from [`device_configs/canon_crn300.yaml`](../../device_configs/canon_crn300.yaml)).
|
||||
|
||||
---
|
||||
|
||||
## 2. The UI surface
|
||||
|
||||
### 2.1 Quick reference
|
||||
|
||||
| Call | Kind | Intended effect | Status |
|
||||
|------|------|-----------------|--------|
|
||||
| `dev.ptz_cam.pan.move(deg)` / `.set(deg)` | positioner | Absolute pan, ±170° | 🟡 [R5](#r5) |
|
||||
| `dev.ptz_cam.tilt.move(deg)` | positioner | Absolute tilt, −30…+100° | 🟡 [R5](#r5) |
|
||||
| `dev.ptz_cam.zoom.move(x)` | positioner | Absolute zoom, 1–20× | 🟡 [R5](#r5) |
|
||||
| `dev.ptz_cam.focus.move(f)` | positioner | Manual focus, 0–1 (MF only) | 🟡 [R5](#r5) |
|
||||
| `dev.ptz_cam.<axis>.stop()` | method | Halt one axis | 🟡 [R9](#r9) |
|
||||
| `dev.ptz_cam.stop_all()` | USER_ACCESS | Halt all four axes | 🟡 [R9](#r9) |
|
||||
| `dev.ptz_cam.jog_pan(speed)` / `jog_tilt(speed)` | USER_ACCESS | Continuous move at deg/s; `0` stops | 🟡 [R5](#r5) |
|
||||
| `dev.ptz_cam.recall_preset(id)` | USER_ACCESS | Recall stored PTZ preset | 🟡 [R3](#r3) |
|
||||
| `dev.ptz_cam.gain.set(v)` | signal (config) | Set gain, verify read-back | 🟡 [R4](#r4) |
|
||||
| `dev.ptz_cam.iris.set(v)` | signal (config) | Set iris/aperture, verify read-back | 🟡 [R4](#r4) |
|
||||
| `dev.ptz_cam.exposure_mode.set(s)` | signal (config) | Set exposure mode | 🟡 [R4](#r4) |
|
||||
| `dev.ptz_cam.white_balance.set(s)` | signal (config) | Set white balance | 🟡 [R4](#r4) |
|
||||
| `dev.ptz_cam.shutter_speed.set(s)` | signal (config) | Set shutter speed | 🟡 [R4](#r4) |
|
||||
| `dev.ptz_cam.focus_mode.set(m)` | signal (config) | manual / continuous_af / face_af / tracking_af | 🟡 [R6](#r6) |
|
||||
| `dev.ptz_cam.preset_state.get()` | signal (RO) | Last recalled preset id | 🟢 |
|
||||
| `dev.ptz_cam.read()` / `.describe()` | ophyd | Readback of all axes + normal signals | 🟢 |
|
||||
| `dev.ptz_cam.read_configuration()` | ophyd | Readback of all config parameters | 🟡 [R4](#r4) |
|
||||
| `dev.ptz_cam.start_stream()` / `stop_stream()` | USER_ACCESS | Start/stop the acquisition worker | 🟡 [R10](#r10) |
|
||||
| `dev.ptz_cam.get_latest()` | USER_ACCESS | Most recent buffered frame | 🟡 [R10](#r10) |
|
||||
| `dev.ptz_cam.get_latest_n(n)` | USER_ACCESS | Last *n* frames, oldest-first | 🟢 |
|
||||
| `dev.ptz_cam.get_nearest(ts)` | USER_ACCESS | Frame closest to a wall-clock timestamp | 🟢 |
|
||||
| `dev.ptz_cam.stream_stats()` | USER_ACCESS | Buffer + worker instrumentation | 🟢 |
|
||||
| `dev.ptz_cam.transport_info()` | USER_ACCESS | Connection / identity diagnostics | 🟡 [R7](#r7) |
|
||||
|
||||
### 2.2 Lifecycle (called by BEC, not normally by the user)
|
||||
|
||||
These run automatically when BEC connects/stages/stops the device. Listed because their
|
||||
behaviour is what you are implicitly testing in Stage 1–3.
|
||||
|
||||
| Hook | When | Intended effect | Status |
|
||||
|------|------|-----------------|--------|
|
||||
| `wait_for_connection()` | on device load | Open XC session (`open.cgi`) **and claim control** (`claim.cgi`), then wait for signals | 🟡 [R1](#r1)[R2](#r2)[R6b](#r6b) |
|
||||
| `on_connected()` | after connect | Log camera identity; seed pan/tilt/zoom/focus readbacks; start stream if `stream_start: connect` | 🟡 [R7](#r7) |
|
||||
| `on_stage()` | scan start | Start stream if `stream_start: stage` | 🟢 |
|
||||
| `on_unstage()` | scan end | No-op (stream persists across scans by design) | 🟢 |
|
||||
| `on_stop()` | abort | Halt all motion (best-effort) | 🟡 [R9](#r9) |
|
||||
| `on_destroy()` | device teardown | Stop stream worker; yield control + close session (`yield.cgi`/`close.cgi`) | 🟡 [R2](#r2) |
|
||||
|
||||
### 2.3 Detailed function reference
|
||||
|
||||
#### Motion — `pan`, `tilt`, `zoom`, `focus`
|
||||
|
||||
Each axis is a full ophyd positioner.
|
||||
|
||||
- **`move(value, wait=True)`** — commands an absolute move and returns an ophyd
|
||||
`Status`. With `wait=True` (default) it blocks until the readback reaches `value`
|
||||
within tolerance, or the move times out (default 30 s), or `stop()` is called.
|
||||
- *Intended effect:* the physical axis travels to `value` (pan/tilt in degrees, zoom
|
||||
in optical ×, focus normalised 0–1) and the readback reflects it.
|
||||
- *Internally:* issues `move_absolute` on the transport, then polls
|
||||
`get_position` every `poll_interval` (default 0.1 s) until `|pos − target| ≤
|
||||
tolerance`. **The encoding from user units to the camera's native command integer
|
||||
is assumption [R5](#r5).**
|
||||
- *Guard rails:* a value outside the configured soft-limits raises `LimitError`
|
||||
**before** any command is sent. Soft limits can only narrow the hardware range.
|
||||
- **`set(value)`** — identical but non-blocking (`wait=False`); returns the `Status`
|
||||
immediately. This is what BEC scans use.
|
||||
- **`stop()`** — halts the axis immediately (sends the transport stop command) and
|
||||
fails any in-flight move status. Best-effort: never raises during teardown.
|
||||
- **`jog(speed)`** (pan/tilt only, exposed as `jog_pan`/`jog_tilt`) — start a
|
||||
continuous move at `speed` (deg/s, sign = direction); `0` stops. **Continuous-move
|
||||
encoding is assumption [R5](#r5).**
|
||||
- **`position`** property and **`read()`** — current readback.
|
||||
- **`limits`** — the active `(low, high)` soft limits.
|
||||
|
||||
> **Focus is special:** `focus.move(...)` raises `RuntimeError` unless `focus_mode` is
|
||||
> `manual`. This is enforced in software ([R6](#r6) governs the mode tokens).
|
||||
|
||||
#### Camera parameters — settable config signals
|
||||
|
||||
`gain`, `iris`, `exposure_mode`, `white_balance`, `shutter_speed`, `focus_mode`.
|
||||
|
||||
- **`set(value)`** — writes the parameter through the transport, then (for `gain`/`iris`)
|
||||
**reads it back and raises `CanonParameterError` if the read-back disagrees**. The
|
||||
status returned reflects success/failure. Parameters whose camera-side representation
|
||||
isn't a clean round-trip (`exposure_mode`, `white_balance`, `shutter_speed`,
|
||||
`focus_mode`) have read-back verification disabled by default (`verify=False`).
|
||||
- *Depends on:* the parameter **names** the camera uses in its info/control CGIs
|
||||
([R4](#r4)) and, for focus, the **mode tokens** ([R6](#r6)).
|
||||
- **`get()`** — reads the live value from the camera.
|
||||
|
||||
#### Presets
|
||||
|
||||
- **`recall_preset(id)`** — recall a stored PTZ preset; updates `preset_state`.
|
||||
([R3](#r3) governs the preset CGI/parameter.)
|
||||
- **`preset_state.get()`** — the last preset id recalled **through this device** (it is
|
||||
device-side state, `-1` if none; it does **not** poll the camera).
|
||||
|
||||
#### Streaming & grab (Phase 2)
|
||||
|
||||
- **`start_stream()`** — opens the MJPEG stream once and starts the background worker
|
||||
filling the ring buffer. Idempotent. ([R10](#r10) governs the stream URL/format.)
|
||||
- **`get_latest()` / `get_latest_n(n)` / `get_nearest(ts)`** — O(1) retrieval of
|
||||
buffered frames. Each returns a `Frame` (compressed bytes + monotonic & wall-clock
|
||||
timestamps + source id), or pass `decode=True` to decode **only that frame** to an
|
||||
RGB array via `cv2`.
|
||||
- **`stream_stats()`** — returns `{"buffer": {...}, "worker": {...}}` including
|
||||
`establishment_time_s` (time to first frame — should be the ~2 s, paid once) and
|
||||
`last_frame_age_s` (health).
|
||||
- **`stop_stream()`** — stops the worker cleanly (no leaked socket/thread).
|
||||
|
||||
#### Diagnostics
|
||||
|
||||
- **`transport_info()`** — `{connected, has_control, simulation, model, firmware}`.
|
||||
The first thing to call when something looks wrong.
|
||||
|
||||
---
|
||||
|
||||
## 3. What has already been tested
|
||||
|
||||
**47 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
|
||||
do **not** touch a camera.
|
||||
|
||||
Coverage by area:
|
||||
|
||||
**Ring buffer (7 tests)** — rejects bad capacity; stamps sequence + monotonic/wall
|
||||
timestamps; overwrites oldest and counts evictions; `get_latest` / `get_latest_n` /
|
||||
`get_nearest` (wall and monotonic); thread-safety under 5 concurrent writers.
|
||||
|
||||
**Stream worker (5 tests)** — fills the buffer and measures establishment time;
|
||||
**fail-soft reconnect** keeps serving the last good frame after a dropped connection;
|
||||
idempotent `start()`; MJPEG byte-splitting on JPEG markers **without decoding**; clean
|
||||
thread shutdown with **no thread leak**.
|
||||
|
||||
**Transport — `FakeTransport` (9 tests)** — session lifecycle; **control-privilege is
|
||||
enforced** (moves rejected without a claim); session required for reads; absolute move
|
||||
honours hardware limits; parameter round-trip; focus-mode + preset state; gradual motion
|
||||
with an injected clock; stop freezes an axis; fault injection.
|
||||
|
||||
**Transport — `XCTransport` via mocked HTTP (9 tests)** — parses the session id from the
|
||||
response body **and** from a `Set-Cookie` header; builds control URLs carrying the
|
||||
session id; **encodes native axis values** (`12.0° → pan=1200`) and decodes them back;
|
||||
rejects moves without control; **maps HTTP 401 → session error, 403 → control-privilege
|
||||
error**; **retries + reconnects** on a transient failure; `close()` is idempotent and
|
||||
never raises; focus-mode wire-token round-trip.
|
||||
|
||||
**Device logic (17 tests)** — connect claims control; the four axes are positioners with
|
||||
the **spec limits** (±170 / −30…+100 / 1–20×); `move()` returns a status that reaches
|
||||
target; `set()` is non-blocking; **soft limits narrow** hardware limits (`LimitError`);
|
||||
parameter set **with read-back verification**, and a **mismatch fails the status**;
|
||||
**focus axis is gated** by focus mode; `read()`/`describe()`/`read_configuration()` cover
|
||||
axes + params; `stop_all()` halts every axis; `recall_preset`; grab API errors cleanly
|
||||
with no buffer; **simulation streaming** fills the buffer and reports establishment time;
|
||||
`host` required unless `simulation`; **credentials read from environment variables**;
|
||||
`destroy()` closes the transport.
|
||||
|
||||
---
|
||||
|
||||
## 4. What remains to be tested
|
||||
|
||||
Everything in this list requires either the physical camera or a running BEC stack, and
|
||||
is the actual subject of [the test regime in §6](#6-commissioning-test-regime).
|
||||
|
||||
| Area | Why it can't be tested without hardware |
|
||||
|------|------------------------------------------|
|
||||
| **XC wire compatibility** | The camera must actually accept our CGI paths/params and return the format we parse. See every [risk item](#5-deviation-risk-map). |
|
||||
| **HTTP authentication** | Real Digest/Basic handshake with the camera's credentials. |
|
||||
| **Axis encoding scale** | That `pan=1200` really means 12.0° (and direction sign) on this model — [R5](#r5). |
|
||||
| **Readback units & calibration** | That decoded readbacks match physical degrees/zoom/focus. |
|
||||
| **Motion completion semantics** | Whether the camera exposes a reliable "moving" flag, and our tolerance-based completion is correct — [R8](#r8). |
|
||||
| **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). |
|
||||
| **Stream establishment & latency** | The real ~2 s setup cost, steady frame rate, and sub-ms grab latency — [R10](#r10). |
|
||||
| **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. |
|
||||
| **Long-run robustness** | Real network drops, session timeout/expiry, reconnection over hours. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Deviation-risk map
|
||||
|
||||
Each item is a protocol assumption the code makes. **All live in one place** —
|
||||
the tables at the top of [`transport/xc.py`](./transport/xc.py) — and each is marked in
|
||||
the source with a `# SPEC:` comment referencing Canon doc **BPE-7216-005**. When a
|
||||
hardware step fails, find the matching item here to know what to change.
|
||||
|
||||
<a name="r1"></a>**R1 — CGI path prefix.** `DEFAULT_CGI_PREFIX = "-wvhttp-01-"`. All
|
||||
requests go to `http://host:port/<prefix>/<cgi>`. *Confirm:* the real prefix for the
|
||||
CR-N300. *Symptom if wrong:* every request 404s. *Fix:* `DEFAULT_CGI_PREFIX` (or the
|
||||
`cgi_prefix` config key).
|
||||
|
||||
<a name="r2"></a>**R2 — CGI endpoint names.** `_CGI = {open, claim, yield, close, info,
|
||||
control, preset}.cgi`. *Confirm:* names exist and serve these roles. *Symptom:* the
|
||||
corresponding action 404s/errors. *Fix:* the `_CGI` table.
|
||||
|
||||
<a name="r6b"></a>**R6b — Session id field.** `open.cgi` response is parsed for `s=` /
|
||||
`session=` / `id=`, else a `Set-Cookie`. *Symptom:* "open.cgi did not return a session
|
||||
id". *Fix:* `connect()` parsing.
|
||||
|
||||
<a name="r7"></a>**R7 — `info.cgi` response format.** Assumed newline-delimited
|
||||
`key=value`. Used for camera info, parameters **and** positions. *Symptom:* parse
|
||||
errors, missing keys, or `transport_info()` empty. *Fix:* `_parse_kv` and the field
|
||||
names below.
|
||||
|
||||
<a name="r4"></a>**R4 — Parameter names.** `_PARAM`/info keys: `gain`, `iris`,
|
||||
`exposure_mode`, `white_balance`, `shutter_speed`. *Symptom:* `CanonParameterError:
|
||||
parameter not present`. *Fix:* the parameter `cmd=` strings in
|
||||
[`canon_ptz.py`](./canon_ptz.py) and/or the camera-side names.
|
||||
|
||||
<a name="r5"></a>**R5 — Axis encoding & param names.** `_PARAM` `pan/tilt/zoom/focus`
|
||||
(+`panspeed`/`tiltspeed`) and `_AXIS_ENCODING` scales (pan/tilt ×100, zoom ×100, focus
|
||||
×1000, offset 0). *Confirm:* native units and **direction sign**. *Symptom:* axis moves
|
||||
to the wrong magnitude, wrong direction, or is rejected as out-of-range. *Fix:*
|
||||
`_AXIS_ENCODING` (and `_PARAM`).
|
||||
|
||||
<a name="r6"></a>**R6 — Focus-mode tokens.** `_FOCUS_MODE_TO_WIRE = {manual:"manual",
|
||||
continuous_af:"auto", face_af:"face", tracking_af:"tracking"}`. *Symptom:* mode doesn't
|
||||
change or read-back doesn't map. *Fix:* `_FOCUS_MODE_TO_WIRE`.
|
||||
|
||||
<a name="r8"></a>**R8 — "Moving" flag.** `is_moving()` reads an assumed `<axis>moving`
|
||||
field; absent ⇒ reported as not moving (move completion then relies purely on the
|
||||
tolerance check). *Confirm:* whether such a flag exists. *Fix:* `is_moving()` / consider
|
||||
tightening tolerance or adding settle time.
|
||||
|
||||
<a name="r3"></a>**R3 — Preset CGI.** `preset.cgi?preset=<id>`. *Symptom:* preset recall
|
||||
errors or no-op. *Fix:* `_CGI["preset"]` / `_PARAM["preset"]`.
|
||||
|
||||
<a name="r9"></a>**R9 — Stop command.** `control.cgi?stop=all|<axis>`. *Symptom:* stop
|
||||
doesn't halt motion. *Fix:* `_PARAM["stop"]` and `stop()`.
|
||||
|
||||
<a name="r10"></a>**R10 — Stream URL/format.** Default `…/<prefix>/image.cgi`, parsed as
|
||||
multipart MJPEG split on JPEG `FFD8…FFD9`. *Confirm:* the real MJPEG/JPEG stream URL and
|
||||
that frames are standalone JPEGs. *Symptom:* worker never produces a frame / decode
|
||||
fails. *Fix:* the `stream_url` config key (or `_default_stream_url`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Commissioning test regime
|
||||
|
||||
A gated schedule: **do not advance to the next stage until the current one passes.** Each
|
||||
step says what to run, what to observe, and — if it fails — which [risk item](#5-deviation-risk-map)
|
||||
to suspect. Stages 0 can be done **now**; Stage 1+ need the camera on the network.
|
||||
|
||||
> **Safety first (Stage 4 onward):** before any motion, ensure the camera can pan/tilt
|
||||
> through its full range without hitting cabling, mounts, or people. Start every axis
|
||||
> with the **smallest** move and be ready to power-cut. Keep `move_timeout` modest.
|
||||
|
||||
### 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 47 passed.* If not, stop — the environment is wrong, not the
|
||||
camera.
|
||||
|
||||
0.2 **Load in BEC in simulation.** Add the `ptz_cam_sim` entry, start a BEC session, and
|
||||
confirm `dev.ptz_cam_sim` appears, `dev.ptz_cam_sim.transport_info()` shows
|
||||
`simulation: True, connected: True`, and `dev.ptz_cam_sim.pan.move(20)` returns a
|
||||
completed status. *This proves the BEC plumbing (entry-point resolution, config mapping,
|
||||
RPC, staging) independently of hardware.* → if this fails it's a BEC/packaging issue, not
|
||||
the protocol.
|
||||
|
||||
0.3 **Desk-check the SPEC tables.** With BPE-7216-005 open, walk the
|
||||
[deviation-risk map](#5-deviation-risk-map) item by item and pre-correct any of the
|
||||
`# SPEC:` tables in `transport/xc.py` you can already confirm from the document. *Every
|
||||
correction here saves a hardware round-trip.*
|
||||
|
||||
**Gate:** 47 green + sim device usable in BEC + SPEC tables reviewed. → proceed.
|
||||
|
||||
### Stage 1 — Network & session (camera on network; read-only)
|
||||
|
||||
1.1 **Reachability.** From the device-server host: `ping <host>`, then
|
||||
`curl -v http://<host>:<port>/`. *Confirm* TCP/HTTP responds. → if not: network/port/firewall.
|
||||
|
||||
1.2 **Auth + open session.** Set `CANON_PTZ_CAM_USER` / `CANON_PTZ_CAM_PASSWORD`, then in
|
||||
Python (outside BEC is fine):
|
||||
```python
|
||||
from csaxs_bec.devices.canon.transport.xc import XCTransport
|
||||
t = XCTransport(host="<host>", username="…", password="…")
|
||||
t.connect(); print(t.connected)
|
||||
```
|
||||
*Confirm* `connected` is `True`. → 401 ⇒ credentials/auth scheme; "no session id" ⇒
|
||||
[R6b](#r6b); 404 ⇒ [R1](#r1)/[R2](#r2).
|
||||
|
||||
1.3 **Identity / info parse.** `print(t.get_camera_info()); print(t.list_parameters())`.
|
||||
*Confirm* model/firmware look right and parameters are populated. → empty/garbled ⇒
|
||||
[R7](#r7).
|
||||
|
||||
**Gate:** session opens, auth works, info parses. → proceed.
|
||||
|
||||
### Stage 2 — Read-only positions & parameters
|
||||
|
||||
2.1 **Positions.** `for a in Axis: print(a, t.get_position(a))`. *Confirm* values are
|
||||
plausible **and in the right units** (move the camera by hand / OSD if possible and see
|
||||
the readback follow). → wrong magnitude ⇒ [R5](#r5) scale; missing key ⇒ [R5](#r5)/[R7](#r7).
|
||||
|
||||
2.2 **Parameters.** Read `gain`, `iris`, etc. *Confirm* names resolve. → `parameter not
|
||||
present` ⇒ [R4](#r4).
|
||||
|
||||
**Gate:** readbacks correct and correctly scaled. → proceed (this is the prerequisite for
|
||||
trusting motion completion).
|
||||
|
||||
### Stage 3 — Control privilege
|
||||
|
||||
3.1 **Claim/yield.** `t.claim_control(); print(t.has_control); t.yield_control()`.
|
||||
*Confirm* no error. → 403 ⇒ another client holds control, or [R2](#r2) claim endpoint.
|
||||
|
||||
3.2 **Contention (optional).** Hold control from a second client; confirm our claim
|
||||
behaves sensibly (errors or waits, not silent).
|
||||
|
||||
**Gate:** control can be claimed and released. → proceed to motion.
|
||||
|
||||
### Stage 4 — Motion (incremental, safety-critical)
|
||||
|
||||
Do this through the transport first (tightest control), then through the ophyd device.
|
||||
|
||||
4.1 **Smallest relative pan.** From a known position, command `+1°`. *Confirm* it moves
|
||||
**~1° in the expected direction**. → wrong size ⇒ [R5](#r5) scale; wrong direction ⇒
|
||||
[R5](#r5) sign.
|
||||
|
||||
4.2 **Absolute pan, then tilt, then zoom, then focus** (focus: set `focus_mode=manual`
|
||||
first). For each: command a modest absolute target; confirm readback converges and the
|
||||
move `Status` completes. → never completes ⇒ [R5](#r5) (scale so tolerance never met) or
|
||||
[R8](#r8) (no moving flag + too-tight tolerance — loosen `tolerance` in config).
|
||||
|
||||
4.3 **Soft + hardware limits.** Confirm an out-of-range target raises `LimitError`
|
||||
(software, no command sent) and an in-range one near the hardware edge behaves.
|
||||
|
||||
4.4 **Speed.** Move pan with `pan_speed` set low vs. high; confirm the camera honours it.
|
||||
→ ignored ⇒ [R5](#r5) speed param name.
|
||||
|
||||
4.5 **Jog + stop.** `dev.ptz_cam.jog_pan(5)`, watch it pan, then `dev.ptz_cam.jog_pan(0)`
|
||||
or `stop_all()`. *Confirm* it starts and **stops promptly**. → won't stop ⇒ [R9](#r9).
|
||||
|
||||
4.6 **Through BEC.** Repeat 4.2 and 4.5 as `dev.ptz_cam.pan.move(...)` / `stop_all()` in a
|
||||
BEC session. *Confirm* identical behaviour and that `read()` reflects positions.
|
||||
|
||||
**Gate:** all four axes move accurately, complete, respect limits/speed, and stop. →
|
||||
proceed.
|
||||
|
||||
### Stage 5 — Focus modes
|
||||
|
||||
5.1 For each of `manual`, `continuous_af`, `face_af`, `tracking_af`:
|
||||
`dev.ptz_cam.focus_mode.set(<mode>)` and confirm the camera engages that mode (OSD /
|
||||
visible behaviour). → no change / wrong mode ⇒ [R6](#r6).
|
||||
|
||||
5.2 Confirm `focus.move(...)` is **blocked** in any AF mode and **allowed** in `manual`.
|
||||
|
||||
**Gate:** modes switch correctly and the manual-focus gate holds. → proceed.
|
||||
|
||||
### Stage 6 — Presets
|
||||
|
||||
6.1 Store a couple of presets on the camera (via its own UI). `dev.ptz_cam.recall_preset(1)`
|
||||
and confirm it drives to the stored pose; `preset_state.get()` returns `1`. → no
|
||||
move/error ⇒ [R3](#r3).
|
||||
|
||||
**Gate:** presets recall. → proceed.
|
||||
|
||||
### Stage 7 — Streaming & grab (Phase 2)
|
||||
|
||||
7.1 **Establish.** `dev.ptz_cam.start_stream()`, then `dev.ptz_cam.stream_stats()`.
|
||||
*Confirm* `worker.establishment_time_s` is populated (≈ the expected ~2 s, **once**) and
|
||||
`buffer.size` grows. → never any frame ⇒ [R10](#r10) (stream URL/format).
|
||||
|
||||
7.2 **Grab latency.** Time `dev.ptz_cam.get_latest()` — expect sub-millisecond, **no**
|
||||
re-establishment cost. Grab repeatedly during simulated DAQ activity.
|
||||
|
||||
7.3 **Decode.** `img = dev.ptz_cam.get_latest(decode=True)`; confirm it's a real RGB array
|
||||
of the expected resolution. → decode returns `None` ⇒ frames aren't standalone JPEGs
|
||||
([R10](#r10)).
|
||||
|
||||
7.4 **Resilience.** Pull the network briefly; confirm `stream_stats()` shows `reconnects`
|
||||
incrementing, `healthy` recovering, and **DAQ never blocks** (last good frames still
|
||||
served).
|
||||
|
||||
**Gate:** stream establishes once, grabs are cheap, decode works, reconnect is fail-soft.
|
||||
|
||||
### Stage 8 — Full BEC integration
|
||||
|
||||
8.1 Run a real scan with the camera staged; confirm staging/unstaging is clean and the
|
||||
stream persists across scans (no per-scan 2 s cost).
|
||||
|
||||
8.2 Confirm the live `preview` reaches the GUI (when Phase-2 preview delivery is wired).
|
||||
|
||||
8.3 Confirm `on_destroy` releases control and closes the session (next client can claim).
|
||||
|
||||
### Stage 9 — Soak
|
||||
|
||||
9.1 Leave the device connected and streaming for hours; confirm no socket/thread leak,
|
||||
session stays alive (or reconnects), and memory is bounded (ring buffer caps it).
|
||||
|
||||
---
|
||||
|
||||
## 7. Recording template
|
||||
|
||||
Copy per stage when commissioning:
|
||||
|
||||
```
|
||||
Stage __ : ____________________ date: ______ operator: ______
|
||||
firmware: ______ device cfg: ______
|
||||
|
||||
step result(P/F) observed value / note risk item if F
|
||||
---- ----------- ------------------------------- --------------
|
||||
__.1 [ ] ______________________________ R__
|
||||
__.2 [ ] ______________________________ R__
|
||||
|
||||
SPEC tables changed (file:line → old → new):
|
||||
____________________________________________________________
|
||||
|
||||
Outcome: [ ] PASS — proceed [ ] BLOCKED — see notes
|
||||
```
|
||||
|
||||
When you adjust a `# SPEC:` table to match the real camera, note the change here **and**
|
||||
in the commit message, so the calibrated values become the documented truth for the next
|
||||
deployment.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Canon CR-N300 PTZ camera — BEC integration
|
||||
|
||||
A custom (non-EPICS) ophyd device for the **Canon CR-N300** PTZ remote camera,
|
||||
integrated into BEC. It provides standalone PTZ/zoom/focus motion and camera-parameter
|
||||
read/write (Phase 1), plus the seams for persistent image streaming into a ring buffer
|
||||
that DAQ can grab from on demand (Phase 2).
|
||||
|
||||
## Status
|
||||
|
||||
| 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** |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
devices/canon/
|
||||
├── transport/ # control plane — pure stdlib, no ophyd, no imaging
|
||||
│ ├── base.py # CameraTransport ABC + Axis/FocusMode/limits
|
||||
│ ├── xc.py # XCTransport — Canon XC Control Protocol (HTTP/CGI)
|
||||
│ ├── fake.py # FakeTransport — offline/sim + tests
|
||||
│ └── errors.py # typed exceptions
|
||||
├── 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)
|
||||
├── positioner.py # CanonAxis — transport-backed ophyd positioner
|
||||
└── canon_ptz.py # CanonPTZCamera — the BEC ophyd device
|
||||
```
|
||||
|
||||
Key boundaries:
|
||||
|
||||
* **Control never depends on imaging.** `transport/` and `acquisition/` import neither
|
||||
ophyd nor each other. `canon_ptz.py` imports the acquisition subsystem *lazily and
|
||||
guarded*, so PTZ/focus/parameter control works with the imaging stack absent.
|
||||
* **The transport is injectable/mockable.** The ophyd device talks to the camera only
|
||||
through the `CameraTransport` interface. `XCTransport` uses an injectable HTTP
|
||||
`opener`; `FakeTransport` is a full in-memory implementation. No hardware is needed
|
||||
for tests or for `simulation: true`.
|
||||
|
||||
## Control protocol
|
||||
|
||||
The transport implements the **Canon XC Control Protocol** (session-based HTTP/CGI:
|
||||
`open` → `claim` → command CGIs → `yield`/`close`), chosen for full parameter
|
||||
read-out/set coverage. The HTTP/session/auth/retry machinery is complete and unit
|
||||
tested; the **exact CGI paths, parameter names and the native pan/tilt/zoom/focus
|
||||
encodings are defined by Canon doc BPE-7216-005** and are isolated in the clearly
|
||||
marked `_CGI` / `_PARAM` / `_AXIS_ENCODING` tables (and `DEFAULT_CGI_PREFIX`) at the
|
||||
top of `transport/xc.py`. Each carries a `# SPEC:` marker — bringing the device up
|
||||
against a real camera means confirming those tables, not changing logic. A VISCA-over-IP
|
||||
transport could be added later behind the same `CameraTransport` interface.
|
||||
|
||||
## Connection & credentials
|
||||
|
||||
Credentials are **never** stored in the YAML config / Redis. The device reads them from
|
||||
environment variables at connect:
|
||||
|
||||
```
|
||||
CANON_<NAME>_USER # e.g. CANON_PTZ_CAM_USER
|
||||
CANON_<NAME>_PASSWORD # e.g. CANON_PTZ_CAM_PASSWORD
|
||||
```
|
||||
|
||||
`<NAME>` is the device name upper-cased (non-alphanumerics → `_`). Override the names
|
||||
with `username_env` / `password_env` in `deviceConfig`.
|
||||
|
||||
The session lifecycle maps onto ophyd/BEC: `wait_for_connection()` opens the session and
|
||||
claims control; `destroy()` yields control and closes the session.
|
||||
|
||||
## Configuration
|
||||
|
||||
See [`device_configs/canon_crn300.yaml`](../../device_configs/canon_crn300.yaml) for
|
||||
full real-camera and simulation examples. Minimal real-camera entry:
|
||||
|
||||
```yaml
|
||||
ptz_cam:
|
||||
deviceClass: csaxs_bec.devices.canon.canon_ptz.CanonPTZCamera
|
||||
deviceConfig:
|
||||
host: 192.168.0.100
|
||||
port: 80
|
||||
enabled: true
|
||||
readoutPriority: on_request
|
||||
```
|
||||
|
||||
Minimal offline/simulation entry (no hardware):
|
||||
|
||||
```yaml
|
||||
ptz_cam_sim:
|
||||
deviceClass: csaxs_bec.devices.canon.canon_ptz.CanonPTZCamera
|
||||
deviceConfig:
|
||||
simulation: true
|
||||
enabled: true
|
||||
readoutPriority: on_request
|
||||
```
|
||||
|
||||
`deviceConfig` keys: `host`, `port`, `timeout`, `cgi_prefix`, `simulation`,
|
||||
`gradual_motion`, `username_env`, `password_env`, `pan_speed`, `tilt_speed`,
|
||||
`buffer_maxlen`, `stream_url`, `stream_start` (`off` | `connect` | `stage`).
|
||||
|
||||
## Supported commands
|
||||
|
||||
Motion — each axis is a positioner (`move()`/`set()` → ophyd status, `stop()`):
|
||||
|
||||
| Axis | Unit | Range (hardware) |
|
||||
|------|------|------------------|
|
||||
| `pan` | deg | ±170 (0.2–300 deg/s) |
|
||||
| `tilt` | deg | −30 … +100 (0.2–170 deg/s) |
|
||||
| `zoom` | × (optical) | 1.0 … 20.0 |
|
||||
| `focus` | normalised | 0.0 (near) … 1.0 (far), manual-focus only |
|
||||
|
||||
```python
|
||||
cam.pan.move(45.0) # blocking; returns a completed status
|
||||
st = cam.tilt.set(-10.0) # non-blocking; st.wait() to block
|
||||
cam.zoom.move(5.0)
|
||||
cam.jog_pan(30.0) # continuous pan at 30 deg/s; jog_pan(0) stops
|
||||
cam.stop_all() # halt every axis
|
||||
cam.recall_preset(3) # recall a stored PTZ preset
|
||||
```
|
||||
|
||||
Parameters (read via `read()`/`read_configuration()`, set with read-back verification):
|
||||
`exposure_mode`, `iris`, `gain`, `white_balance`, `shutter_speed`, `focus_mode`
|
||||
(`manual` | `continuous_af` | `face_af` | `tracking_af`), and read-only `preset_state`.
|
||||
|
||||
```python
|
||||
cam.gain.set(6.0).wait()
|
||||
cam.focus_mode.set("manual").wait() # focus axis only moves in manual mode
|
||||
```
|
||||
|
||||
## Buffer-and-grab API (Phase 2)
|
||||
|
||||
A persistent worker opens the stream **once** (paying the ~2 s establishment cost up
|
||||
front, never inside a scan) and fills a bounded ring buffer of *compressed* JPEG frames,
|
||||
each tagged with a monotonic + wall-clock timestamp and source id. DAQ grabs with O(1)
|
||||
latency; if it never grabs, frames simply cycle out.
|
||||
|
||||
```python
|
||||
cam.start_stream() # open the stream + start the worker (idempotent)
|
||||
frame = cam.get_latest() # most recent Frame (compressed bytes + timestamps)
|
||||
frames = cam.get_latest_n(5) # last 5, oldest-first
|
||||
frame = cam.get_nearest(ts) # closest to a wall-clock timestamp
|
||||
img = cam.get_latest(decode=True) # decode only the grabbed frame (uses cv2)
|
||||
cam.stream_stats() # ring-buffer + worker instrumentation (latency, health)
|
||||
cam.stop_stream()
|
||||
```
|
||||
|
||||
Set `stream_start: connect` (or `stage`) in `deviceConfig` to start the stream
|
||||
automatically. Buffering the compressed bytes (not decoded arrays) keeps memory small
|
||||
and spends no CPU decoding frames nobody grabs; decoding happens only on grab. Frame
|
||||
delivery into a scan follows the area-detector convention (file-writer references via
|
||||
`SUB_FILE_EVENT` for full frames, `PreviewSignal` for live preview) — those hooks exist
|
||||
and are completed in Phase 2.
|
||||
|
||||
> 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.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd csaxs_bec
|
||||
OPHYD_CONTROL_LAYER=dummy pytest tests/tests_devices/test_canon_*.py --random-order
|
||||
```
|
||||
|
||||
No hardware required. Coverage: transport (`FakeTransport` behaviour + `XCTransport`
|
||||
HTTP/session logic via a mocked opener), the ring buffer (incl. thread-safety), the
|
||||
stream worker (fail-soft reconnect, no thread leak), and the ophyd device (motion
|
||||
status, limits, parameter read-back verification, focus-mode gating, simulation
|
||||
streaming, credential handling).
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Canon CR-N300 PTZ remote camera integration for BEC.
|
||||
|
||||
Public entry point is :class:`CanonPTZCamera`. The control transport
|
||||
(:mod:`.transport`) and the Phase-2 acquisition subsystem (:mod:`.acquisition`) are
|
||||
independently importable and free of ophyd, so standalone control works with the
|
||||
imaging stack absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .canon_ptz import CanonPTZCamera
|
||||
from .positioner import CanonAxis
|
||||
from .transport import Axis, CameraTransport, FakeTransport, FocusMode, XCTransport
|
||||
|
||||
__all__ = [
|
||||
"CanonPTZCamera",
|
||||
"CanonAxis",
|
||||
"Axis",
|
||||
"FocusMode",
|
||||
"CameraTransport",
|
||||
"FakeTransport",
|
||||
"XCTransport",
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Persistent frame acquisition + ring buffering for the Canon CR-N300 (Phase 2).
|
||||
|
||||
Pure standard library. Imports neither ophyd nor the control transport, so the
|
||||
control plane never depends on imaging. The ophyd device imports this lazily/guarded
|
||||
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, StreamWorker
|
||||
|
||||
__all__ = [
|
||||
"Frame",
|
||||
"FrameRingBuffer",
|
||||
"FrameSource",
|
||||
"IterableFrameSource",
|
||||
"MJPEGHttpSource",
|
||||
"StreamWorker",
|
||||
]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Bounded, thread-safe ring buffer of recent (compressed) camera frames.
|
||||
|
||||
This is the heart of the buffer-and-grab model (Phase 2). A persistent acquisition
|
||||
worker pushes *compressed* JPEG frames in; DAQ pulls recent frames out with O(1)
|
||||
latency and no setup cost. Storing the compressed bytes (rather than decoded arrays)
|
||||
keeps memory small -- a 4K JPEG is tens of kilobytes vs ~24 MB decoded -- and means
|
||||
no CPU is spent decoding frames nobody grabs. Decoding happens only when a frame is
|
||||
actually retrieved (see the device's grab API).
|
||||
|
||||
Pure standard library: no ophyd, no networking, no transport import. The buffer does
|
||||
not care whether the bytes are real or synthetic, which makes it trivially testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Literal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Frame:
|
||||
"""A single buffered frame.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
data:
|
||||
The raw, still-compressed frame payload (e.g. JPEG bytes). Never decoded by
|
||||
the buffer.
|
||||
seq:
|
||||
Monotonic per-source sequence number assigned on insertion (0, 1, 2, ...).
|
||||
monotonic_ts:
|
||||
``time.monotonic()`` at insertion -- for measuring intervals/latency.
|
||||
wall_ts:
|
||||
``time.time()`` (Unix epoch) at insertion -- for correlating with experiment
|
||||
time. :meth:`FrameRingBuffer.get_nearest` matches against this by default.
|
||||
source_id:
|
||||
Identifier of the stream/camera this frame came from.
|
||||
"""
|
||||
|
||||
data: bytes
|
||||
seq: int
|
||||
monotonic_ts: float
|
||||
wall_ts: float
|
||||
source_id: str
|
||||
|
||||
@property
|
||||
def nbytes(self) -> int:
|
||||
return len(self.data)
|
||||
|
||||
|
||||
class FrameRingBuffer:
|
||||
"""A fixed-capacity ring of :class:`Frame` objects for one source.
|
||||
|
||||
Oldest frames are overwritten once ``maxlen`` is reached. All public methods are
|
||||
thread-safe. If DAQ never grabs, frames simply cycle out -- this is not an error.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
maxlen:
|
||||
Maximum number of frames retained. Must be >= 1.
|
||||
source_id:
|
||||
Default source id stamped onto frames added via :meth:`put` without an
|
||||
explicit source.
|
||||
time_func / wall_time_func:
|
||||
Injectable clocks (monotonic / wall) for deterministic tests.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
maxlen: int,
|
||||
*,
|
||||
source_id: str = "default",
|
||||
time_func=time.monotonic,
|
||||
wall_time_func=time.time,
|
||||
) -> None:
|
||||
if maxlen < 1:
|
||||
raise ValueError("maxlen must be >= 1")
|
||||
self._maxlen = maxlen
|
||||
self._source_id = source_id
|
||||
self._mono = time_func
|
||||
self._wall = wall_time_func
|
||||
self._lock = threading.RLock()
|
||||
self._frames: deque[Frame] = deque(maxlen=maxlen)
|
||||
self._seq = 0
|
||||
self._dropped = 0 # frames evicted unread (informational only)
|
||||
self._total = 0
|
||||
|
||||
@property
|
||||
def maxlen(self) -> int:
|
||||
return self._maxlen
|
||||
|
||||
@property
|
||||
def source_id(self) -> str:
|
||||
return self._source_id
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._frames)
|
||||
|
||||
def put(self, data: bytes, *, source_id: str | None = None) -> Frame:
|
||||
"""Insert a compressed frame, stamping it with sequence + timestamps."""
|
||||
with self._lock:
|
||||
if len(self._frames) == self._maxlen:
|
||||
self._dropped += 1
|
||||
frame = Frame(
|
||||
data=data,
|
||||
seq=self._seq,
|
||||
monotonic_ts=self._mono(),
|
||||
wall_ts=self._wall(),
|
||||
source_id=source_id if source_id is not None else self._source_id,
|
||||
)
|
||||
self._frames.append(frame)
|
||||
self._seq += 1
|
||||
self._total += 1
|
||||
return frame
|
||||
|
||||
def get_latest(self) -> Frame | None:
|
||||
"""Return the most recently inserted frame, or ``None`` if empty."""
|
||||
with self._lock:
|
||||
return self._frames[-1] if self._frames else None
|
||||
|
||||
def get_latest_n(self, n: int) -> list[Frame]:
|
||||
"""Return up to *n* most recent frames, oldest-first."""
|
||||
if n <= 0:
|
||||
return []
|
||||
with self._lock:
|
||||
if n >= len(self._frames):
|
||||
return list(self._frames)
|
||||
return list(self._frames)[-n:]
|
||||
|
||||
def get_nearest(
|
||||
self, timestamp: float, *, clock: Literal["wall", "monotonic"] = "wall"
|
||||
) -> Frame | None:
|
||||
"""Return the frame whose timestamp is closest to *timestamp*.
|
||||
|
||||
Matches against the wall-clock stamp by default (for correlating with
|
||||
experiment time); pass ``clock="monotonic"`` to match monotonic stamps.
|
||||
Returns ``None`` if the buffer is empty.
|
||||
"""
|
||||
attr = "wall_ts" if clock == "wall" else "monotonic_ts"
|
||||
with self._lock:
|
||||
if not self._frames:
|
||||
return None
|
||||
return min(self._frames, key=lambda f: abs(getattr(f, attr) - timestamp))
|
||||
|
||||
def snapshot(self) -> list[Frame]:
|
||||
"""Return a copy of all currently buffered frames, oldest-first."""
|
||||
with self._lock:
|
||||
return list(self._frames)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._frames.clear()
|
||||
|
||||
def extend(self, items: Iterable[bytes], *, source_id: str | None = None) -> None:
|
||||
for data in items:
|
||||
self.put(data, source_id=source_id)
|
||||
|
||||
def stats(self) -> dict[str, int]:
|
||||
"""Return buffer statistics (current size, capacity, totals, evictions)."""
|
||||
with self._lock:
|
||||
return {
|
||||
"size": len(self._frames),
|
||||
"maxlen": self._maxlen,
|
||||
"total_received": self._total,
|
||||
"dropped_unread": self._dropped,
|
||||
"bytes_buffered": sum(f.nbytes for f in self._frames),
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Persistent frame-acquisition worker feeding a :class:`FrameRingBuffer`.
|
||||
|
||||
Design goals (from the integration brief):
|
||||
|
||||
* A stream is opened **once** -- ideally at device connect/stage -- and kept running
|
||||
for the whole session. The expensive (~2 s) stream *establishment* is paid up front,
|
||||
never inside a scan.
|
||||
* The worker pushes *compressed* frames into a ring buffer; DAQ pulls with O(1)
|
||||
latency. If DAQ never grabs, frames just cycle out.
|
||||
* Reconnects on stream drop are **fail-soft**: they never block consumers, the last
|
||||
good frames keep being served, and the event is logged.
|
||||
* Establishment time and per-frame intervals are measured so we can confirm the
|
||||
~2 s cost is paid once and grabs are cheap.
|
||||
|
||||
This module is pure standard library and imports neither ophyd nor the control
|
||||
transport: acquisition is never a dependency of control.
|
||||
|
||||
GIL note: the HTTP/socket reads below release the GIL while blocked on I/O, so the
|
||||
worker thread does not starve the main interpreter. No decoding happens here -- we
|
||||
only split and buffer compressed bytes -- so there is no Python-level hot loop. If a
|
||||
future inter-frame codec (H.264/H.265 via RTSP) is ever required it would force
|
||||
continuous decoding in this thread; that is explicitly out of scope here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Iterable, Iterator
|
||||
|
||||
try: # pragma: no cover - logging is best-effort
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
except Exception: # pragma: no cover
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .ring_buffer import FrameRingBuffer
|
||||
|
||||
# JPEG start-of-image / end-of-image markers, used to split an MJPEG byte stream.
|
||||
_JPEG_SOI = b"\xff\xd8"
|
||||
_JPEG_EOI = b"\xff\xd9"
|
||||
|
||||
|
||||
class FrameSource(abc.ABC):
|
||||
"""A source of compressed frames. ``open()`` then iterate ``frames()``."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def open(self, *, timeout: float | None = None) -> None:
|
||||
"""Establish the underlying stream (the expensive, once-per-session step)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def frames(self) -> Iterator[bytes]:
|
||||
"""Yield compressed frame payloads until the stream ends or is closed."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def close(self) -> None:
|
||||
"""Tear down the stream. Idempotent; must not raise."""
|
||||
|
||||
|
||||
class IterableFrameSource(FrameSource):
|
||||
"""Wraps an iterable/callable of ``bytes`` -- used for the fake feed and tests.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
frames_iterable:
|
||||
Either an iterable of ``bytes`` or a zero-arg callable returning one
|
||||
``bytes`` frame per call. A callable yields frames indefinitely.
|
||||
delay:
|
||||
Seconds to sleep between yielded frames (simulates frame rate).
|
||||
open_delay:
|
||||
Seconds to sleep inside :meth:`open` (simulates the ~2 s establishment).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
frames_iterable: Iterable[bytes] | Callable[[], bytes],
|
||||
*,
|
||||
delay: float = 0.0,
|
||||
open_delay: float = 0.0,
|
||||
) -> None:
|
||||
self._src = frames_iterable
|
||||
self._delay = delay
|
||||
self._open_delay = open_delay
|
||||
self._closed = False
|
||||
|
||||
def open(self, *, timeout: float | None = None) -> None:
|
||||
self._closed = False
|
||||
if self._open_delay:
|
||||
time.sleep(self._open_delay)
|
||||
|
||||
def frames(self) -> Iterator[bytes]:
|
||||
if callable(self._src):
|
||||
while not self._closed:
|
||||
yield self._src()
|
||||
if self._delay:
|
||||
time.sleep(self._delay)
|
||||
else:
|
||||
for frame in self._src:
|
||||
if self._closed:
|
||||
break
|
||||
yield frame
|
||||
if self._delay:
|
||||
time.sleep(self._delay)
|
||||
|
||||
def close(self) -> None:
|
||||
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:
|
||||
import urllib.request
|
||||
|
||||
handlers = []
|
||||
if self._auth is not None:
|
||||
mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
|
||||
mgr.add_password(None, self.url, self._auth[0], self._auth[1])
|
||||
handlers.append(urllib.request.HTTPDigestAuthHandler(mgr))
|
||||
handlers.append(urllib.request.HTTPBasicAuthHandler(mgr))
|
||||
opener = urllib.request.build_opener(*handlers)
|
||||
self._response = opener.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 StreamWorker:
|
||||
"""Owns a background thread that keeps a :class:`FrameSource` feeding a buffer.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
source:
|
||||
The frame source to keep alive.
|
||||
buffer:
|
||||
The ring buffer to push frames into.
|
||||
name:
|
||||
Human-readable worker name (used for the thread name and logs).
|
||||
open_timeout:
|
||||
Timeout passed to :meth:`FrameSource.open`.
|
||||
reconnect_delay / max_reconnect_delay:
|
||||
Initial and capped backoff between reconnect attempts after a drop.
|
||||
time_func:
|
||||
Injectable monotonic clock for deterministic latency measurement in tests.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: FrameSource,
|
||||
buffer: FrameRingBuffer,
|
||||
*,
|
||||
name: str = "canon-stream",
|
||||
open_timeout: float = 10.0,
|
||||
reconnect_delay: float = 2.0,
|
||||
max_reconnect_delay: float = 30.0,
|
||||
time_func: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.buffer = buffer
|
||||
self.name = name
|
||||
self.open_timeout = open_timeout
|
||||
self.reconnect_delay = reconnect_delay
|
||||
self.max_reconnect_delay = max_reconnect_delay
|
||||
self._time = time_func
|
||||
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop = threading.Event()
|
||||
self._first_frame = threading.Event()
|
||||
|
||||
# Instrumentation.
|
||||
self._start_t: float | None = None
|
||||
self._establishment_time: float | None = None
|
||||
self._frames_received = 0
|
||||
self._last_frame_t: float | None = None
|
||||
self._reconnects = 0
|
||||
self._healthy = False
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the worker thread. Idempotent."""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._first_frame.clear()
|
||||
self._establishment_time = None
|
||||
self._start_t = self._time()
|
||||
self._thread = threading.Thread(target=self._run, name=self.name, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, *, timeout: float = 5.0) -> None:
|
||||
"""Signal the worker to stop, close the source, and join the thread."""
|
||||
self._stop.set()
|
||||
try:
|
||||
self.source.close()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=timeout)
|
||||
if self._thread.is_alive():
|
||||
logger.warning(f"Stream worker {self.name!r} did not stop within {timeout}s")
|
||||
self._thread = None
|
||||
self._healthy = False
|
||||
|
||||
def wait_for_first_frame(self, timeout: float | None = None) -> bool:
|
||||
"""Block until the first frame arrives (or timeout). Returns success."""
|
||||
return self._first_frame.wait(timeout=timeout)
|
||||
|
||||
# -- worker loop -------------------------------------------------------
|
||||
|
||||
def _run(self) -> None:
|
||||
delay = self.reconnect_delay
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.source.open(timeout=self.open_timeout)
|
||||
self._healthy = True
|
||||
delay = self.reconnect_delay # reset backoff after a good open
|
||||
for frame in self.source.frames():
|
||||
if self._stop.is_set():
|
||||
break
|
||||
now = self._time()
|
||||
if self._establishment_time is None and self._start_t is not None:
|
||||
self._establishment_time = now - self._start_t
|
||||
self.buffer.put(frame)
|
||||
self._frames_received += 1
|
||||
self._last_frame_t = now
|
||||
self._first_frame.set()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# Fail soft: log, keep the last good frames, back off, retry.
|
||||
self._healthy = False
|
||||
logger.warning(f"Stream worker {self.name!r} error: {exc}; reconnecting")
|
||||
finally:
|
||||
try:
|
||||
self.source.close()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
if self._stop.is_set():
|
||||
break
|
||||
self._reconnects += 1
|
||||
# Interruptible backoff so stop() is responsive.
|
||||
self._stop.wait(timeout=delay)
|
||||
delay = min(delay * 2, self.max_reconnect_delay)
|
||||
|
||||
# -- instrumentation ---------------------------------------------------
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
@property
|
||||
def healthy(self) -> bool:
|
||||
"""Whether the source is currently open and delivering frames."""
|
||||
return self._healthy
|
||||
|
||||
@property
|
||||
def establishment_time(self) -> float | None:
|
||||
"""Seconds from :meth:`start` to the first buffered frame (``None`` if not yet)."""
|
||||
return self._establishment_time
|
||||
|
||||
def stats(self) -> dict[str, object]:
|
||||
"""Return worker instrumentation for latency/health monitoring."""
|
||||
last_age = None
|
||||
if self._last_frame_t is not None:
|
||||
last_age = self._time() - self._last_frame_t
|
||||
return {
|
||||
"name": self.name,
|
||||
"running": self.running,
|
||||
"healthy": self._healthy,
|
||||
"establishment_time_s": self._establishment_time,
|
||||
"frames_received": self._frames_received,
|
||||
"reconnects": self._reconnects,
|
||||
"last_frame_age_s": last_age,
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
"""Canon CR-N300 PTZ remote camera -- BEC ophyd device.
|
||||
|
||||
Phase 1 (this module): standalone control -- parameter read/write and PTZ/zoom/focus
|
||||
motion -- working with the imaging/streaming stack absent. Phase 2 seams (persistent
|
||||
acquisition worker + ring buffer + grab API + preview) are wired here but the heavy
|
||||
network/decoder work is deferred; the acquisition subsystem is imported lazily and
|
||||
guarded so control never depends on it.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
* ``self.transport`` -- a :class:`CameraTransport` (real ``XCTransport`` or, when
|
||||
``simulation: true``, ``FakeTransport``). All control goes through it.
|
||||
* ``pan/tilt/zoom/focus`` -- :class:`CanonAxis` positioners sharing the transport.
|
||||
* parameter signals -- read-only and settable signals that proxy to the transport.
|
||||
* ``preview`` + ring buffer + :class:`StreamWorker` -- Phase-2 buffer-and-grab path.
|
||||
|
||||
Credentials are read from environment variables (never from the YAML/Redis config):
|
||||
``CANON_<NAME>_USER`` / ``CANON_<NAME>_PASSWORD`` by default (``<NAME>`` = the device
|
||||
name upper-cased), overridable via ``username_env`` / ``password_env``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd import DeviceStatus, Kind, Signal
|
||||
from ophyd.utils import ReadOnlyError
|
||||
from ophyd_devices import PreviewSignal
|
||||
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
|
||||
|
||||
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, XCTransport
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.devicemanager import ScanInfo
|
||||
|
||||
try: # pragma: no cover
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
except Exception: # pragma: no cover
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Parameter names handled specially by the device (not generic info/control params).
|
||||
_FOCUS_MODE_PARAM = "focus_mode"
|
||||
_PRESET_STATE_PARAM = "preset_state"
|
||||
|
||||
|
||||
class CanonParameterSignal(Signal):
|
||||
"""Read-only signal proxying a single camera parameter through the transport."""
|
||||
|
||||
def __init__(self, *args, cmd: str, **kwargs):
|
||||
self._cmd = cmd
|
||||
super().__init__(*args, **kwargs)
|
||||
self._metadata.update(connected=True, write_access=False)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def get(self, **kwargs):
|
||||
value = self.parent._get_parameter(self._cmd) # noqa: SLF001
|
||||
self._readback = value
|
||||
return value
|
||||
|
||||
def put(self, value, **kwargs):
|
||||
raise ReadOnlyError(f"{self.name} is read-only")
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
return time.time()
|
||||
|
||||
|
||||
class CanonConfigSignal(CanonParameterSignal):
|
||||
"""Settable camera parameter with read-back verification on write."""
|
||||
|
||||
def __init__(self, *args, cmd: str, verify: bool = True, rtol: float = 1e-3, **kwargs):
|
||||
self._verify = verify
|
||||
self._rtol = rtol
|
||||
super().__init__(*args, cmd=cmd, **kwargs)
|
||||
self._metadata.update(write_access=True)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def put(self, value, **kwargs):
|
||||
self.parent._set_parameter(self._cmd, value) # noqa: SLF001
|
||||
if self._verify:
|
||||
readback = self.parent._get_parameter(self._cmd) # noqa: SLF001
|
||||
if not self._matches(readback, value):
|
||||
raise CanonParameterError(
|
||||
f"{self.name}: read-back {readback!r} does not match set value {value!r}"
|
||||
)
|
||||
self._readback = value
|
||||
self._run_subs(sub_type=self.SUB_VALUE, old_value=None, value=value)
|
||||
|
||||
def set(self, value, **kwargs) -> DeviceStatus:
|
||||
status = DeviceStatus(self)
|
||||
try:
|
||||
self.put(value)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
status.set_exception(exc)
|
||||
else:
|
||||
status.set_finished()
|
||||
return status
|
||||
|
||||
def _matches(self, readback, value) -> bool:
|
||||
try:
|
||||
return abs(float(readback) - float(value)) <= self._rtol * max(1.0, abs(float(value)))
|
||||
except (TypeError, ValueError):
|
||||
return str(readback) == str(value)
|
||||
|
||||
|
||||
class CanonPTZCamera(PSIDeviceBase):
|
||||
"""BEC ophyd device for the Canon CR-N300 PTZ camera.
|
||||
|
||||
See module docstring for the architecture overview and credential handling.
|
||||
"""
|
||||
|
||||
USER_ACCESS = [
|
||||
"stop_all",
|
||||
"recall_preset",
|
||||
"jog_pan",
|
||||
"jog_tilt",
|
||||
"start_stream",
|
||||
"stop_stream",
|
||||
"get_latest",
|
||||
"get_latest_n",
|
||||
"get_nearest",
|
||||
"stream_stats",
|
||||
"transport_info",
|
||||
]
|
||||
|
||||
# -- motion axes (look like motors to BEC) -----------------------------
|
||||
pan = Cpt(CanonAxis, axis=Axis.PAN, egu="deg", tolerance=0.2, kind=Kind.normal)
|
||||
tilt = Cpt(CanonAxis, axis=Axis.TILT, egu="deg", tolerance=0.2, kind=Kind.normal)
|
||||
zoom = Cpt(CanonAxis, axis=Axis.ZOOM, egu="x", tolerance=0.05, kind=Kind.normal)
|
||||
focus = Cpt(CanonAxis, axis=Axis.FOCUS, egu="", tolerance=0.01, kind=Kind.normal)
|
||||
|
||||
# -- camera parameters -------------------------------------------------
|
||||
exposure_mode = Cpt(CanonConfigSignal, cmd="exposure_mode", verify=False, kind=Kind.config)
|
||||
iris = Cpt(CanonConfigSignal, cmd="iris", kind=Kind.config)
|
||||
gain = Cpt(CanonConfigSignal, cmd="gain", kind=Kind.config)
|
||||
white_balance = Cpt(CanonConfigSignal, cmd="white_balance", verify=False, kind=Kind.config)
|
||||
shutter_speed = Cpt(CanonConfigSignal, cmd="shutter_speed", verify=False, kind=Kind.config)
|
||||
focus_mode = Cpt(CanonConfigSignal, cmd=_FOCUS_MODE_PARAM, verify=False, kind=Kind.config)
|
||||
preset_state = Cpt(CanonParameterSignal, cmd=_PRESET_STATE_PARAM, kind=Kind.normal)
|
||||
|
||||
# -- Phase-2 live preview ----------------------------------------------
|
||||
preview = Cpt(PreviewSignal, ndim=2, num_rotation_90=0, transpose=False, kind=Kind.omitted)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
host: str | None = None,
|
||||
port: int = 80,
|
||||
simulation: bool = False,
|
||||
cgi_prefix: str = DEFAULT_CGI_PREFIX,
|
||||
timeout: float = 5.0,
|
||||
username_env: str | None = None,
|
||||
password_env: str | None = None,
|
||||
gradual_motion: bool = False,
|
||||
buffer_maxlen: int = 64,
|
||||
stream_url: str | None = None,
|
||||
stream_start: str = "off",
|
||||
pan_speed: float | None = None,
|
||||
tilt_speed: float | None = None,
|
||||
scan_info: "ScanInfo | None" = None,
|
||||
device_manager=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""See class/module docstring for parameter meanings.
|
||||
|
||||
Args:
|
||||
host: camera IP/hostname (ignored when ``simulation`` is true).
|
||||
port: HTTP port (default 80).
|
||||
simulation: when true, use :class:`FakeTransport` -- no hardware needed.
|
||||
cgi_prefix: XC CGI path prefix (see ``xc.py``; verify vs BPE-7216-005).
|
||||
timeout: per-request transport timeout in seconds.
|
||||
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 (Phase 2). Defaults derived if omitted.
|
||||
stream_start: one of ``off`` | ``connect`` | ``stage`` -- when to start
|
||||
the persistent acquisition worker.
|
||||
pan_speed/tilt_speed: optional move speeds (deg/s) for those axes.
|
||||
"""
|
||||
if stream_start not in ("off", "connect", "stage"):
|
||||
raise ValueError(f"stream_start must be off|connect|stage, got {stream_start!r}")
|
||||
if not simulation and not host:
|
||||
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
|
||||
|
||||
# Shared lock serialising all transport access across axes/signals.
|
||||
import threading
|
||||
|
||||
self._transport_lock = threading.RLock()
|
||||
|
||||
# Build the transport BEFORE super().__init__ so axis components can reach it.
|
||||
if simulation:
|
||||
self.transport: CameraTransport = FakeTransport(gradual_motion=gradual_motion)
|
||||
else:
|
||||
user, password = self._read_credentials(name, username_env, password_env)
|
||||
self.transport = XCTransport(
|
||||
host=host,
|
||||
port=port,
|
||||
username=user,
|
||||
password=password,
|
||||
cgi_prefix=cgi_prefix,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Phase-2 acquisition objects (created lazily in start_stream()).
|
||||
self._buffer = None
|
||||
self._worker = None
|
||||
|
||||
super().__init__(name=name, scan_info=scan_info, device_manager=device_manager, **kwargs)
|
||||
|
||||
# Apply optional per-axis move speeds.
|
||||
if pan_speed is not None:
|
||||
self.pan._speed = pan_speed # noqa: SLF001
|
||||
if tilt_speed is not None:
|
||||
self.tilt._speed = tilt_speed # noqa: SLF001
|
||||
# Focus is only movable in manual focus mode.
|
||||
self.focus.pre_move_hook = self._focus_move_guard
|
||||
|
||||
# -- credentials -------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _read_credentials(
|
||||
name: str, username_env: str | None, password_env: str | None
|
||||
) -> tuple[str | None, str | None]:
|
||||
slug = re.sub(r"[^A-Z0-9]+", "_", name.upper())
|
||||
user_var = username_env or f"CANON_{slug}_USER"
|
||||
pass_var = password_env or f"CANON_{slug}_PASSWORD"
|
||||
user = os.environ.get(user_var)
|
||||
password = os.environ.get(pass_var)
|
||||
if user is None and password is None:
|
||||
logger.warning(
|
||||
f"No XC credentials found in env ({user_var}/{pass_var}); "
|
||||
"connecting without authentication."
|
||||
)
|
||||
return user, password
|
||||
|
||||
# -- parameter routing -------------------------------------------------
|
||||
|
||||
def _get_parameter(self, name: str):
|
||||
with self._transport_lock:
|
||||
if name == _FOCUS_MODE_PARAM:
|
||||
return self.transport.get_focus_mode().value
|
||||
if name == _PRESET_STATE_PARAM:
|
||||
return self.transport.get_preset_state()
|
||||
return self.transport.get_parameter(name)
|
||||
|
||||
def _set_parameter(self, name: str, value) -> None:
|
||||
with self._transport_lock:
|
||||
if name == _FOCUS_MODE_PARAM:
|
||||
self.transport.set_focus_mode(FocusMode(value))
|
||||
return
|
||||
self.transport.set_parameter(name, value)
|
||||
|
||||
def _focus_move_guard(self, _position: float) -> None:
|
||||
mode = self.transport.get_focus_mode()
|
||||
if mode != FocusMode.MANUAL:
|
||||
raise RuntimeError(
|
||||
f"{self.name}: focus is in {mode.value} mode; switch focus_mode to "
|
||||
"'manual' before moving the focus axis"
|
||||
)
|
||||
|
||||
# -- connection lifecycle ----------------------------------------------
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def wait_for_connection(self, all_signals: bool = False, timeout: float = 10.0) -> None:
|
||||
"""Open the transport session, claim control, then wait for signals."""
|
||||
with self._transport_lock:
|
||||
self.transport.connect(timeout=timeout)
|
||||
self.transport.claim_control(timeout=timeout)
|
||||
super().wait_for_connection(all_signals=all_signals, timeout=timeout)
|
||||
|
||||
def on_connected(self) -> None:
|
||||
"""Log identity, seed positioner readbacks, optionally start streaming."""
|
||||
try:
|
||||
info = self.transport.get_camera_info()
|
||||
logger.info(f"{self.name} connected to {info.model} (fw {info.firmware})")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"{self.name}: could not read camera info: {exc}")
|
||||
for ax in (self.pan, self.tilt, self.zoom, self.focus):
|
||||
try:
|
||||
ax._update_readback(self.transport.get_position(ax._axis)) # noqa: SLF001
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"{self.name}: initial readback for {ax.name} failed: {exc}")
|
||||
if self._stream_start == "connect":
|
||||
self.start_stream()
|
||||
|
||||
def on_stage(self) -> None:
|
||||
"""Ensure the persistent stream is running if configured to start at stage."""
|
||||
if self._stream_start == "stage":
|
||||
self.start_stream()
|
||||
|
||||
def on_unstage(self) -> None:
|
||||
"""Leave the stream running across scans (it is opened once per session)."""
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""Halt all motion (axes are also stopped by the base class)."""
|
||||
try:
|
||||
with self._transport_lock:
|
||||
if self.transport.has_control:
|
||||
self.transport.stop(None)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"{self.name}: stop_all during on_stop failed: {exc}")
|
||||
|
||||
def on_destroy(self) -> None:
|
||||
"""Tear down the stream worker and close the transport session."""
|
||||
self.stop_stream()
|
||||
try:
|
||||
self.transport.close()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"{self.name}: transport close failed: {exc}")
|
||||
|
||||
# -- control convenience (USER_ACCESS) ---------------------------------
|
||||
|
||||
def stop_all(self) -> None:
|
||||
"""Halt every axis immediately."""
|
||||
for ax in (self.pan, self.tilt, self.zoom, self.focus):
|
||||
ax.stop()
|
||||
|
||||
def recall_preset(self, preset_id: int) -> None:
|
||||
"""Recall a stored PTZ preset by id."""
|
||||
with self._transport_lock:
|
||||
self.transport.recall_preset(int(preset_id))
|
||||
|
||||
def jog_pan(self, speed: float) -> None:
|
||||
"""Continuously pan at *speed* deg/s (sign = direction); 0 stops."""
|
||||
self.pan.jog(speed)
|
||||
|
||||
def jog_tilt(self, speed: float) -> None:
|
||||
"""Continuously tilt at *speed* deg/s (sign = direction); 0 stops."""
|
||||
self.tilt.jog(speed)
|
||||
|
||||
def transport_info(self) -> dict:
|
||||
"""Return connection/identity info for diagnostics."""
|
||||
info = self.transport.get_camera_info() if self.transport.connected else None
|
||||
return {
|
||||
"connected": self.transport.connected,
|
||||
"has_control": self.transport.has_control,
|
||||
"simulation": self._simulation,
|
||||
"model": getattr(info, "model", None),
|
||||
"firmware": getattr(info, "firmware", None),
|
||||
}
|
||||
|
||||
# -- Phase-2 streaming / grab API --------------------------------------
|
||||
|
||||
def _default_stream_url(self) -> str:
|
||||
# SPEC: the MJPEG stream URL form is defined by BPE-7216-005; this is a
|
||||
# reasonable default to verify against the real camera.
|
||||
host = getattr(self.transport, "host", "localhost")
|
||||
port = getattr(self.transport, "port", 80)
|
||||
prefix = getattr(self.transport, "cgi_prefix", DEFAULT_CGI_PREFIX)
|
||||
return f"http://{host}:{port}/{prefix}/image.cgi"
|
||||
|
||||
def start_stream(self) -> None:
|
||||
"""Start the persistent acquisition worker + ring buffer (idempotent).
|
||||
|
||||
Imports the acquisition subsystem lazily and guarded: if it is unavailable,
|
||||
control still works and a warning is logged.
|
||||
"""
|
||||
if self._worker is not None and self._worker.running:
|
||||
return
|
||||
try:
|
||||
from .acquisition import ( # pylint: disable=import-outside-toplevel
|
||||
FrameRingBuffer,
|
||||
IterableFrameSource,
|
||||
MJPEGHttpSource,
|
||||
StreamWorker,
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"{self.name}: acquisition subsystem unavailable: {exc}")
|
||||
return
|
||||
|
||||
if self._buffer is None:
|
||||
self._buffer = FrameRingBuffer(self._buffer_maxlen, source_id=self.name)
|
||||
|
||||
if self._simulation:
|
||||
source = IterableFrameSource(self._synthetic_jpeg, delay=0.05)
|
||||
else:
|
||||
url = self._stream_url or self._default_stream_url()
|
||||
auth = getattr(self.transport, "_auth", None)
|
||||
source = MJPEGHttpSource(url, auth=auth)
|
||||
|
||||
self._worker = StreamWorker(source, self._buffer, name=f"{self.name}-stream")
|
||||
self._worker.start()
|
||||
logger.info(f"{self.name}: stream worker started")
|
||||
|
||||
def stop_stream(self) -> None:
|
||||
"""Stop the acquisition worker cleanly (idempotent)."""
|
||||
if self._worker is not None:
|
||||
self._worker.stop()
|
||||
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(
|
||||
f"{self.name}: no frame buffer; call start_stream() (or set "
|
||||
"stream_start) before grabbing frames"
|
||||
)
|
||||
return self._buffer
|
||||
|
||||
def get_latest(self, decode: bool = False):
|
||||
"""Return the most recent buffered frame (``Frame`` or decoded array)."""
|
||||
frame = self._require_buffer().get_latest()
|
||||
return self._decode(frame) if (decode and frame is not None) else frame
|
||||
|
||||
def get_latest_n(self, n: int, decode: bool = False):
|
||||
"""Return up to *n* most recent frames, oldest-first."""
|
||||
frames = self._require_buffer().get_latest_n(n)
|
||||
return [self._decode(f) for f in frames] if decode else frames
|
||||
|
||||
def get_nearest(self, timestamp: float, decode: bool = False, clock: str = "wall"):
|
||||
"""Return the buffered frame closest to *timestamp*."""
|
||||
frame = self._require_buffer().get_nearest(timestamp, clock=clock)
|
||||
return self._decode(frame) if (decode and frame is not None) else frame
|
||||
|
||||
def stream_stats(self) -> dict:
|
||||
"""Return ring-buffer + worker instrumentation (latency, health, counts)."""
|
||||
stats: dict = {"buffer": self._buffer.stats() if self._buffer else None}
|
||||
if self._worker is not None:
|
||||
stats["worker"] = self._worker.stats()
|
||||
return stats
|
||||
|
||||
@staticmethod
|
||||
def _decode(frame):
|
||||
"""Decode a single buffered JPEG frame to an RGB array.
|
||||
|
||||
Phase-2 helper. Uses ``cv2`` (already a declared csaxs_bec dependency); only
|
||||
the frame actually grabbed is decoded.
|
||||
"""
|
||||
if frame is None:
|
||||
return None
|
||||
import cv2 # pylint: disable=import-outside-toplevel
|
||||
import numpy as np # pylint: disable=import-outside-toplevel
|
||||
|
||||
image = cv2.imdecode(np.frombuffer(frame.data, np.uint8), cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
return None
|
||||
return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Transport-backed positioner component for a single Canon PTZ/focus axis.
|
||||
|
||||
``CanonAxis`` is a real ophyd positioner (``Device`` + ``PositionerBase``) so that
|
||||
BEC treats pan/tilt/zoom/focus as motors: each exposes ``move()``/``set()`` returning
|
||||
a ``DeviceStatus`` that completes when the axis reaches target (or on timeout/stop),
|
||||
plus ``stop()``. It follows the threaded-move pattern of ``ophyd_devices.SimPositioner``
|
||||
but reads/writes through the parent device's injected
|
||||
:class:`~csaxs_bec.devices.canon.transport.base.CameraTransport` rather than a
|
||||
simulator backend, so the same class drives both the real camera and the fake.
|
||||
|
||||
The axis never opens its own connection; it borrows the parent's transport and a
|
||||
shared lock so concurrent axis commands are serialised onto the single HTTP session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from ophyd import Component as Cpt
|
||||
from ophyd import Device, DeviceStatus, Kind, PositionerBase, Signal
|
||||
from ophyd.utils import LimitError
|
||||
|
||||
from .transport.base import HARDWARE_LIMITS, Axis, CameraTransport
|
||||
|
||||
|
||||
class CanonAxis(Device, PositionerBase):
|
||||
"""One motion axis (pan/tilt/zoom/focus) of the Canon camera.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
axis:
|
||||
Which :class:`Axis` this positioner drives.
|
||||
egu:
|
||||
Engineering units for the readback (e.g. ``"deg"``).
|
||||
limits:
|
||||
Soft ``(low, high)`` limits. Defaults to the axis hardware limits. Soft
|
||||
limits may narrow, never widen, the hardware range.
|
||||
tolerance:
|
||||
Absolute readback tolerance for declaring a move complete.
|
||||
speed:
|
||||
Optional move speed (axis-units/s) passed to the transport on absolute moves.
|
||||
poll_interval:
|
||||
Seconds between readback polls while moving.
|
||||
move_timeout:
|
||||
Seconds before a move fails with ``TimeoutError``.
|
||||
"""
|
||||
|
||||
readback = Cpt(Signal, value=0.0, kind=Kind.hinted)
|
||||
setpoint = Cpt(Signal, value=0.0, kind=Kind.normal)
|
||||
motor_is_moving = Cpt(Signal, value=0, kind=Kind.normal)
|
||||
|
||||
SUB_READBACK = "readback"
|
||||
_default_sub = SUB_READBACK
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prefix: str = "",
|
||||
*,
|
||||
name: str,
|
||||
axis: Axis,
|
||||
egu: str = "",
|
||||
limits: tuple[float, float] | None = None,
|
||||
tolerance: float = 0.1,
|
||||
speed: float | None = None,
|
||||
poll_interval: float = 0.1,
|
||||
move_timeout: float = 30.0,
|
||||
parent=None,
|
||||
kind=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self._axis = Axis(axis)
|
||||
self._egu = egu
|
||||
self._tolerance = tolerance
|
||||
self._speed = speed
|
||||
self._poll_interval = poll_interval
|
||||
self._move_timeout = move_timeout
|
||||
self._stopped = False
|
||||
self._move_thread: threading.Thread | None = None
|
||||
self._fallback_lock = threading.RLock()
|
||||
# Optional gate set by the parent (e.g. focus axis only movable in MF).
|
||||
self.pre_move_hook: Callable[[float], None] | None = None
|
||||
|
||||
hw = HARDWARE_LIMITS[self._axis]
|
||||
if limits is None:
|
||||
self._limits: tuple[float, float] = (hw.low, hw.high)
|
||||
else:
|
||||
low, high = limits
|
||||
# Clamp soft limits into the hardware range.
|
||||
self._limits = (max(low, hw.low), min(high, hw.high))
|
||||
|
||||
super().__init__(prefix, name=name, parent=parent, kind=kind, **kwargs)
|
||||
|
||||
# -- transport access --------------------------------------------------
|
||||
|
||||
@property
|
||||
def _transport(self) -> CameraTransport:
|
||||
transport = getattr(self.parent, "transport", None)
|
||||
if transport is None:
|
||||
raise RuntimeError(f"{self.name}: parent has no transport")
|
||||
return transport
|
||||
|
||||
@property
|
||||
def _lock(self) -> threading.RLock:
|
||||
return getattr(self.parent, "_transport_lock", None) or self._fallback_lock
|
||||
|
||||
def _call(self, fn):
|
||||
"""Run a transport call under the shared lock."""
|
||||
with self._lock:
|
||||
return fn()
|
||||
|
||||
# -- positioner interface ----------------------------------------------
|
||||
|
||||
@property
|
||||
def limits(self) -> tuple[float, float]:
|
||||
return self._limits
|
||||
|
||||
@property
|
||||
def low_limit(self) -> float:
|
||||
return self._limits[0]
|
||||
|
||||
@property
|
||||
def high_limit(self) -> float:
|
||||
return self._limits[1]
|
||||
|
||||
@property
|
||||
def egu(self) -> str:
|
||||
return self._egu
|
||||
|
||||
@property
|
||||
def position(self) -> float:
|
||||
return self.readback.get()
|
||||
|
||||
def check_value(self, value: float) -> None:
|
||||
low, high = self._limits
|
||||
if low != high and not low <= value <= high:
|
||||
raise LimitError(f"{self.name}: position {value} outside limits {self._limits}")
|
||||
|
||||
def _update_readback(self, value: float) -> None:
|
||||
old = self.readback.get()
|
||||
self.readback.put(value)
|
||||
self._set_position(value)
|
||||
self._run_subs(
|
||||
sub_type=self.SUB_READBACK, old_value=old, value=value, timestamp=time.time()
|
||||
)
|
||||
|
||||
def _poll_until_target(self, target: float, status: DeviceStatus) -> None:
|
||||
deadline = time.monotonic() + self._move_timeout
|
||||
try:
|
||||
while not self._stopped:
|
||||
pos = self._call(lambda: self._transport.get_position(self._axis))
|
||||
self._update_readback(pos)
|
||||
if abs(pos - target) <= self._tolerance:
|
||||
break
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError(
|
||||
f"{self.name}: move to {target} timed out after {self._move_timeout}s"
|
||||
)
|
||||
time.sleep(self._poll_interval)
|
||||
# Final readback refresh.
|
||||
pos = self._call(lambda: self._transport.get_position(self._axis))
|
||||
self._update_readback(pos)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.motor_is_moving.put(0)
|
||||
if not status.done:
|
||||
status.set_exception(exc)
|
||||
return
|
||||
self.motor_is_moving.put(0)
|
||||
if self._stopped:
|
||||
if not status.done:
|
||||
status.set_exception(RuntimeError(f"{self.name}: move interrupted by stop()"))
|
||||
return
|
||||
if not status.done:
|
||||
status.set_finished()
|
||||
|
||||
def move(self, position: float, wait: bool = True, timeout=None, moved_cb=None) -> DeviceStatus:
|
||||
"""Move the axis to *position*; return a status that completes at target."""
|
||||
self.check_value(position)
|
||||
if self.pre_move_hook is not None:
|
||||
self.pre_move_hook(position)
|
||||
self._stopped = False
|
||||
self.setpoint.put(position)
|
||||
|
||||
status = DeviceStatus(self, timeout=timeout or self._move_timeout)
|
||||
if moved_cb is not None:
|
||||
status.add_callback(moved_cb)
|
||||
self.motor_is_moving.put(1)
|
||||
|
||||
try:
|
||||
self._call(
|
||||
lambda: self._transport.move_absolute(self._axis, position, speed=self._speed)
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.motor_is_moving.put(0)
|
||||
status.set_exception(exc)
|
||||
return status
|
||||
|
||||
self._move_thread = threading.Thread(
|
||||
target=self._poll_until_target, args=(position, status), daemon=True
|
||||
)
|
||||
self._move_thread.start()
|
||||
|
||||
if wait:
|
||||
status.wait()
|
||||
return status
|
||||
|
||||
def set(self, position: float, **kwargs) -> DeviceStatus:
|
||||
"""Alias for :meth:`move` with ``wait=False`` (BEC positioner interface)."""
|
||||
return self.move(position, wait=False, **kwargs)
|
||||
|
||||
def jog(self, speed: float) -> None:
|
||||
"""Start a continuous move at *speed* (sign = direction); 0 stops."""
|
||||
self._call(lambda: self._transport.jog(self._axis, speed))
|
||||
|
||||
def stop(self, *, success: bool = False) -> None:
|
||||
"""Halt this axis immediately (best-effort: never raises during teardown)."""
|
||||
self._stopped = True
|
||||
try:
|
||||
self._call(lambda: self._transport.stop(self._axis))
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.log.warning("%s: transport stop failed: %s", self.name, exc)
|
||||
finally:
|
||||
if self._move_thread is not None:
|
||||
self._move_thread.join(timeout=2.0)
|
||||
self._move_thread = None
|
||||
self.motor_is_moving.put(0)
|
||||
super().stop(success=success)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Control-plane transport for the Canon CR-N300 PTZ camera.
|
||||
|
||||
Pure standard-library; imports neither ophyd nor the acquisition subsystem, so that
|
||||
standalone control works with the imaging stack absent and the transport is unit
|
||||
testable without hardware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import HARDWARE_LIMITS, Axis, AxisLimits, CameraInfo, CameraTransport, FocusMode
|
||||
from .errors import (
|
||||
CanonCommandError,
|
||||
CanonConnectionError,
|
||||
CanonControlPrivilegeError,
|
||||
CanonParameterError,
|
||||
CanonSessionError,
|
||||
CanonTimeoutError,
|
||||
CanonTransportError,
|
||||
)
|
||||
from .fake import FakeTransport
|
||||
from .xc import XCTransport
|
||||
|
||||
__all__ = [
|
||||
"Axis",
|
||||
"AxisLimits",
|
||||
"CameraInfo",
|
||||
"CameraTransport",
|
||||
"FocusMode",
|
||||
"HARDWARE_LIMITS",
|
||||
"FakeTransport",
|
||||
"XCTransport",
|
||||
"CanonTransportError",
|
||||
"CanonConnectionError",
|
||||
"CanonTimeoutError",
|
||||
"CanonSessionError",
|
||||
"CanonControlPrivilegeError",
|
||||
"CanonCommandError",
|
||||
"CanonParameterError",
|
||||
]
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Transport abstraction for the Canon CR-N300 PTZ camera.
|
||||
|
||||
This module is intentionally free of any ophyd / BEC imports so that it can be unit
|
||||
tested without hardware and so that *control* never depends on the *acquisition*
|
||||
(imaging) subsystem. The ophyd device talks to the camera exclusively through the
|
||||
:class:`CameraTransport` interface; concrete implementations are:
|
||||
|
||||
* :class:`~csaxs_bec.devices.canon.transport.xc.XCTransport` -- the real
|
||||
Canon XC Control Protocol client (session-based HTTP/CGI), and
|
||||
* :class:`~csaxs_bec.devices.canon.transport.fake.FakeTransport` -- an in-memory
|
||||
simulator used for offline mode and the test-suite.
|
||||
|
||||
Units (user-facing, as exposed by the positioners):
|
||||
|
||||
* ``pan`` -- degrees, hardware range +/-170 deg
|
||||
* ``tilt`` -- degrees, hardware range -30 .. +100 deg
|
||||
* ``zoom`` -- optical magnification, 1.0 .. 20.0 x
|
||||
* ``focus`` -- normalised manual-focus position, 0.0 (near) .. 1.0 (far)
|
||||
|
||||
The mapping between these user units and the camera's native command encoding lives
|
||||
inside the concrete transport (see ``xc.py``), so the ophyd layer stays unit-correct
|
||||
and protocol-agnostic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class Axis(str, enum.Enum):
|
||||
"""The four motion axes of the camera."""
|
||||
|
||||
PAN = "pan"
|
||||
TILT = "tilt"
|
||||
ZOOM = "zoom"
|
||||
FOCUS = "focus"
|
||||
|
||||
|
||||
class FocusMode(str, enum.Enum):
|
||||
"""Focus operating modes supported by the CR-N300.
|
||||
|
||||
``MANUAL`` exposes the manual-focus position as a positioner; the autofocus
|
||||
modes drive focus internally and the manual-focus positioner is inactive.
|
||||
"""
|
||||
|
||||
MANUAL = "manual"
|
||||
CONTINUOUS_AF = "continuous_af"
|
||||
FACE_AF = "face_af"
|
||||
TRACKING_AF = "tracking_af"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AxisLimits:
|
||||
"""Inclusive low/high travel limits and speed range for an axis.
|
||||
|
||||
Speeds are in axis-units per second (deg/s for pan/tilt). ``None`` speed bounds
|
||||
mean the axis does not support a settable move speed.
|
||||
"""
|
||||
|
||||
low: float
|
||||
high: float
|
||||
speed_min: float | None = None
|
||||
speed_max: float | None = None
|
||||
|
||||
|
||||
# Hardware limits taken from the CR-N300 specification. These are the *physical*
|
||||
# limits; per-deployment soft limits can be narrower and are applied in the ophyd
|
||||
# layer (the positioner's ``limits``).
|
||||
HARDWARE_LIMITS: dict[Axis, AxisLimits] = {
|
||||
Axis.PAN: AxisLimits(low=-170.0, high=170.0, speed_min=0.2, speed_max=300.0),
|
||||
Axis.TILT: AxisLimits(low=-30.0, high=100.0, speed_min=0.2, speed_max=170.0),
|
||||
Axis.ZOOM: AxisLimits(low=1.0, high=20.0),
|
||||
Axis.FOCUS: AxisLimits(low=0.0, high=1.0),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraInfo:
|
||||
"""Static identity information read once after connecting."""
|
||||
|
||||
model: str = ""
|
||||
firmware: str = ""
|
||||
serial: str = ""
|
||||
|
||||
|
||||
class CameraTransport(abc.ABC):
|
||||
"""Control-plane interface to a Canon PTZ camera.
|
||||
|
||||
A transport owns the session lifecycle (connect/claim/yield/close), exposes
|
||||
camera parameters, drives the four axes (absolute moves plus optional
|
||||
continuous jog), and recalls presets. It does **not** handle the image stream:
|
||||
persistent frame acquisition lives in
|
||||
:mod:`csaxs_bec.devices.canon.acquisition`, which never imports this module.
|
||||
|
||||
All methods raise subclasses of
|
||||
:class:`~csaxs_bec.devices.canon.transport.errors.CanonTransportError` on
|
||||
failure. Implementations must be safe to call from a single controlling thread;
|
||||
the ophyd device serialises access.
|
||||
"""
|
||||
|
||||
# -- session lifecycle -------------------------------------------------
|
||||
|
||||
@abc.abstractmethod
|
||||
def connect(self, *, timeout: float | None = None) -> None:
|
||||
"""Open a session with the camera (Canon ``open.cgi``).
|
||||
|
||||
Idempotent: calling :meth:`connect` on an already-connected transport is a
|
||||
no-op. Does **not** claim control privilege; call :meth:`claim_control`
|
||||
for that.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def close(self) -> None:
|
||||
"""Yield any held control privilege and close the session.
|
||||
|
||||
Idempotent and must never raise; cleanup errors are swallowed/logged so
|
||||
that ophyd ``unstage``/``destroy`` can always complete.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def connected(self) -> bool:
|
||||
"""Whether a session is currently open."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def claim_control(self, *, timeout: float | None = None) -> None:
|
||||
"""Acquire the exclusive control privilege (Canon ``claim.cgi``)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def yield_control(self) -> None:
|
||||
"""Release the control privilege (Canon ``yield.cgi``). Idempotent."""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def has_control(self) -> bool:
|
||||
"""Whether this transport currently holds the control privilege."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_camera_info(self) -> CameraInfo:
|
||||
"""Return static identity information (model/firmware/serial)."""
|
||||
|
||||
# -- parameters --------------------------------------------------------
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_parameter(self, name: str) -> object:
|
||||
"""Read a single camera parameter (e.g. ``"gain"``, ``"white_balance"``)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_parameter(self, name: str, value: object) -> None:
|
||||
"""Write a single camera parameter. Requires control privilege."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def list_parameters(self) -> dict[str, object]:
|
||||
"""Return a snapshot of all known parameters as ``{name: value}``."""
|
||||
|
||||
# -- motion ------------------------------------------------------------
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_position(self, axis: Axis) -> float:
|
||||
"""Return the current position of *axis* in its user unit."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def move_absolute(self, axis: Axis, value: float, *, speed: float | None = None) -> None:
|
||||
"""Command an absolute move of *axis* to *value* (non-blocking).
|
||||
|
||||
Returns as soon as the command is accepted; completion is observed by the
|
||||
caller polling :meth:`get_position` (and optionally :meth:`is_moving`).
|
||||
Requires control privilege.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def jog(self, axis: Axis, speed: float) -> None:
|
||||
"""Start a continuous move of *axis* at *speed* (sign sets direction).
|
||||
|
||||
``speed == 0`` stops the axis. Requires control privilege. Implementations
|
||||
that do not support continuous jog raise
|
||||
:class:`~csaxs_bec.devices.canon.transport.errors.CanonCommandError`.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_moving(self, axis: Axis) -> bool:
|
||||
"""Whether *axis* is currently in motion (best-effort)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def stop(self, axis: Axis | None = None) -> None:
|
||||
"""Halt *axis*, or all axes when *axis* is ``None``. Requires control."""
|
||||
|
||||
# -- focus mode --------------------------------------------------------
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_focus_mode(self) -> FocusMode:
|
||||
"""Return the current focus mode."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def set_focus_mode(self, mode: FocusMode) -> None:
|
||||
"""Set the focus mode. Requires control privilege."""
|
||||
|
||||
# -- presets -----------------------------------------------------------
|
||||
|
||||
@abc.abstractmethod
|
||||
def recall_preset(self, preset_id: int) -> None:
|
||||
"""Recall a stored PTZ preset by id. Requires control privilege."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_preset_state(self) -> int:
|
||||
"""Return the id of the last recalled preset (``-1`` if none/unknown)."""
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Typed exceptions for the Canon CR-N300 transport layer.
|
||||
|
||||
These are deliberately defined in a tiny, dependency-free module so that both the
|
||||
transport implementations and the ophyd device can import them without pulling in
|
||||
ophyd or any networking stack.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class CanonTransportError(Exception):
|
||||
"""Base class for all Canon transport errors."""
|
||||
|
||||
|
||||
class CanonConnectionError(CanonTransportError):
|
||||
"""Raised when the transport cannot reach the camera (socket/HTTP failure)."""
|
||||
|
||||
|
||||
class CanonTimeoutError(CanonTransportError):
|
||||
"""Raised when a request to the camera exceeds its timeout."""
|
||||
|
||||
|
||||
class CanonSessionError(CanonTransportError):
|
||||
"""Raised on session-lifecycle problems (open/claim/yield/close)."""
|
||||
|
||||
|
||||
class CanonControlPrivilegeError(CanonSessionError):
|
||||
"""Raised when a control command is attempted without an active control claim.
|
||||
|
||||
Canon's XC protocol grants exactly one client the "control privilege" at a time
|
||||
via ``claim.cgi``. Camera-control CGIs fail until the privilege is held.
|
||||
"""
|
||||
|
||||
|
||||
class CanonCommandError(CanonTransportError):
|
||||
"""Raised when the camera rejects a command or returns an error response."""
|
||||
|
||||
|
||||
class CanonParameterError(CanonCommandError):
|
||||
"""Raised when a parameter name/value is invalid or read-back verification fails."""
|
||||
@@ -0,0 +1,248 @@
|
||||
"""In-memory fake transport for the Canon CR-N300.
|
||||
|
||||
:class:`FakeTransport` implements the full :class:`CameraTransport` interface with
|
||||
no network access. It backs two things:
|
||||
|
||||
* **offline / simulation mode** -- set ``simulation: true`` in the device config and
|
||||
the ophyd device loads without a physical camera, and
|
||||
* **the unit-test suite** -- tests inject it (or assert against it) instead of
|
||||
touching hardware.
|
||||
|
||||
Motion is *instant* by default (a move sets the position immediately), which keeps
|
||||
positioner tests fast and deterministic. Pass ``gradual_motion=True`` to simulate
|
||||
finite-speed travel for exercising the polling / status path; positions then advance
|
||||
toward the target according to the commanded (or default) speed and the injected
|
||||
clock ``time_func``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from .base import HARDWARE_LIMITS, Axis, CameraInfo, CameraTransport, FocusMode
|
||||
from .errors import CanonControlPrivilegeError, CanonParameterError, CanonSessionError
|
||||
|
||||
# Default parameter set surfaced by the fake. Mirrors the read/settable parameters
|
||||
# the ophyd device exposes, with plausible defaults.
|
||||
_DEFAULT_PARAMETERS: dict[str, object] = {
|
||||
"exposure_mode": "auto",
|
||||
"iris": 5.6,
|
||||
"gain": 0.0,
|
||||
"white_balance": "auto",
|
||||
"shutter_speed": "1/50",
|
||||
}
|
||||
|
||||
_DEFAULT_POSITIONS: dict[Axis, float] = {
|
||||
Axis.PAN: 0.0,
|
||||
Axis.TILT: 0.0,
|
||||
Axis.ZOOM: 1.0,
|
||||
Axis.FOCUS: 0.5,
|
||||
}
|
||||
|
||||
# Default jog/move speeds (axis-units per second) used when a move does not specify
|
||||
# one and ``gradual_motion`` is enabled.
|
||||
_DEFAULT_SPEED: dict[Axis, float] = {
|
||||
Axis.PAN: 60.0,
|
||||
Axis.TILT: 60.0,
|
||||
Axis.ZOOM: 4.0,
|
||||
Axis.FOCUS: 1.0,
|
||||
}
|
||||
|
||||
|
||||
class FakeTransport(CameraTransport):
|
||||
"""A deterministic, hardware-free :class:`CameraTransport` implementation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
gradual_motion: bool = False,
|
||||
time_func: Callable[[], float] | None = None,
|
||||
info: CameraInfo | None = None,
|
||||
) -> None:
|
||||
import time as _time
|
||||
|
||||
self._time = time_func or _time.monotonic
|
||||
self._gradual = gradual_motion
|
||||
self._info = info or CameraInfo(model="CR-N300 (fake)", firmware="0.0", serial="FAKE0001")
|
||||
|
||||
self._connected = False
|
||||
self._has_control = False
|
||||
self._parameters = dict(_DEFAULT_PARAMETERS)
|
||||
self._focus_mode = FocusMode.MANUAL
|
||||
self._preset_state = -1
|
||||
|
||||
# Motion state. For gradual motion we record where/when a move started.
|
||||
self._positions = dict(_DEFAULT_POSITIONS)
|
||||
self._targets = dict(_DEFAULT_POSITIONS)
|
||||
self._move_start_pos: dict[Axis, float] = {}
|
||||
self._move_start_t: dict[Axis, float] = {}
|
||||
self._move_speed: dict[Axis, float] = {}
|
||||
self._jog_speed: dict[Axis, float] = {a: 0.0 for a in Axis}
|
||||
|
||||
# Test hooks: count calls and optionally inject failures.
|
||||
self.call_log: list[str] = []
|
||||
self.fail_next: dict[str, Exception] = {}
|
||||
|
||||
# -- internal helpers --------------------------------------------------
|
||||
|
||||
def _record(self, name: str) -> None:
|
||||
self.call_log.append(name)
|
||||
exc = self.fail_next.pop(name, None)
|
||||
if exc is not None:
|
||||
raise exc
|
||||
|
||||
def _require_session(self) -> None:
|
||||
if not self._connected:
|
||||
raise CanonSessionError("transport is not connected")
|
||||
|
||||
def _require_control(self) -> None:
|
||||
self._require_session()
|
||||
if not self._has_control:
|
||||
raise CanonControlPrivilegeError("control privilege not held")
|
||||
|
||||
def _settle(self, axis: Axis) -> None:
|
||||
"""Advance gradual-motion state for *axis* to 'now'."""
|
||||
if axis not in self._move_start_t:
|
||||
return
|
||||
elapsed = max(0.0, self._time() - self._move_start_t[axis])
|
||||
start = self._move_start_pos[axis]
|
||||
target = self._targets[axis]
|
||||
speed = self._move_speed[axis]
|
||||
if speed <= 0:
|
||||
self._positions[axis] = target
|
||||
else:
|
||||
travelled = speed * elapsed
|
||||
remaining = abs(target - start)
|
||||
if travelled >= remaining:
|
||||
self._positions[axis] = target
|
||||
else:
|
||||
direction = 1.0 if target >= start else -1.0
|
||||
self._positions[axis] = start + direction * travelled
|
||||
if self._positions[axis] == target:
|
||||
# Move finished; drop the in-flight bookkeeping.
|
||||
self._move_start_t.pop(axis, None)
|
||||
self._move_start_pos.pop(axis, None)
|
||||
self._move_speed.pop(axis, None)
|
||||
|
||||
# -- session lifecycle -------------------------------------------------
|
||||
|
||||
def connect(self, *, timeout: float | None = None) -> None:
|
||||
self._record("connect")
|
||||
self._connected = True
|
||||
|
||||
def close(self) -> None:
|
||||
self._record("close")
|
||||
self._has_control = False
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def claim_control(self, *, timeout: float | None = None) -> None:
|
||||
self._record("claim_control")
|
||||
self._require_session()
|
||||
self._has_control = True
|
||||
|
||||
def yield_control(self) -> None:
|
||||
self._record("yield_control")
|
||||
self._has_control = False
|
||||
|
||||
@property
|
||||
def has_control(self) -> bool:
|
||||
return self._has_control
|
||||
|
||||
def get_camera_info(self) -> CameraInfo:
|
||||
self._require_session()
|
||||
return self._info
|
||||
|
||||
# -- parameters --------------------------------------------------------
|
||||
|
||||
def get_parameter(self, name: str) -> object:
|
||||
self._record(f"get_parameter:{name}")
|
||||
self._require_session()
|
||||
if name not in self._parameters:
|
||||
raise CanonParameterError(f"unknown parameter {name!r}")
|
||||
return self._parameters[name]
|
||||
|
||||
def set_parameter(self, name: str, value: object) -> None:
|
||||
self._record(f"set_parameter:{name}")
|
||||
self._require_control()
|
||||
if name not in self._parameters:
|
||||
raise CanonParameterError(f"unknown parameter {name!r}")
|
||||
self._parameters[name] = value
|
||||
|
||||
def list_parameters(self) -> dict[str, object]:
|
||||
self._require_session()
|
||||
return dict(self._parameters)
|
||||
|
||||
# -- motion ------------------------------------------------------------
|
||||
|
||||
def get_position(self, axis: Axis) -> float:
|
||||
self._require_session()
|
||||
if self._gradual:
|
||||
self._settle(axis)
|
||||
return self._positions[axis]
|
||||
|
||||
def move_absolute(self, axis: Axis, value: float, *, speed: float | None = None) -> None:
|
||||
self._record(f"move_absolute:{axis.value}")
|
||||
self._require_control()
|
||||
lim = HARDWARE_LIMITS[axis]
|
||||
if not lim.low <= value <= lim.high:
|
||||
raise CanonParameterError(
|
||||
f"{axis.value}={value} outside hardware range [{lim.low}, {lim.high}]"
|
||||
)
|
||||
self._targets[axis] = value
|
||||
if not self._gradual:
|
||||
self._positions[axis] = value
|
||||
return
|
||||
self._move_start_pos[axis] = self._positions[axis]
|
||||
self._move_start_t[axis] = self._time()
|
||||
self._move_speed[axis] = speed if speed is not None else _DEFAULT_SPEED[axis]
|
||||
|
||||
def jog(self, axis: Axis, speed: float) -> None:
|
||||
self._record(f"jog:{axis.value}")
|
||||
self._require_control()
|
||||
self._jog_speed[axis] = speed
|
||||
|
||||
def is_moving(self, axis: Axis) -> bool:
|
||||
self._require_session()
|
||||
if self._jog_speed[axis] != 0.0:
|
||||
return True
|
||||
if self._gradual:
|
||||
self._settle(axis)
|
||||
return self._positions[axis] != self._targets[axis]
|
||||
return False
|
||||
|
||||
def stop(self, axis: Axis | None = None) -> None:
|
||||
self._record("stop" if axis is None else f"stop:{axis.value}")
|
||||
self._require_control()
|
||||
axes = list(Axis) if axis is None else [axis]
|
||||
for ax in axes:
|
||||
if self._gradual:
|
||||
self._settle(ax)
|
||||
self._targets[ax] = self._positions[ax]
|
||||
self._jog_speed[ax] = 0.0
|
||||
self._move_start_t.pop(ax, None)
|
||||
|
||||
# -- focus mode --------------------------------------------------------
|
||||
|
||||
def get_focus_mode(self) -> FocusMode:
|
||||
self._require_session()
|
||||
return self._focus_mode
|
||||
|
||||
def set_focus_mode(self, mode: FocusMode) -> None:
|
||||
self._record(f"set_focus_mode:{mode.value}")
|
||||
self._require_control()
|
||||
self._focus_mode = FocusMode(mode)
|
||||
|
||||
# -- presets -----------------------------------------------------------
|
||||
|
||||
def recall_preset(self, preset_id: int) -> None:
|
||||
self._record(f"recall_preset:{preset_id}")
|
||||
self._require_control()
|
||||
self._preset_state = int(preset_id)
|
||||
|
||||
def get_preset_state(self) -> int:
|
||||
self._require_session()
|
||||
return self._preset_state
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Canon XC Control Protocol transport (session-based HTTP/CGI), standard library only.
|
||||
|
||||
This is the real client for the CR-N300. It implements the XC session model:
|
||||
|
||||
1. ``open.cgi`` -- open a session, receive a session id;
|
||||
2. ``claim.cgi`` -- acquire the exclusive *control privilege*;
|
||||
3. command CGIs -- camera info, camera/preset control, menu, focus metadata;
|
||||
4. ``yield.cgi`` -- release the control privilege;
|
||||
5. session close.
|
||||
|
||||
Design notes / honesty about the spec
|
||||
--------------------------------------
|
||||
The HTTP, session, authentication and retry machinery below is implemented fully
|
||||
and is unit-tested through an injectable ``opener`` (no hardware needed). However,
|
||||
the **exact CGI path prefix, parameter names and the native encoding of pan/tilt/
|
||||
zoom/focus values are defined by Canon document BPE-7216-005**, which is not bundled
|
||||
here. Every such spec-defined detail is isolated in the clearly-marked constants and
|
||||
the ``_CGI`` / ``_PARAM`` / ``_AXIS_ENCODING`` tables at the top of this module, so
|
||||
that bringing this up against a real camera is a matter of adjusting those tables --
|
||||
not rewriting logic. Each carries a ``# SPEC:`` marker.
|
||||
|
||||
The defaults follow Canon's long-standing WebView/Livescope CGI conventions
|
||||
(``-wvhttp-01-`` prefix, newline-delimited ``key=value`` response bodies) which the
|
||||
XC protocol is derived from; treat them as a starting point to verify.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .base import HARDWARE_LIMITS, Axis, CameraInfo, CameraTransport, FocusMode
|
||||
from .errors import (
|
||||
CanonCommandError,
|
||||
CanonConnectionError,
|
||||
CanonControlPrivilegeError,
|
||||
CanonParameterError,
|
||||
CanonSessionError,
|
||||
CanonTimeoutError,
|
||||
CanonTransportError,
|
||||
)
|
||||
|
||||
try: # pragma: no cover - logging is best-effort and never required for control
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
except Exception: # pragma: no cover
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# SPEC: path prefix for all CGIs. Canon WebView/Livescope uses "-wvhttp-01-".
|
||||
# Verify against BPE-7216-005 for the CR-N300 XC protocol.
|
||||
DEFAULT_CGI_PREFIX = "-wvhttp-01-"
|
||||
|
||||
# SPEC: CGI endpoint names. Adjust to match BPE-7216-005.
|
||||
_CGI = {
|
||||
"open": "open.cgi",
|
||||
"claim": "claim.cgi",
|
||||
"yield": "yield.cgi",
|
||||
"close": "close.cgi",
|
||||
"info": "info.cgi",
|
||||
"control": "control.cgi",
|
||||
"preset": "preset.cgi",
|
||||
}
|
||||
|
||||
# SPEC: query-parameter names used by the control/info CGIs. Adjust per spec.
|
||||
_PARAM = {
|
||||
"session": "s", # session-id parameter appended to authenticated requests
|
||||
"pan": "pan",
|
||||
"tilt": "tilt",
|
||||
"zoom": "zoom",
|
||||
"focus": "focus",
|
||||
"pan_speed": "panspeed",
|
||||
"tilt_speed": "tiltspeed",
|
||||
"focus_mode": "focusmode",
|
||||
"preset": "preset",
|
||||
"stop": "stop",
|
||||
}
|
||||
|
||||
# SPEC: focus-mode wire tokens.
|
||||
_FOCUS_MODE_TO_WIRE = {
|
||||
FocusMode.MANUAL: "manual",
|
||||
FocusMode.CONTINUOUS_AF: "auto",
|
||||
FocusMode.FACE_AF: "face",
|
||||
FocusMode.TRACKING_AF: "tracking",
|
||||
}
|
||||
_WIRE_TO_FOCUS_MODE = {v: k for k, v in _FOCUS_MODE_TO_WIRE.items()}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AxisEncoding:
|
||||
"""Linear map between a user unit and the camera's native command integer.
|
||||
|
||||
native = round(user * scale + offset). SPEC: scale/offset are device-native and
|
||||
must be calibrated/confirmed against BPE-7216-005.
|
||||
"""
|
||||
|
||||
scale: float
|
||||
offset: float = 0.0
|
||||
|
||||
def to_native(self, user: float) -> int:
|
||||
return int(round(user * self.scale + self.offset))
|
||||
|
||||
def to_user(self, native: float) -> float:
|
||||
return (native - self.offset) / self.scale
|
||||
|
||||
|
||||
# SPEC: per-axis encodings. Pan/tilt commonly in 1/100 degree; zoom/focus device
|
||||
# specific. Confirm against BPE-7216-005.
|
||||
_AXIS_ENCODING = {
|
||||
Axis.PAN: _AxisEncoding(scale=100.0),
|
||||
Axis.TILT: _AxisEncoding(scale=100.0),
|
||||
Axis.ZOOM: _AxisEncoding(scale=100.0),
|
||||
Axis.FOCUS: _AxisEncoding(scale=1000.0),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HttpResponse:
|
||||
"""Minimal HTTP response container returned by an :class:`XCTransport` opener."""
|
||||
|
||||
status: int
|
||||
body: bytes
|
||||
headers: dict[str, str]
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self.body.decode("ascii", errors="replace")
|
||||
|
||||
|
||||
def _default_opener(
|
||||
url: str, data: bytes | None, timeout: float, auth: tuple[str, str] | None
|
||||
) -> HttpResponse:
|
||||
"""Standard-library HTTP opener (urllib) with optional HTTP Basic/Digest auth."""
|
||||
handlers: list[urllib.request.BaseHandler] = []
|
||||
if auth is not None:
|
||||
pwd_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
|
||||
pwd_mgr.add_password(None, url, auth[0], auth[1])
|
||||
handlers.append(urllib.request.HTTPDigestAuthHandler(pwd_mgr))
|
||||
handlers.append(urllib.request.HTTPBasicAuthHandler(pwd_mgr))
|
||||
opener = urllib.request.build_opener(*handlers)
|
||||
req = urllib.request.Request(url, data=data, method="POST" if data is not None else "GET")
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
return HttpResponse(
|
||||
status=resp.status,
|
||||
body=resp.read(),
|
||||
headers={k.lower(): v for k, v in resp.headers.items()},
|
||||
)
|
||||
except urllib.error.HTTPError as exc: # 4xx/5xx still carry a body
|
||||
return HttpResponse(
|
||||
status=exc.code,
|
||||
body=exc.read() if hasattr(exc, "read") else b"",
|
||||
headers={k.lower(): v for k, v in (exc.headers or {}).items()},
|
||||
)
|
||||
except socket.timeout as exc:
|
||||
raise CanonTimeoutError(f"request to {url} timed out after {timeout}s") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise CanonConnectionError(f"cannot reach {url}: {exc.reason}") from exc
|
||||
|
||||
|
||||
class XCTransport(CameraTransport):
|
||||
"""HTTP/CGI client implementing the Canon XC Control Protocol.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
host, port:
|
||||
Camera network location. ``port`` defaults to 80 (HTTP).
|
||||
username, password:
|
||||
Credentials for the control session. Supplied by the ophyd device from
|
||||
environment variables -- never stored in the YAML config / Redis.
|
||||
cgi_prefix:
|
||||
Path prefix for the CGI endpoints (SPEC, see module docstring).
|
||||
timeout:
|
||||
Per-request timeout in seconds.
|
||||
max_retries:
|
||||
Number of transient-failure retries (with one reconnect) per request.
|
||||
opener:
|
||||
Injectable HTTP backend ``opener(url, data, timeout, auth) -> HttpResponse``.
|
||||
Defaults to a urllib-based opener. Tests pass a fake to avoid the network.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
host: str,
|
||||
port: int = 80,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
cgi_prefix: str = DEFAULT_CGI_PREFIX,
|
||||
timeout: float = 5.0,
|
||||
max_retries: int = 2,
|
||||
opener=None,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._auth = (username, password) if username is not None else None
|
||||
self.cgi_prefix = cgi_prefix.strip("/")
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self._opener = opener or _default_opener
|
||||
|
||||
self._session_id: str | None = None
|
||||
self._has_control = False
|
||||
self._preset_state = -1
|
||||
|
||||
# -- URL / request plumbing -------------------------------------------
|
||||
|
||||
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",):
|
||||
query.setdefault(_PARAM["session"], self._session_id)
|
||||
if query:
|
||||
base = f"{base}?{urllib.parse.urlencode(query)}"
|
||||
return base
|
||||
|
||||
def _request(
|
||||
self, cgi_key: str, params: dict[str, object] | None = None, *, timeout: float | None = None
|
||||
) -> HttpResponse:
|
||||
"""Issue a CGI request with retry + single reconnect on transient failures."""
|
||||
url = self._url(cgi_key, params)
|
||||
to = self.timeout if timeout is None else timeout
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
resp = self._opener(url, None, to, self._auth)
|
||||
except (CanonConnectionError, CanonTimeoutError) as exc:
|
||||
last_exc = exc
|
||||
logger.warning(f"Canon XC request {cgi_key} attempt {attempt + 1} failed: {exc}")
|
||||
# On a transient failure, drop the (possibly dead) session so the
|
||||
# next attempt re-opens it. Never reconnect inside a control claim
|
||||
# silently -- callers re-claim as needed.
|
||||
if cgi_key != "open" and self._session_id is not None:
|
||||
self._session_id = None
|
||||
try:
|
||||
self.connect(timeout=to)
|
||||
except CanonTransportError:
|
||||
pass
|
||||
url = self._url(cgi_key, params)
|
||||
continue
|
||||
if resp.status == 401:
|
||||
raise CanonSessionError("authentication failed (HTTP 401)")
|
||||
if resp.status == 403:
|
||||
raise CanonControlPrivilegeError("control privilege required (HTTP 403)")
|
||||
if resp.status >= 400:
|
||||
raise CanonCommandError(f"{cgi_key} failed: HTTP {resp.status}: {resp.text[:200]}")
|
||||
return resp
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_kv(text: str) -> dict[str, str]:
|
||||
"""Parse a newline-delimited ``key=value`` CGI response body."""
|
||||
result: dict[str, str] = {}
|
||||
for line in text.replace("\r", "\n").split("\n"):
|
||||
line = line.strip()
|
||||
if not line or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
result[key.strip()] = value.strip()
|
||||
return result
|
||||
|
||||
# -- session lifecycle -------------------------------------------------
|
||||
|
||||
def connect(self, *, timeout: float | None = None) -> None:
|
||||
if self._session_id is not None:
|
||||
return
|
||||
resp = self._request("open", timeout=timeout)
|
||||
kv = self._parse_kv(resp.text)
|
||||
# SPEC: the session-id field name. "s" / "session" / "id" are plausible.
|
||||
session_id = kv.get("s") or kv.get("session") or kv.get("id")
|
||||
if not session_id:
|
||||
# Some firmware returns the session id only in a Set-Cookie header.
|
||||
cookie = resp.headers.get("set-cookie", "")
|
||||
if "=" in cookie:
|
||||
session_id = cookie.split(";", 1)[0].split("=", 1)[1]
|
||||
if not session_id:
|
||||
raise CanonSessionError(f"open.cgi did not return a session id: {resp.text[:200]!r}")
|
||||
self._session_id = session_id
|
||||
logger.info(f"Canon XC session opened to {self.host}:{self.port}")
|
||||
|
||||
def close(self) -> None:
|
||||
# Idempotent and must never raise.
|
||||
try:
|
||||
if self._has_control:
|
||||
self.yield_control()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"Canon XC yield during close failed: {exc}")
|
||||
try:
|
||||
if self._session_id is not None:
|
||||
self._request("close")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"Canon XC close.cgi failed: {exc}")
|
||||
finally:
|
||||
self._session_id = None
|
||||
self._has_control = False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._session_id is not None
|
||||
|
||||
def claim_control(self, *, timeout: float | None = None) -> None:
|
||||
if self._session_id is None:
|
||||
raise CanonSessionError("cannot claim control before connecting")
|
||||
self._request("claim", timeout=timeout)
|
||||
self._has_control = True
|
||||
logger.info("Canon XC control privilege claimed")
|
||||
|
||||
def yield_control(self) -> None:
|
||||
if self._session_id is None or not self._has_control:
|
||||
self._has_control = False
|
||||
return
|
||||
self._request("yield")
|
||||
self._has_control = False
|
||||
logger.info("Canon XC control privilege yielded")
|
||||
|
||||
@property
|
||||
def has_control(self) -> bool:
|
||||
return self._has_control
|
||||
|
||||
def get_camera_info(self) -> CameraInfo:
|
||||
resp = self._request("info")
|
||||
kv = self._parse_kv(resp.text)
|
||||
return CameraInfo(
|
||||
model=kv.get("model", ""),
|
||||
firmware=kv.get("firmware", kv.get("version", "")),
|
||||
serial=kv.get("serial", ""),
|
||||
)
|
||||
|
||||
# -- parameters --------------------------------------------------------
|
||||
|
||||
def get_parameter(self, name: str) -> object:
|
||||
kv = self._parse_kv(self._request("info").text)
|
||||
if name not in kv:
|
||||
raise CanonParameterError(f"parameter {name!r} not present in info.cgi response")
|
||||
return kv[name]
|
||||
|
||||
def set_parameter(self, name: str, value: object) -> None:
|
||||
if not self._has_control:
|
||||
raise CanonControlPrivilegeError("control privilege required to set parameters")
|
||||
self._request("control", {name: value})
|
||||
|
||||
def list_parameters(self) -> dict[str, object]:
|
||||
return dict(self._parse_kv(self._request("info").text))
|
||||
|
||||
# -- motion ------------------------------------------------------------
|
||||
|
||||
def get_position(self, axis: Axis) -> float:
|
||||
kv = self._parse_kv(self._request("info").text)
|
||||
key = _PARAM[axis.value]
|
||||
if key not in kv:
|
||||
raise CanonParameterError(f"axis {axis.value!r} ({key}) not in info.cgi response")
|
||||
return _AXIS_ENCODING[axis].to_user(float(kv[key]))
|
||||
|
||||
def move_absolute(self, axis: Axis, value: float, *, speed: float | None = None) -> None:
|
||||
if not self._has_control:
|
||||
raise CanonControlPrivilegeError("control privilege required to move")
|
||||
lim = HARDWARE_LIMITS[axis]
|
||||
if not lim.low <= value <= lim.high:
|
||||
raise CanonParameterError(
|
||||
f"{axis.value}={value} outside hardware range [{lim.low}, {lim.high}]"
|
||||
)
|
||||
params: dict[str, object] = {_PARAM[axis.value]: _AXIS_ENCODING[axis].to_native(value)}
|
||||
if speed is not None and axis in (Axis.PAN, Axis.TILT):
|
||||
params[_PARAM[f"{axis.value}_speed"]] = int(round(speed))
|
||||
self._request("control", params)
|
||||
|
||||
def jog(self, axis: Axis, speed: float) -> None:
|
||||
if not self._has_control:
|
||||
raise CanonControlPrivilegeError("control privilege required to jog")
|
||||
if axis not in (Axis.PAN, Axis.TILT):
|
||||
raise CanonCommandError(f"continuous jog not supported for axis {axis.value}")
|
||||
# SPEC: continuous-move encoding. Modelled as a signed speed on the axis.
|
||||
self._request("control", {_PARAM[f"{axis.value}_speed"]: int(round(speed))})
|
||||
|
||||
def is_moving(self, axis: Axis) -> bool:
|
||||
kv = self._parse_kv(self._request("info").text)
|
||||
# SPEC: a "<axis>moving" flag is assumed; absence means we report False.
|
||||
flag = kv.get(f"{axis.value}moving")
|
||||
return flag not in (None, "0", "false", "")
|
||||
|
||||
def stop(self, axis: Axis | None = None) -> None:
|
||||
if not self._has_control:
|
||||
raise CanonControlPrivilegeError("control privilege required to stop")
|
||||
target = "all" if axis is None else axis.value
|
||||
self._request("control", {_PARAM["stop"]: target})
|
||||
|
||||
# -- focus mode --------------------------------------------------------
|
||||
|
||||
def get_focus_mode(self) -> FocusMode:
|
||||
kv = self._parse_kv(self._request("info").text)
|
||||
wire = kv.get(_PARAM["focus_mode"], "manual")
|
||||
return _WIRE_TO_FOCUS_MODE.get(wire, FocusMode.MANUAL)
|
||||
|
||||
def set_focus_mode(self, mode: FocusMode) -> None:
|
||||
if not self._has_control:
|
||||
raise CanonControlPrivilegeError("control privilege required to set focus mode")
|
||||
self._request("control", {_PARAM["focus_mode"]: _FOCUS_MODE_TO_WIRE[FocusMode(mode)]})
|
||||
|
||||
# -- presets -----------------------------------------------------------
|
||||
|
||||
def recall_preset(self, preset_id: int) -> None:
|
||||
if not self._has_control:
|
||||
raise CanonControlPrivilegeError("control privilege required to recall presets")
|
||||
self._request("preset", {_PARAM["preset"]: int(preset_id)})
|
||||
self._preset_state = int(preset_id)
|
||||
|
||||
def get_preset_state(self) -> int:
|
||||
return self._preset_state
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tests for the CanonPTZCamera ophyd device (simulation mode, no hardware)."""
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from ophyd.utils import LimitError
|
||||
|
||||
from csaxs_bec.devices.canon import CanonPTZCamera
|
||||
from csaxs_bec.devices.canon.transport.base import FocusMode
|
||||
from csaxs_bec.devices.canon.transport.errors import CanonParameterError
|
||||
from csaxs_bec.devices.canon.transport.fake import FakeTransport
|
||||
from csaxs_bec.devices.canon.transport.xc import XCTransport
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cam():
|
||||
device = CanonPTZCamera(name="canon_test", simulation=True)
|
||||
device.wait_for_connection()
|
||||
yield device
|
||||
device.destroy()
|
||||
|
||||
|
||||
def test_connect_claims_control(cam):
|
||||
assert cam.transport.connected
|
||||
assert cam.transport.has_control
|
||||
assert isinstance(cam.transport, FakeTransport)
|
||||
|
||||
|
||||
def test_axes_are_positioners_with_spec_limits(cam):
|
||||
assert cam.pan.limits == (-170.0, 170.0)
|
||||
assert cam.tilt.limits == (-30.0, 100.0)
|
||||
assert cam.zoom.limits == (1.0, 20.0)
|
||||
for ax in (cam.pan, cam.tilt, cam.zoom, cam.focus):
|
||||
assert hasattr(ax, "move") and hasattr(ax, "stop")
|
||||
|
||||
|
||||
def test_move_returns_status_and_reaches_target(cam):
|
||||
status = cam.pan.move(30.0, wait=True)
|
||||
assert status.done and status.success
|
||||
assert cam.pan.position == pytest.approx(30.0)
|
||||
|
||||
|
||||
def test_set_alias_is_nonblocking_and_completes(cam):
|
||||
status = cam.tilt.set(15.0)
|
||||
status.wait(timeout=5)
|
||||
assert status.success
|
||||
assert cam.tilt.position == pytest.approx(15.0)
|
||||
|
||||
|
||||
def test_soft_limits_narrow_hardware_limits():
|
||||
device = CanonPTZCamera(name="lim", simulation=True)
|
||||
device.wait_for_connection()
|
||||
try:
|
||||
device.pan._limits = (-50.0, 50.0)
|
||||
with pytest.raises(LimitError):
|
||||
device.pan.move(120.0)
|
||||
finally:
|
||||
device.destroy()
|
||||
|
||||
|
||||
def test_parameter_set_with_readback_verification(cam):
|
||||
cam.gain.set(6.0).wait()
|
||||
assert cam.gain.get() == 6.0
|
||||
assert cam.iris.get() == cam.transport.get_parameter("iris")
|
||||
|
||||
|
||||
def test_parameter_readback_mismatch_raises():
|
||||
device = CanonPTZCamera(name="mism", simulation=True)
|
||||
device.wait_for_connection()
|
||||
try:
|
||||
# Force the transport to swallow the write so read-back disagrees.
|
||||
with mock.patch.object(device.transport, "set_parameter"):
|
||||
status = device.gain.set(9.0)
|
||||
with pytest.raises(CanonParameterError):
|
||||
status.wait(timeout=2)
|
||||
assert status.done and not status.success
|
||||
finally:
|
||||
device.destroy()
|
||||
|
||||
|
||||
def test_focus_axis_gated_by_focus_mode(cam):
|
||||
cam.focus_mode.set(FocusMode.CONTINUOUS_AF.value).wait()
|
||||
with pytest.raises(RuntimeError):
|
||||
cam.focus.move(0.3)
|
||||
cam.focus_mode.set(FocusMode.MANUAL.value).wait()
|
||||
cam.focus.move(0.3, wait=True)
|
||||
assert cam.focus.position == pytest.approx(0.3)
|
||||
|
||||
|
||||
def test_read_and_describe_cover_axes_and_params(cam):
|
||||
reading = cam.read()
|
||||
assert "canon_test_pan_readback" in reading
|
||||
desc = cam.describe()
|
||||
assert desc # non-empty
|
||||
cfg = cam.read_configuration()
|
||||
assert "canon_test_gain" in cfg
|
||||
|
||||
|
||||
def test_stop_all_halts_axes(cam):
|
||||
with mock.patch.object(cam.pan, "stop") as p, mock.patch.object(cam.tilt, "stop") as t:
|
||||
cam.stop_all()
|
||||
p.assert_called_once()
|
||||
t.assert_called_once()
|
||||
|
||||
|
||||
def test_recall_preset(cam):
|
||||
cam.recall_preset(5)
|
||||
assert cam.preset_state.get() == 5
|
||||
|
||||
|
||||
def test_grab_api_requires_buffer(cam):
|
||||
with pytest.raises(RuntimeError):
|
||||
cam.get_latest()
|
||||
|
||||
|
||||
def test_streaming_in_simulation_fills_buffer():
|
||||
device = CanonPTZCamera(name="stream_sim", simulation=True, buffer_maxlen=8)
|
||||
device.wait_for_connection()
|
||||
try:
|
||||
device.start_stream()
|
||||
assert device._worker.wait_for_first_frame(timeout=2.0)
|
||||
frame = device.get_latest()
|
||||
assert frame is not None and frame.data.startswith(b"\xff\xd8")
|
||||
stats = device.stream_stats()
|
||||
assert stats["buffer"]["size"] >= 1
|
||||
assert stats["worker"]["establishment_time_s"] is not None
|
||||
finally:
|
||||
device.stop_stream()
|
||||
device.destroy()
|
||||
|
||||
|
||||
def test_non_simulation_requires_host():
|
||||
with pytest.raises(ValueError):
|
||||
CanonPTZCamera(name="nohost", simulation=False)
|
||||
|
||||
|
||||
def test_invalid_stream_start_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
CanonPTZCamera(name="bad", simulation=True, stream_start="whenever")
|
||||
|
||||
|
||||
def test_real_transport_reads_credentials_from_env():
|
||||
env = {"CANON_REALCAM_USER": "operator", "CANON_REALCAM_PASSWORD": "secret"}
|
||||
with mock.patch.dict(os.environ, env, clear=False):
|
||||
device = CanonPTZCamera(name="realcam", simulation=False, host="192.168.0.50")
|
||||
assert isinstance(device.transport, XCTransport)
|
||||
assert device.transport._auth == ("operator", "secret")
|
||||
|
||||
|
||||
def test_destroy_closes_transport(cam):
|
||||
cam.destroy()
|
||||
assert not cam.transport.connected
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for the Canon acquisition ring buffer (pure stdlib, no hardware)."""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from csaxs_bec.devices.canon.acquisition.ring_buffer import Frame, FrameRingBuffer
|
||||
|
||||
|
||||
def test_rejects_bad_maxlen():
|
||||
with pytest.raises(ValueError):
|
||||
FrameRingBuffer(0)
|
||||
|
||||
|
||||
def test_put_stamps_sequence_and_timestamps():
|
||||
clock = iter([1.0, 2.0, 3.0])
|
||||
wall = iter([100.0, 200.0, 300.0])
|
||||
buf = FrameRingBuffer(
|
||||
8, source_id="cam", time_func=lambda: next(clock), wall_time_func=lambda: next(wall)
|
||||
)
|
||||
f0 = buf.put(b"a")
|
||||
f1 = buf.put(b"bb")
|
||||
assert isinstance(f0, Frame)
|
||||
assert (f0.seq, f0.monotonic_ts, f0.wall_ts, f0.source_id) == (0, 1.0, 100.0, "cam")
|
||||
assert (f1.seq, f1.nbytes) == (1, 2)
|
||||
|
||||
|
||||
def test_get_latest_empty_and_filled():
|
||||
buf = FrameRingBuffer(4)
|
||||
assert buf.get_latest() is None
|
||||
buf.put(b"x")
|
||||
buf.put(b"y")
|
||||
assert buf.get_latest().data == b"y"
|
||||
|
||||
|
||||
def test_overwrites_oldest_and_counts_drops():
|
||||
buf = FrameRingBuffer(3)
|
||||
for i in range(5):
|
||||
buf.put(bytes([i]))
|
||||
snap = buf.snapshot()
|
||||
assert [f.data for f in snap] == [b"\x02", b"\x03", b"\x04"] # oldest two evicted
|
||||
stats = buf.stats()
|
||||
assert stats["size"] == 3
|
||||
assert stats["total_received"] == 5
|
||||
assert stats["dropped_unread"] == 2
|
||||
|
||||
|
||||
def test_get_latest_n():
|
||||
buf = FrameRingBuffer(5)
|
||||
for i in range(4):
|
||||
buf.put(bytes([i]))
|
||||
assert [f.data for f in buf.get_latest_n(2)] == [b"\x02", b"\x03"]
|
||||
assert len(buf.get_latest_n(10)) == 4 # capped at available
|
||||
assert buf.get_latest_n(0) == []
|
||||
|
||||
|
||||
def test_get_nearest_wall_and_monotonic():
|
||||
mono = iter([1.0, 2.0, 3.0])
|
||||
wall = iter([10.0, 20.0, 30.0])
|
||||
buf = FrameRingBuffer(8, time_func=lambda: next(mono), wall_time_func=lambda: next(wall))
|
||||
buf.put(b"a")
|
||||
buf.put(b"b")
|
||||
buf.put(b"c")
|
||||
assert buf.get_nearest(19.0).data == b"b" # wall clock default
|
||||
assert buf.get_nearest(2.9, clock="monotonic").data == b"c"
|
||||
empty = FrameRingBuffer(2)
|
||||
assert empty.get_nearest(0.0) is None
|
||||
|
||||
|
||||
def test_thread_safety_under_concurrent_writers():
|
||||
buf = FrameRingBuffer(1000)
|
||||
|
||||
def worker(tag):
|
||||
for i in range(200):
|
||||
buf.put(f"{tag}-{i}".encode())
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(t,)) for t in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
assert buf.stats()["total_received"] == 1000
|
||||
assert len(buf) == 1000
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for the persistent stream worker (fail-soft, instrumented)."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from csaxs_bec.devices.canon.acquisition.ring_buffer import FrameRingBuffer
|
||||
from csaxs_bec.devices.canon.acquisition.stream_worker import (
|
||||
FrameSource,
|
||||
IterableFrameSource,
|
||||
MJPEGHttpSource,
|
||||
StreamWorker,
|
||||
)
|
||||
|
||||
|
||||
def _wait(predicate, timeout=2.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
def test_worker_fills_buffer_and_measures_establishment():
|
||||
buf = FrameRingBuffer(16)
|
||||
frames = [b"\xff\xd8a\xff\xd9", b"\xff\xd8b\xff\xd9", b"\xff\xd8c\xff\xd9"]
|
||||
worker = StreamWorker(IterableFrameSource(frames, delay=0.01), buf, name="t")
|
||||
worker.start()
|
||||
assert worker.wait_for_first_frame(timeout=2.0)
|
||||
assert _wait(lambda: len(buf) == 3)
|
||||
worker.stop()
|
||||
assert worker.establishment_time is not None
|
||||
assert worker.stats()["frames_received"] == 3
|
||||
assert not worker.running
|
||||
|
||||
|
||||
def test_worker_reconnects_fail_soft_and_keeps_serving():
|
||||
buf = FrameRingBuffer(16)
|
||||
|
||||
class FlakySource(FrameSource):
|
||||
def __init__(self):
|
||||
self.opens = 0
|
||||
self._closed = False
|
||||
|
||||
def open(self, *, timeout=None):
|
||||
self.opens += 1
|
||||
self._closed = False
|
||||
|
||||
def frames(self):
|
||||
if self.opens == 1:
|
||||
yield b"good1"
|
||||
raise ConnectionError("drop") # first connection dies
|
||||
# second connection delivers steadily
|
||||
while not self._closed:
|
||||
yield b"good2"
|
||||
time.sleep(0.01)
|
||||
|
||||
def close(self):
|
||||
self._closed = True
|
||||
|
||||
src = FlakySource()
|
||||
worker = StreamWorker(src, buf, name="flaky", reconnect_delay=0.05)
|
||||
worker.start()
|
||||
# The first good frame is served even though the connection later drops.
|
||||
assert _wait(lambda: buf.get_latest() is not None)
|
||||
# It reconnects (fail-soft) and keeps delivering.
|
||||
assert _wait(lambda: src.opens >= 2 and buf.get_latest().data == b"good2")
|
||||
worker.stop()
|
||||
assert worker.stats()["reconnects"] >= 1
|
||||
|
||||
|
||||
def test_worker_start_is_idempotent():
|
||||
buf = FrameRingBuffer(8)
|
||||
worker = StreamWorker(IterableFrameSource(lambda: b"x", delay=0.01), buf, name="idem")
|
||||
worker.start()
|
||||
t1 = worker._thread
|
||||
worker.start() # no-op while running
|
||||
assert worker._thread is t1
|
||||
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_worker_thread_stops_cleanly_no_leak():
|
||||
buf = FrameRingBuffer(8)
|
||||
before = threading.active_count()
|
||||
worker = StreamWorker(IterableFrameSource(lambda: b"y", delay=0.01), buf, name="leak")
|
||||
worker.start()
|
||||
assert _wait(lambda: len(buf) > 0)
|
||||
worker.stop()
|
||||
assert _wait(lambda: threading.active_count() == before)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tests for the Canon transport layer: FakeTransport behaviour and the XCTransport
|
||||
HTTP/session logic driven through a mocked opener (no hardware, no network)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from csaxs_bec.devices.canon.transport.base import Axis, FocusMode
|
||||
from csaxs_bec.devices.canon.transport.errors import (
|
||||
CanonCommandError,
|
||||
CanonConnectionError,
|
||||
CanonControlPrivilegeError,
|
||||
CanonParameterError,
|
||||
CanonSessionError,
|
||||
)
|
||||
from csaxs_bec.devices.canon.transport.fake import FakeTransport
|
||||
from csaxs_bec.devices.canon.transport.xc import HttpResponse, XCTransport
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# FakeTransport #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake():
|
||||
t = FakeTransport()
|
||||
t.connect()
|
||||
t.claim_control()
|
||||
return t
|
||||
|
||||
|
||||
def test_fake_session_lifecycle():
|
||||
t = FakeTransport()
|
||||
assert not t.connected
|
||||
t.connect()
|
||||
assert t.connected and not t.has_control
|
||||
t.claim_control()
|
||||
assert t.has_control
|
||||
t.yield_control()
|
||||
assert not t.has_control
|
||||
t.close()
|
||||
assert not t.connected
|
||||
|
||||
|
||||
def test_fake_requires_control_to_move():
|
||||
t = FakeTransport()
|
||||
t.connect()
|
||||
with pytest.raises(CanonControlPrivilegeError):
|
||||
t.move_absolute(Axis.PAN, 10.0)
|
||||
|
||||
|
||||
def test_fake_requires_session():
|
||||
t = FakeTransport()
|
||||
with pytest.raises(CanonSessionError):
|
||||
t.get_position(Axis.PAN)
|
||||
|
||||
|
||||
def test_fake_absolute_move_and_limits(fake):
|
||||
fake.move_absolute(Axis.PAN, 90.0)
|
||||
assert fake.get_position(Axis.PAN) == 90.0
|
||||
with pytest.raises(CanonParameterError):
|
||||
fake.move_absolute(Axis.PAN, 200.0) # beyond +/-170
|
||||
with pytest.raises(CanonParameterError):
|
||||
fake.move_absolute(Axis.TILT, -45.0) # below -30
|
||||
|
||||
|
||||
def test_fake_parameters_roundtrip(fake):
|
||||
assert fake.get_parameter("gain") == 0.0
|
||||
fake.set_parameter("gain", 6.0)
|
||||
assert fake.get_parameter("gain") == 6.0
|
||||
with pytest.raises(CanonParameterError):
|
||||
fake.get_parameter("does_not_exist")
|
||||
|
||||
|
||||
def test_fake_focus_mode_and_presets(fake):
|
||||
assert fake.get_focus_mode() == FocusMode.MANUAL
|
||||
fake.set_focus_mode(FocusMode.FACE_AF)
|
||||
assert fake.get_focus_mode() == FocusMode.FACE_AF
|
||||
assert fake.get_preset_state() == -1
|
||||
fake.recall_preset(3)
|
||||
assert fake.get_preset_state() == 3
|
||||
|
||||
|
||||
def test_fake_gradual_motion_with_injected_clock():
|
||||
now = [0.0]
|
||||
t = FakeTransport(gradual_motion=True, time_func=lambda: now[0])
|
||||
t.connect()
|
||||
t.claim_control()
|
||||
t.move_absolute(Axis.PAN, 60.0, speed=60.0) # 60 deg/s -> 1 s total
|
||||
now[0] = 0.5
|
||||
assert t.is_moving(Axis.PAN)
|
||||
assert t.get_position(Axis.PAN) == pytest.approx(30.0)
|
||||
now[0] = 1.0
|
||||
assert t.get_position(Axis.PAN) == pytest.approx(60.0)
|
||||
assert not t.is_moving(Axis.PAN)
|
||||
|
||||
|
||||
def test_fake_stop_halts_axis():
|
||||
now = [0.0]
|
||||
t = FakeTransport(gradual_motion=True, time_func=lambda: now[0])
|
||||
t.connect()
|
||||
t.claim_control()
|
||||
t.move_absolute(Axis.PAN, 100.0, speed=100.0)
|
||||
now[0] = 0.3
|
||||
t.stop(Axis.PAN)
|
||||
pos = t.get_position(Axis.PAN)
|
||||
now[0] = 5.0
|
||||
assert t.get_position(Axis.PAN) == pytest.approx(pos) # frozen after stop
|
||||
|
||||
|
||||
def test_fake_fault_injection(fake):
|
||||
fake.fail_next["move_absolute:pan"] = CanonCommandError("boom")
|
||||
with pytest.raises(CanonCommandError):
|
||||
fake.move_absolute(Axis.PAN, 10.0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# XCTransport with a recording mock opener #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class RecordingOpener:
|
||||
"""Mock HTTP opener; records URLs and returns canned bodies keyed by CGI name."""
|
||||
|
||||
def __init__(self, bodies=None, status=200):
|
||||
self.requests = []
|
||||
self.bodies = bodies or {}
|
||||
self.status = status
|
||||
self.fail_times = 0 # raise CanonConnectionError this many times first
|
||||
|
||||
def __call__(self, url, data, timeout, auth):
|
||||
self.requests.append(url)
|
||||
if self.fail_times > 0:
|
||||
self.fail_times -= 1
|
||||
raise CanonConnectionError("transient")
|
||||
# pick body by CGI filename present in URL
|
||||
for key, body in self.bodies.items():
|
||||
if f"/{key}" in url:
|
||||
return HttpResponse(status=self.status, body=body.encode(), headers={})
|
||||
return HttpResponse(status=self.status, body=b"", headers={})
|
||||
|
||||
|
||||
def make_xc(opener):
|
||||
return XCTransport(host="cam.test", port=80, username="u", password="p", opener=opener)
|
||||
|
||||
|
||||
def test_xc_open_parses_session_and_builds_urls():
|
||||
opener = RecordingOpener(bodies={"open.cgi": "s=ABC123\n"})
|
||||
t = make_xc(opener)
|
||||
t.connect()
|
||||
assert t.connected
|
||||
assert "open.cgi" in opener.requests[0]
|
||||
# subsequent requests carry the session id
|
||||
t.claim_control()
|
||||
assert any("claim.cgi" in u and "s=ABC123" in u for u in opener.requests)
|
||||
assert t.has_control
|
||||
|
||||
|
||||
def test_xc_open_via_set_cookie_header():
|
||||
class CookieOpener(RecordingOpener):
|
||||
def __call__(self, url, data, timeout, auth):
|
||||
self.requests.append(url)
|
||||
return HttpResponse(200, b"", {"set-cookie": "session=COOKIEVAL; path=/"})
|
||||
|
||||
t = make_xc(CookieOpener())
|
||||
t.connect()
|
||||
assert t.connected
|
||||
|
||||
|
||||
def test_xc_move_absolute_encodes_native_value_and_session():
|
||||
opener = RecordingOpener(bodies={"open.cgi": "s=S1\n"})
|
||||
t = make_xc(opener)
|
||||
t.connect()
|
||||
t.claim_control()
|
||||
t.move_absolute(Axis.PAN, 12.0, speed=30.0)
|
||||
move_url = [u for u in opener.requests if "control.cgi" in u][-1]
|
||||
assert "pan=1200" in move_url # 12.0 deg * scale(100) = 1200 native
|
||||
assert "panspeed=30" in move_url
|
||||
assert "s=S1" in move_url
|
||||
|
||||
|
||||
def test_xc_get_position_decodes_native():
|
||||
opener = RecordingOpener(bodies={"open.cgi": "s=S1\n", "info.cgi": "pan=4500\ntilt=-300\n"})
|
||||
t = make_xc(opener)
|
||||
t.connect()
|
||||
assert t.get_position(Axis.PAN) == pytest.approx(45.0)
|
||||
assert t.get_position(Axis.TILT) == pytest.approx(-3.0)
|
||||
|
||||
|
||||
def test_xc_move_requires_control():
|
||||
opener = RecordingOpener(bodies={"open.cgi": "s=S1\n"})
|
||||
t = make_xc(opener)
|
||||
t.connect()
|
||||
with pytest.raises(CanonControlPrivilegeError):
|
||||
t.move_absolute(Axis.PAN, 10.0)
|
||||
|
||||
|
||||
def test_xc_http_401_and_403_map_to_typed_errors():
|
||||
opener = RecordingOpener(bodies={"open.cgi": "s=S1\n"}, status=401)
|
||||
t = make_xc(opener)
|
||||
with pytest.raises(CanonSessionError):
|
||||
t.connect()
|
||||
|
||||
opener2 = RecordingOpener(bodies={"open.cgi": "s=S1\n"})
|
||||
t2 = make_xc(opener2)
|
||||
t2.connect()
|
||||
opener2.status = 403
|
||||
with pytest.raises(CanonControlPrivilegeError):
|
||||
t2.claim_control()
|
||||
|
||||
|
||||
def test_xc_retries_and_reconnects_on_transient_failure():
|
||||
opener = RecordingOpener(bodies={"open.cgi": "s=S1\n", "info.cgi": "pan=0\n"})
|
||||
t = make_xc(opener)
|
||||
t.connect()
|
||||
# next info request fails once, then the transport reconnects + retries.
|
||||
opener.fail_times = 1
|
||||
assert t.get_position(Axis.PAN) == pytest.approx(0.0)
|
||||
assert opener.requests.count("http://cam.test:80/-wvhttp-01-/open.cgi") >= 2
|
||||
|
||||
|
||||
def test_xc_close_is_idempotent_and_safe():
|
||||
opener = RecordingOpener(bodies={"open.cgi": "s=S1\n"})
|
||||
t = make_xc(opener)
|
||||
t.connect()
|
||||
t.claim_control()
|
||||
t.close()
|
||||
assert not t.connected and not t.has_control
|
||||
t.close() # second call must not raise
|
||||
|
||||
|
||||
def test_xc_focus_mode_roundtrip_wire_tokens():
|
||||
opener = RecordingOpener(bodies={"open.cgi": "s=S1\n", "info.cgi": "focusmode=face\n"})
|
||||
t = make_xc(opener)
|
||||
t.connect()
|
||||
t.claim_control()
|
||||
assert t.get_focus_mode() == FocusMode.FACE_AF
|
||||
t.set_focus_mode(FocusMode.CONTINUOUS_AF)
|
||||
set_url = [u for u in opener.requests if "control.cgi" in u][-1]
|
||||
assert "focusmode=auto" in set_url
|
||||
Reference in New Issue
Block a user