From 7d377f5a0f84dde7332e3fed8d46b4d8ef65bff7 Mon Sep 17 00:00:00 2001 From: menzel Date: Mon, 17 Aug 2026 19:19:57 +0200 Subject: [PATCH] feat(canon): replace the protocol guesses with the measured CR-N300 tables info.cgi from the real camera (129.129.122.151, fw 1.4.0, protocol 5.0.0) turns most of the R1-R10 risk map from assumption into fact. The `# SPEC:` markers become `# MEASURED` where confirmed; what is still a guess still says SPEC. Confirmed as shipped -------------------- Pan and tilt encodings and travel are exactly what we assumed: hundredths of a degree, c.1.pan.min:=-17000/max:=17000 (+/-170 deg) and c.1.tilt.min:=-3000/max:=10000 (-30..+100 deg). Also found c.1..status -- the "is it moving" flag (R8) we were not sure existed. is_moving() now reads it instead of an invented field name. Corrected --------- * Names: everything camera-side is namespaced "c.1.*", nothing like the guessed flat names. Axes are c.1.pan/tilt/zoom/focus.value; parameters map through a new _PARAM_ALIASES table (iris -> c.1.me.diaphragm, gain -> c.1.me.gain, ...). Settings exist twice, ".ae." for auto exposure and ".me." for manual; the settable ".me." variants are used. * Speeds are in hundredths of a degree per second, so a commanded speed is now scaled by 100. Max is 100 deg/s, not the 300 assumed (presets can go faster, but not commanded moves). * Zoom is not a magnification. c.1.zoom is a horizontal *field of view* in hundredths of a degree, 6340 (63.40 deg, wide) down to 340 (3.40 deg, tele) -- the number gets smaller as you zoom in. Users keep magnification (zoom.move(5) == 5x) via a non-linear encoding, mag = tan(wide/2)/tan(fov/2), which yields 1.0-20.8x and independently corroborates the quoted "20x optical zoom". * Focus range is 0..2048, not 0..1000. * Focus modes: c.1.focus takes only manual|auto. Face detection and tracking are *separate* settings (c.1.focus.detect, c.1.focus.auto.track), not focus modes, so FocusMode loses CONTINUOUS_AF/FACE_AF/TRACKING_AF and gains a FaceDetect enum plus face_detect/auto_track signals. The manual-focus gate on the focus axis is unchanged. * preset.cgi does not exist. Presets are the p.* namespace (100 slots, only p.1 "home" enabled). recall_preset now goes through control.cgi; that specific form is the one thing here still marked SPEC/unverified, for Stage 6. Not changed ----------- DEFAULT_STREAM_CGI is left alone: no MJPEG operator exists on this firmware, so `acquisition_mode: mjpeg` has no confirmed endpoint. The JPEG path is 1280x720 at up to 12.5 fps (w.3), which makes jpeg_poll -- the default, at 2 Hz -- the right acquisition mode regardless. Tests: 86 (was 83). Seven existing tests asserted the old guesses and now assert the measured values; new ones decode the camera's actual reported positions (pan -1559 -> -15.59 deg, zoom 1406 -> 5.0x, focus 1114 -> 0.544) and cover the zoom round-trip and the status flag. Co-Authored-By: Claude Opus 5 (1M context) --- csaxs_bec/devices/canon/README.md | 41 ++++-- csaxs_bec/devices/canon/canon_ptz.py | 13 ++ csaxs_bec/devices/canon/transport/base.py | 75 ++++++++-- csaxs_bec/devices/canon/transport/fake.py | 29 +++- csaxs_bec/devices/canon/transport/xc.py | 149 +++++++++++++++----- tests/tests_devices/test_canon_device.py | 9 +- tests/tests_devices/test_canon_transport.py | 82 +++++++++-- 7 files changed, 317 insertions(+), 81 deletions(-) diff --git a/csaxs_bec/devices/canon/README.md b/csaxs_bec/devices/canon/README.md index 4ea44c31..24781e54 100644 --- a/csaxs_bec/devices/canon/README.md +++ b/csaxs_bec/devices/canon/README.md @@ -44,13 +44,25 @@ Key boundaries: 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. +read-out/set coverage. The CGI paths, parameter names and axis encodings live in the +`_CGI` / `_PARAM` / `_PARAM_ALIASES` / `_AXIS_ENCODING` tables at the top of +`transport/xc.py`. A VISCA-over-IP transport could be added later behind the same +`CameraTransport` interface. + +**These tables were measured against the real camera on 2026-08-17** (CR-N300, +firmware 1.4.0, protocol 5.0.0) and carry `# MEASURED` markers; the few remaining +guesses still say `# SPEC:`. Three things are worth knowing because they are not what +a reading of Canon doc BPE-7216-005 would suggest: + +* **The assignment operator is `:=`, not `=`.** Responses read `s:=8091-50cdfb31`; + a few fields use plain `=` and one uses `==`. +* **HTTP 200 does not mean an endpoint exists.** The server answers 200 for everything + and reports the outcome in the body — `Unknown Operator` means absent, + `Parameter Missing` means present. Only `open`, `claim`, `yield`, `close`, `info`, + `control` and `image.cgi` exist; there is no `preset.cgi` or MJPEG operator. +* **Zoom is a field of view, not a magnification** — `c.1.zoom` runs 63.40° (wide) to + 3.40° (tele), so the native number *decreases* as you zoom in. The transport converts + to magnification, giving 1.0–20.8× and corroborating the quoted 20× optical zoom. ## Bringing it up against hardware @@ -134,9 +146,9 @@ 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 | +| `pan` | deg | ±170 (0.2–100 deg/s) | +| `tilt` | deg | −30 … +100 (0.2–100 deg/s) | +| `zoom` | × (optical) | 1.0 … 20.8 (camera reports field of view; converted) | | `focus` | normalised | 0.0 (near) … 1.0 (far), manual-focus only | ```python @@ -149,8 +161,13 @@ 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`. +`exposure_mode`, `shooting_mode`, `iris`, `gain`, `gain_mode`, `white_balance`, +`shutter_speed`, `image_stabilizer`, `focus_mode` (`manual` | `auto`), `face_detect` +(`off` | `faceonly` | `facecatch`), `auto_track` (`off` | `on`), and read-only +`preset_state`. + +> Face detection and subject tracking are **separate settings**, not focus modes — +> that is how the camera models them (`c.1.focus.detect`, `c.1.focus.auto.track`). ```python cam.gain.set(6.0).wait() diff --git a/csaxs_bec/devices/canon/canon_ptz.py b/csaxs_bec/devices/canon/canon_ptz.py index 7cce0e69..3f77bacd 100644 --- a/csaxs_bec/devices/canon/canon_ptz.py +++ b/csaxs_bec/devices/canon/canon_ptz.py @@ -56,6 +56,8 @@ except Exception: # pragma: no cover # Parameter names handled specially by the device (not generic info/control params). _FOCUS_MODE_PARAM = "focus_mode" +_FACE_DETECT_PARAM = "face_detect" +_AUTO_TRACK_PARAM = "auto_track" _PRESET_STATE_PARAM = "preset_state" # Acquisition modes -- how frames reach the ring buffer, and at what network cost. @@ -165,12 +167,23 @@ class CanonPTZCamera(PSIDeviceBase): focus = Cpt(CanonAxis, axis=Axis.FOCUS, egu="", tolerance=0.01, kind=Kind.normal) # -- camera parameters ------------------------------------------------- + # Names are the friendly ones; the transport maps them onto the measured + # camera-side names (c.1.exp, c.1.me.diaphragm, ...) -- see xc._PARAM_ALIASES. exposure_mode = Cpt(CanonConfigSignal, cmd="exposure_mode", verify=False, kind=Kind.config) + shooting_mode = Cpt(CanonConfigSignal, cmd="shooting_mode", verify=False, kind=Kind.config) iris = Cpt(CanonConfigSignal, cmd="iris", kind=Kind.config) gain = Cpt(CanonConfigSignal, cmd="gain", kind=Kind.config) + gain_mode = Cpt(CanonConfigSignal, cmd="gain_mode", verify=False, 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) + image_stabilizer = Cpt( + CanonConfigSignal, cmd="image_stabilizer", verify=False, kind=Kind.config + ) + # Focus mode is manual|auto. Face detection and subject tracking are separate + # settings on this camera, not extra focus modes. focus_mode = Cpt(CanonConfigSignal, cmd=_FOCUS_MODE_PARAM, verify=False, kind=Kind.config) + face_detect = Cpt(CanonConfigSignal, cmd=_FACE_DETECT_PARAM, verify=False, kind=Kind.config) + auto_track = Cpt(CanonConfigSignal, cmd=_AUTO_TRACK_PARAM, verify=False, kind=Kind.config) preset_state = Cpt(CanonParameterSignal, cmd=_PRESET_STATE_PARAM, kind=Kind.normal) # -- Phase-2 live preview ---------------------------------------------- diff --git a/csaxs_bec/devices/canon/transport/base.py b/csaxs_bec/devices/canon/transport/base.py index 989d61bb..ac9452c8 100644 --- a/csaxs_bec/devices/canon/transport/base.py +++ b/csaxs_bec/devices/canon/transport/base.py @@ -26,6 +26,7 @@ from __future__ import annotations import abc import enum +import math from dataclasses import dataclass @@ -39,16 +40,31 @@ class Axis(str, enum.Enum): class FocusMode(str, enum.Enum): - """Focus operating modes supported by the CR-N300. + """Focus operating mode. - ``MANUAL`` exposes the manual-focus position as a positioner; the autofocus - modes drive focus internally and the manual-focus positioner is inactive. + MEASURED (CR-N300 fw 1.4.0): ``c.1.focus`` takes exactly two values. Face + detection and subject tracking are **not** focus modes -- they are independent + settings (``c.1.focus.detect``, ``c.1.focus.auto.track``) that shape how AUTO + behaves, and are exposed separately as :class:`FaceDetect` and a tracking flag. + + ``MANUAL`` exposes the focus position as a positioner; in ``AUTO`` the camera + drives focus itself and the positioner is inactive. """ MANUAL = "manual" - CONTINUOUS_AF = "continuous_af" - FACE_AF = "face_af" - TRACKING_AF = "tracking_af" + AUTO = "auto" + + +class FaceDetect(str, enum.Enum): + """Face-detection behaviour, applied on top of :attr:`FocusMode.AUTO`. + + MEASURED: ``c.1.focus.detect`` -- ``off``, ``faceonly`` (focus only on a + detected face), ``facecatch`` (prefer a face, fall back to the whole scene). + """ + + OFF = "off" + FACE_ONLY = "faceonly" + FACE_CATCH = "facecatch" @dataclass(frozen=True) @@ -65,13 +81,48 @@ class AxisLimits: 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``). +# Zoom is reported by the camera as a horizontal *field of view*, not a +# magnification: c.1.zoom runs 340..6340 in hundredths of a degree, i.e. 63.40 deg +# at the wide end down to 3.40 deg at full telephoto -- so the number gets *smaller* +# as you zoom in. The transport converts to magnification, which is what the +# datasheet quotes and what users expect from `zoom.move(5)`. +# +# magnification = tan(wide_fov / 2) / tan(fov / 2) +# +# MEASURED (CR-N300 fw 1.4.0): c.1.zoom.min:=340, c.1.zoom.max:=6340. The resulting +# maximum works out at 20.8x, which corroborates the quoted "20x optical zoom". +ZOOM_FOV_TELE_DEG = 3.40 +ZOOM_FOV_WIDE_DEG = 63.40 +_ZOOM_WIDE_TAN = math.tan(math.radians(ZOOM_FOV_WIDE_DEG / 2.0)) +ZOOM_MAG_MIN = 1.0 +ZOOM_MAG_MAX = _ZOOM_WIDE_TAN / math.tan(math.radians(ZOOM_FOV_TELE_DEG / 2.0)) + + +def zoom_fov_to_magnification(fov_deg: float) -> float: + """Convert a horizontal field of view in degrees to optical magnification.""" + return _ZOOM_WIDE_TAN / math.tan(math.radians(fov_deg / 2.0)) + + +def zoom_magnification_to_fov(magnification: float) -> float: + """Inverse of :func:`zoom_fov_to_magnification`.""" + return 2.0 * math.degrees(math.atan(_ZOOM_WIDE_TAN / magnification)) + + +# Hardware limits, MEASURED against a CR-N300 (fw 1.4.0) on 2026-08-17 -- these are +# the *physical* limits; per-deployment soft limits can be narrower and are applied +# in the ophyd layer (the positioner's ``limits``). +# +# Pan/tilt travel and speed come straight from info.cgi: +# c.1.pan.min:=-17000 c.1.pan.max:=17000 (hundredths of a degree) +# c.1.tilt.min:=-3000 c.1.tilt.max:=10000 +# c.1.{pan,tilt}.speed.min:=20 .speed.max:=10000 (hundredths of a deg/s) +# Presets can be driven faster (p.1.pan.speed:=30000, i.e. 300 deg/s), but 100 deg/s +# is the ceiling for the commanded moves this transport issues. 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.PAN: AxisLimits(low=-170.0, high=170.0, speed_min=0.2, speed_max=100.0), + Axis.TILT: AxisLimits(low=-30.0, high=100.0, speed_min=0.2, speed_max=100.0), + Axis.ZOOM: AxisLimits(low=ZOOM_MAG_MIN, high=ZOOM_MAG_MAX), + # c.1.focus.value:=0..2048, exposed normalised 0..1. Axis.FOCUS: AxisLimits(low=0.0, high=1.0), } diff --git a/csaxs_bec/devices/canon/transport/fake.py b/csaxs_bec/devices/canon/transport/fake.py index e55aef10..6cf5483f 100644 --- a/csaxs_bec/devices/canon/transport/fake.py +++ b/csaxs_bec/devices/canon/transport/fake.py @@ -19,23 +19,32 @@ from __future__ import annotations from typing import Callable -from .base import HARDWARE_LIMITS, Axis, CameraInfo, CameraTransport, FocusMode +from .base import HARDWARE_LIMITS, Axis, CameraInfo, CameraTransport, FaceDetect, 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. +# Defaults mirror a real CR-N300 as measured on 2026-08-17, in the units the +# transport exposes (see xc._PARAM_ALIASES for the camera-side names). _DEFAULT_PARAMETERS: dict[str, object] = { - "exposure_mode": "auto", - "iris": 5.6, - "gain": 0.0, + "exposure_mode": "auto", # c.1.exp: auto | av | tv | manual + "shooting_mode": "fullauto", # c.1.shooting + "iris": 260, # c.1.me.diaphragm, f-number x100 => f/2.6 + "gain": 240, # c.1.me.gain, 0..360 + "gain_mode": "auto", + "shutter_speed": 25, # c.1.me.shutter denominator => 1/25 s "white_balance": "auto", - "shutter_speed": "1/50", + "white_balance_kelvin": 3770, + "image_stabilizer": "on1", + # Focus sub-settings -- separate from the focus mode on real hardware. + "face_detect": FaceDetect.FACE_CATCH.value, + "auto_track": "off", } _DEFAULT_POSITIONS: dict[Axis, float] = { Axis.PAN: 0.0, Axis.TILT: 0.0, - Axis.ZOOM: 1.0, + Axis.ZOOM: 1.0, # magnification, wide end Axis.FOCUS: 0.5, } @@ -48,6 +57,10 @@ _DEFAULT_SPEED: dict[Axis, float] = { Axis.FOCUS: 1.0, } +# Focus sub-settings that are separate from the focus *mode* on real hardware. +_DEFAULT_FACE_DETECT = FaceDetect.FACE_CATCH +_DEFAULT_AUTO_TRACK = False + class FakeTransport(CameraTransport): """A deterministic, hardware-free :class:`CameraTransport` implementation.""" @@ -63,7 +76,9 @@ class FakeTransport(CameraTransport): 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._info = info or CameraInfo( + model="Canon CR-N300 (fake)", firmware="1.4.0", serial="7438B7FF507C" + ) self._connected = False self._has_control = False diff --git a/csaxs_bec/devices/canon/transport/xc.py b/csaxs_bec/devices/canon/transport/xc.py index 39480eb5..42da5e01 100644 --- a/csaxs_bec/devices/canon/transport/xc.py +++ b/csaxs_bec/devices/canon/transport/xc.py @@ -32,7 +32,15 @@ import urllib.parse import urllib.request from dataclasses import dataclass -from .base import HARDWARE_LIMITS, Axis, CameraInfo, CameraTransport, FocusMode +from .base import ( + HARDWARE_LIMITS, + Axis, + CameraInfo, + CameraTransport, + FocusMode, + zoom_fov_to_magnification, + zoom_magnification_to_fov, +) from .errors import ( CanonCommandError, CanonConnectionError, @@ -72,31 +80,62 @@ _CGI = { "close": "close.cgi", "info": "info.cgi", "control": "control.cgi", - "preset": "preset.cgi", "still": DEFAULT_STILL_CGI, } +# MEASURED: preset.cgi, getparam.cgi, camera.cgi, nphControlCamera, nphMotionJpeg, +# GetLiveImage, getstill.cgi and snapshot.cgi all answer "Unknown Operator" on this +# firmware -- they do not exist. Presets live in the "p.*" namespace instead and are +# driven through control.cgi (see recall_preset). + +# MEASURED (CR-N300 fw 1.4.0, protocol 5.0.0) on 2026-08-17 from info.cgi. +# +# Everything camera-side lives under a "c.." namespace, where is the camera +# index (c.count:=1, so always 1 here). Exposure-dependent settings appear twice -- +# "c.1.ae.*" are the values in effect under auto exposure, "c.1.me.*" under manual -- +# so writes go to the pair matching the current c.1.exp. +_CAM = "c.1" -# 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", + # Axes. Pan/tilt in hundredths of a degree; zoom as a field-of-view angle; + # focus as a 0..2048 position (only meaningful when c.1.focus is "manual"). + "pan": f"{_CAM}.pan", + "tilt": f"{_CAM}.tilt", + "zoom": f"{_CAM}.zoom", + "focus": f"{_CAM}.focus.value", + "pan_speed": f"{_CAM}.pan.speed", + "tilt_speed": f"{_CAM}.tilt.speed", + "zoom_speed": f"{_CAM}.zoom.speed", + # Motion state -- c.1..status is 0 when the axis is idle. This is the + # "moving" flag we were unsure existed (risk item R8). + "pan_status": f"{_CAM}.pan.status", + "tilt_status": f"{_CAM}.tilt.status", + "zoom_status": f"{_CAM}.zoom.status", + # Focus. + "focus_mode": f"{_CAM}.focus", + "face_detect": f"{_CAM}.focus.detect", + "auto_track": f"{_CAM}.focus.auto.track", "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", +# Camera parameters exposed as ophyd signals, mapped to their measured names. +# The ".me." (manual-exposure) variants are used for the settable ones because the +# ".ae." counterparts are read-only outputs of the auto-exposure algorithm. +_PARAM_ALIASES = { + "exposure_mode": f"{_CAM}.exp", # auto | av | tv | manual + "shooting_mode": f"{_CAM}.shooting", # fullauto | manual | scene + "iris": f"{_CAM}.me.diaphragm", # f-number x100: 260..2200 = f/2.6..f/22 + "gain": f"{_CAM}.me.gain", # 0..360 in steps of 5 + "gain_mode": f"{_CAM}.me.gain.mode", # auto | manual + "shutter_speed": f"{_CAM}.me.shutter", # denominator: 25 => 1/25 s + "white_balance": f"{_CAM}.wb", # auto | manual | wb_a | wb_b | daylight | ... + "white_balance_kelvin": f"{_CAM}.wb.kelvin", + "image_stabilizer": f"{_CAM}.is", # off | on1 | on2 } + +# MEASURED: c.1.focus takes exactly two values. Face detection and tracking are +# separate settings, not focus modes -- see FocusMode/FaceDetect in base.py. +_FOCUS_MODE_TO_WIRE = {FocusMode.MANUAL: "manual", FocusMode.AUTO: "auto"} _WIRE_TO_FOCUS_MODE = {v: k for k, v in _FOCUS_MODE_TO_WIRE.items()} @@ -104,8 +143,7 @@ _WIRE_TO_FOCUS_MODE = {v: k for k, v in _FOCUS_MODE_TO_WIRE.items()} 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. + native = round(user * scale + offset). """ scale: float @@ -118,15 +156,38 @@ class _AxisEncoding: 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. +@dataclass(frozen=True) +class _ZoomEncoding: + """Map optical magnification to the camera's native field-of-view integer. + + Unlike the other axes this is **not linear and runs backwards**: the camera + reports ``c.1.zoom`` as a horizontal field of view in hundredths of a degree, + so a *larger* native value is a *wider* view, i.e. less zoom. Users get + magnification (``zoom.move(5)`` = 5x), which is what the datasheet quotes. + """ + + def to_native(self, user: float) -> int: + return int(round(zoom_magnification_to_fov(user) * 100.0)) + + def to_user(self, native: float) -> float: + return zoom_fov_to_magnification(native / 100.0) + + +# MEASURED (CR-N300 fw 1.4.0) on 2026-08-17: +# c.1.pan:=-1559 min -17000 max 17000 -> hundredths of a degree +# c.1.tilt:=-2226 min -3000 max 10000 -> hundredths of a degree +# c.1.zoom:=1406 min 340 max 6340 -> hundredths of a degree of FOV +# c.1.focus.value:=1114 0 2048 -> normalised to 0..1 _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), + Axis.ZOOM: _ZoomEncoding(), + Axis.FOCUS: _AxisEncoding(scale=2048.0), } +# Speeds are also in hundredths of a unit per second (c.1.pan.speed.min:=20 = 0.2 deg/s). +_SPEED_SCALE = 100.0 + @dataclass class HttpResponse: @@ -363,16 +424,24 @@ class XCTransport(CameraTransport): # -- parameters -------------------------------------------------------- + @staticmethod + def _resolve(name: str) -> str: + """Map a friendly parameter name onto its measured camera-side name.""" + return _PARAM_ALIASES.get(name, name) + def get_parameter(self, name: str) -> object: + key = self._resolve(name) 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] + if key not in kv: + raise CanonParameterError( + f"parameter {name!r} (camera name {key!r}) not present in info.cgi response" + ) + return kv[key] 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}) + self._request("control", {self._resolve(name): value}) def list_parameters(self) -> dict[str, object]: return dict(self._parse_kv(self._request("info").text)) @@ -396,7 +465,8 @@ class XCTransport(CameraTransport): ) 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)) + # MEASURED: speeds are in hundredths of a degree per second. + params[_PARAM[f"{axis.value}_speed"]] = int(round(speed * _SPEED_SCALE)) self._request("control", params) def jog(self, axis: Axis, speed: float) -> None: @@ -405,12 +475,17 @@ class XCTransport(CameraTransport): 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))}) + self._request("control", {_PARAM[f"{axis.value}_speed"]: int(round(speed * _SPEED_SCALE))}) def is_moving(self, axis: Axis) -> bool: + # MEASURED: c.1..status is 0 while the axis is idle. Focus has no such + # field, so it falls back to "not moving" and completion relies on the + # positioner's tolerance check. + key = _PARAM.get(f"{axis.value}_status") + if key is None: + return False kv = self._parse_kv(self._request("info").text) - # SPEC: a "moving" flag is assumed; absence means we report False. - flag = kv.get(f"{axis.value}moving") + flag = kv.get(key) return flag not in (None, "0", "false", "") def stop(self, axis: Axis | None = None) -> None: @@ -434,9 +509,19 @@ class XCTransport(CameraTransport): # -- presets ----------------------------------------------------------- def recall_preset(self, preset_id: int) -> None: + """Recall a stored PTZ preset. + + MEASURED: there is no ``preset.cgi``. Presets are the ``p.*`` namespace -- + ``p.count:=100`` slots, of which only ``p.1`` ("home") is enabled by default, + and the currently selected one is reported as ``p:=``. + + SPEC/UNVERIFIED: recall is issued as ``control.cgi?p=``, following the + pattern that control.cgi writes the same names info.cgi reports. Confirm at + commissioning Stage 6; if it is wrong, this one line is what changes. + """ if not self._has_control: raise CanonControlPrivilegeError("control privilege required to recall presets") - self._request("preset", {_PARAM["preset"]: int(preset_id)}) + self._request("control", {"p": int(preset_id)}) self._preset_state = int(preset_id) def get_preset_state(self) -> int: diff --git a/tests/tests_devices/test_canon_device.py b/tests/tests_devices/test_canon_device.py index 089b2610..735a48ea 100644 --- a/tests/tests_devices/test_canon_device.py +++ b/tests/tests_devices/test_canon_device.py @@ -8,7 +8,7 @@ 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.base import ZOOM_MAG_MAX, ZOOM_MAG_MIN, 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 @@ -31,7 +31,10 @@ def test_connect_claims_control(cam): 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) + # MEASURED: zoom is a field of view, 63.40 deg wide to 3.40 deg tele, which + # works out at 20.8x -- corroborating the quoted 20x optical zoom. + assert cam.zoom.limits == (ZOOM_MAG_MIN, pytest.approx(ZOOM_MAG_MAX)) + assert cam.zoom.limits[1] == pytest.approx(20.81, abs=0.01) for ax in (cam.pan, cam.tilt, cam.zoom, cam.focus): assert hasattr(ax, "move") and hasattr(ax, "stop") @@ -81,7 +84,7 @@ def test_parameter_readback_mismatch_raises(): def test_focus_axis_gated_by_focus_mode(cam): - cam.focus_mode.set(FocusMode.CONTINUOUS_AF.value).wait() + cam.focus_mode.set(FocusMode.AUTO.value).wait() with pytest.raises(RuntimeError): cam.focus.move(0.3) cam.focus_mode.set(FocusMode.MANUAL.value).wait() diff --git a/tests/tests_devices/test_canon_transport.py b/tests/tests_devices/test_canon_transport.py index a0eff241..cd41eb9f 100644 --- a/tests/tests_devices/test_canon_transport.py +++ b/tests/tests_devices/test_canon_transport.py @@ -3,7 +3,7 @@ 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.base import ZOOM_MAG_MAX, ZOOM_MAG_MIN, Axis, FocusMode from csaxs_bec.devices.canon.transport.errors import ( CanonCommandError, CanonConnectionError, @@ -12,7 +12,7 @@ from csaxs_bec.devices.canon.transport.errors import ( CanonSessionError, ) from csaxs_bec.devices.canon.transport.fake import FakeTransport -from csaxs_bec.devices.canon.transport.xc import HttpResponse, XCTransport +from csaxs_bec.devices.canon.transport.xc import _AXIS_ENCODING, HttpResponse, XCTransport # --------------------------------------------------------------------------- # # FakeTransport # @@ -63,17 +63,17 @@ def test_fake_absolute_move_and_limits(fake): 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 + assert fake.get_parameter("gain") == 240 # MEASURED default c.1.me.gain:=240 + fake.set_parameter("gain", 120) + assert fake.get_parameter("gain") == 120 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 + fake.set_focus_mode(FocusMode.AUTO) + assert fake.get_focus_mode() == FocusMode.AUTO assert fake.get_preset_state() == -1 fake.recall_preset(3) assert fake.get_preset_state() == 3 @@ -172,13 +172,17 @@ def test_xc_move_absolute_encodes_native_value_and_session(): 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 + # MEASURED: the camera namespaces everything under c.1.*, and speeds are in + # hundredths of a degree per second (c.1.pan.speed.min:=20 == 0.2 deg/s). + assert "c.1.pan=1200" in move_url # 12.0 deg * 100 = 1200 native + assert "c.1.pan.speed=3000" in move_url # 30 deg/s * 100 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"}) + opener = RecordingOpener( + bodies={"open.cgi": "s=S1\n", "info.cgi": "c.1.pan:=4500\nc.1.tilt:=-300\n"} + ) t = make_xc(opener) t.connect() assert t.get_position(Axis.PAN) == pytest.approx(45.0) @@ -208,7 +212,7 @@ def test_xc_http_401_and_403_map_to_typed_errors(): def test_xc_retries_and_reconnects_on_transient_failure(): - opener = RecordingOpener(bodies={"open.cgi": "s=S1\n", "info.cgi": "pan=0\n"}) + opener = RecordingOpener(bodies={"open.cgi": "s=S1\n", "info.cgi": "c.1.pan:=0\n"}) t = make_xc(opener) t.connect() # next info request fails once, then the transport reconnects + retries. @@ -228,14 +232,15 @@ def test_xc_close_is_idempotent_and_safe(): def test_xc_focus_mode_roundtrip_wire_tokens(): - opener = RecordingOpener(bodies={"open.cgi": "s=S1\n", "info.cgi": "focusmode=face\n"}) + opener = RecordingOpener(bodies={"open.cgi": "s=S1\n", "info.cgi": "c.1.focus:=auto\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) + assert t.get_focus_mode() == FocusMode.AUTO + t.set_focus_mode(FocusMode.MANUAL) set_url = [u for u in opener.requests if "control.cgi" in u][-1] - assert "focusmode=auto" in set_url + # MEASURED: the field is c.1.focus and takes only manual|auto. + assert "c.1.focus=manual" in set_url # --------------------------------------------------------------------------- # @@ -297,6 +302,53 @@ def test_xc_connect_finds_the_session_id_in_a_real_open_response(): assert t._session_id == "8091-50cdfb31" +def test_axis_encodings_decode_the_cameras_reported_positions(): + """Decode the exact values the camera reported on 2026-08-17.""" + opener = RecordingOpener( + bodies={ + "open.cgi": REAL_OPEN_BODY, + "info.cgi": ( + "c.1.pan:=-1559\nc.1.tilt:=-2226\nc.1.zoom:=1406\nc.1.focus.value:=1114\n" + ), + } + ) + t = make_xc(opener) + t.connect() + # Pan/tilt: hundredths of a degree. + assert t.get_position(Axis.PAN) == pytest.approx(-15.59) + assert t.get_position(Axis.TILT) == pytest.approx(-22.26) + # Focus: 0..2048 normalised to 0..1. + assert t.get_position(Axis.FOCUS) == pytest.approx(1114 / 2048) + # Zoom: 14.06 deg field of view -> about 5x magnification. + assert t.get_position(Axis.ZOOM) == pytest.approx(5.0, abs=0.02) + + +def test_zoom_encoding_is_a_non_linear_fov_conversion(): + """Zoom runs backwards and non-linearly: wider FOV means less magnification.""" + enc = _AXIS_ENCODING[Axis.ZOOM] + # The two hardware endpoints, c.1.zoom.min:=340 and .max:=6340. + assert enc.to_user(6340) == pytest.approx(ZOOM_MAG_MIN) + assert enc.to_user(340) == pytest.approx(ZOOM_MAG_MAX) + assert ZOOM_MAG_MAX == pytest.approx(20.81, abs=0.01) # corroborates "20x optical" + # Round-trips, and a larger native value really is less zoom. + for mag in (1.0, 2.5, 5.0, 12.0, 20.0): + assert enc.to_user(enc.to_native(mag)) == pytest.approx(mag, rel=2e-3) + assert enc.to_native(20.0) < enc.to_native(2.0) + + +def test_is_moving_reads_the_measured_status_field(): + """R8: c.1..status is the moving flag we were unsure existed.""" + opener = RecordingOpener( + bodies={"open.cgi": REAL_OPEN_BODY, "info.cgi": "c.1.pan.status:=1\nc.1.tilt.status:=0\n"} + ) + t = make_xc(opener) + t.connect() + assert t.is_moving(Axis.PAN) + assert not t.is_moving(Axis.TILT) + # Focus has no status field on this camera -- report idle rather than guess. + assert not t.is_moving(Axis.FOCUS) + + def test_xc_camera_info_uses_the_measured_field_names(): opener = RecordingOpener(bodies={"open.cgi": REAL_OPEN_BODY, "info.cgi": REAL_INFO_BODY}) t = make_xc(opener)