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:
1 parent
f3fe2447a4
commit
7d377f5a0f
7 files changed
+317
-81
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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 ----------------------------------------------
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user