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.<axis>.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) <noreply@anthropic.com>
This commit is contained in:
menzelandClaude Opus 5 committed 2026-08-31 16:41:06 +02:00
1 parent f3fe2447a4
commit 7d377f5a0f
7 files changed
+317 -81

No files matched your search

+29 -12
View File
@@ -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()
+13
View File
@@ -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 ----------------------------------------------
+63 -12
View File
@@ -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),
}
+22 -7
View File
@@ -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
+117 -32
View File
@@ -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.<n>." namespace, where <n> 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.<axis>.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.<axis>.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 "<axis>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:=<id>``.
SPEC/UNVERIFIED: recall is issued as ``control.cgi?p=<id>``, 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:
+6 -3
View File
@@ -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()
+67 -15
View File
@@ -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.<axis>.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)