ipcam-mcp 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ipcam_mcp/__init__.py ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Device adapters. Each adapter implements one or more capability protocols."""
@@ -0,0 +1,483 @@
1
+ """Generic ONVIF/RTSP IP camera: RTSP media via ffmpeg, optional PTZ via ONVIF."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import dataclasses
6
+ import logging
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import quote, urlsplit
12
+
13
+ import aiohttp
14
+ import zeep.exceptions
15
+
16
+ from ipcam_mcp.capabilities import Amount, Direction, Preset, Quality
17
+ from ipcam_mcp.config import DeviceConfig
18
+ from ipcam_mcp.errors import CameraMcpError, ConfigError, DeviceError, scrub
19
+ from ipcam_mcp.media import mask_url, media_path, record_cmd, run_ffmpeg, snapshot_cmd
20
+ from ipcam_mcp.store import ensure_folder
21
+
22
+ log = logging.getLogger(__name__)
23
+ # Closes still running after their caller was cancelled (a reference keeps them alive).
24
+ _closing: set[asyncio.Task] = set()
25
+
26
+
27
+ async def _close_client(cam: Any) -> None:
28
+ """Close an ONVIF client, best effort. Shielded: a cancelled caller does not leave the
29
+ session half closed (the close finishes in the background)."""
30
+ if not hasattr(cam, "close"):
31
+ return
32
+ with contextlib.suppress(Exception):
33
+ task = asyncio.ensure_future(cam.close())
34
+ _closing.add(task)
35
+ task.add_done_callback(_close_done)
36
+ await asyncio.shield(task)
37
+
38
+
39
+ def _close_done(task: asyncio.Task) -> None:
40
+ _closing.discard(task)
41
+ if not task.cancelled() and task.exception() is not None:
42
+ log.debug("closing an ONVIF client failed: %s", type(task.exception()).__name__)
43
+
44
+
45
+ PROBE_PORTS = (80, 2020)
46
+ DEFAULT_RTSP_PORT = 554
47
+ SNAPSHOT_TIMEOUT = 20
48
+ RECORD_TIMEOUT_EXTRA = 15
49
+ MAX_RECORD_SECONDS = 60
50
+ ONVIF_TIMEOUT = 15.0
51
+
52
+ # Standard ONVIF axes; cameras with an inverted pan axis (e.g. Tapo TC70) set invert_pan.
53
+ VECTORS: dict[str, tuple[int, int]] = {
54
+ "left": (-1, 0),
55
+ "right": (1, 0),
56
+ "up": (0, 1),
57
+ "down": (0, -1),
58
+ }
59
+ RELATIVE_STEPS: dict[str, float] = {"small": 0.05, "medium": 0.15, "large": 0.30}
60
+ CONTINUOUS_SPEED = 0.5
61
+ CONTINUOUS_DURATIONS: dict[str, float] = {"small": 0.3, "medium": 0.8, "large": 1.5}
62
+ # The TC70 reports MoveStatus UNKNOWN and drops new moves while still moving, so after a
63
+ # move we poll the position until two consecutive reads match.
64
+ SETTLE_POLL = 0.3
65
+ STOP_TIMEOUT = 5.0
66
+ SETTLE_MAX_POLLS = 20
67
+ # Settling has its own budget: a move that worked must not be reported as a timeout.
68
+ SETTLE_TIMEOUT = 10.0
69
+
70
+
71
+ def _default_onvif_factory(host: str, port: int, user: str, password: str) -> Any:
72
+ from onvif import ONVIFCamera
73
+
74
+ return ONVIFCamera(host, port, user, password)
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class DeviceInfo:
79
+ manufacturer: str
80
+ model: str
81
+ ptz: bool = False
82
+ onvif_port: int = 80
83
+
84
+
85
+ # (manufacturer substring, model substring or None, settings), matched case-insensitively.
86
+ KNOWN_MODELS: list[tuple[str, str | None, dict[str, Any]]] = [
87
+ ("tp-link", None, {"invert_pan": True}),
88
+ ]
89
+
90
+
91
+ def known_settings(manufacturer: str, model: str) -> dict[str, Any]:
92
+ """Settings known for a device (e.g. an inverted pan axis); empty if unknown."""
93
+ result: dict[str, Any] = {}
94
+ for maker, mdl, settings in KNOWN_MODELS:
95
+ if maker.lower() in manufacturer.lower() and (mdl is None or mdl.lower() in model.lower()):
96
+ result.update(settings)
97
+ return result
98
+
99
+
100
+ async def _probe_port(
101
+ host: str, port: int, user: str, password: str, factory: Callable[..., Any]
102
+ ) -> DeviceInfo:
103
+ cam = factory(host, port, user, password)
104
+ try:
105
+ await cam.update_xaddrs()
106
+ mgmt = await cam.create_devicemgmt_service()
107
+ info = await mgmt.GetDeviceInformation()
108
+ try:
109
+ caps = await mgmt.GetCapabilities({"Category": "All"})
110
+ ptz = getattr(caps, "PTZ", None) is not None
111
+ except Exception: # noqa: BLE001 — PTZ detection is best effort
112
+ ptz = False
113
+ return DeviceInfo(str(info.Manufacturer), str(info.Model), ptz, port)
114
+ finally:
115
+ await _close_client(cam)
116
+
117
+
118
+ async def probe(
119
+ host: str,
120
+ user: str,
121
+ password: str,
122
+ port: int | None = None,
123
+ onvif_factory: Callable[..., Any] | None = None,
124
+ ) -> DeviceInfo:
125
+ """Verify ONVIF credentials and detect the port (80, then 2020) and PTZ support."""
126
+ factory = onvif_factory or _default_onvif_factory
127
+ ports = (port,) if port is not None else PROBE_PORTS
128
+ reasons: list[str] = []
129
+ for p in ports:
130
+ try:
131
+ return await asyncio.wait_for(
132
+ _probe_port(host, p, user, password, factory), ONVIF_TIMEOUT
133
+ )
134
+ except TimeoutError:
135
+ reasons.append(f"port {p}: timed out after {ONVIF_TIMEOUT:.0f}s (unreachable?)")
136
+ except (OSError, aiohttp.ClientError, zeep.exceptions.TransportError) as e:
137
+ msg = scrub(f"{type(e).__name__}: {e}", [password, user])
138
+ reasons.append(f"port {p}: {msg}")
139
+ except (zeep.exceptions.XMLSyntaxError, zeep.exceptions.XMLParseError):
140
+ # Something answered (often the camera's web UI), but not with SOAP: not ONVIF.
141
+ reasons.append(f"port {p}: answered, but not as an ONVIF service")
142
+ except Exception as e: # noqa: BLE001 — a SOAP fault or other error: stop searching
143
+ msg = scrub(f"{type(e).__name__}: {e}", [password, user])
144
+ raise DeviceError(f"{host}:{p}: ONVIF login failed: {msg}") from None
145
+ if len(ports) == 1:
146
+ raise DeviceError(f"{host}:{ports[0]}: ONVIF login failed ({reasons[0]})")
147
+ tried = ", ".join(str(p) for p in ports)
148
+ raise DeviceError(
149
+ f"{host}: no ONVIF service found on ports {tried} ({'; '.join(reasons)}); "
150
+ "pass onvif_port if the camera uses another port"
151
+ )
152
+
153
+
154
+ TYPE_DEFAULTS: dict[str, dict[str, Any]] = {
155
+ "onvif": {"onvif_port": 80, "invert_pan": False, "ptz": False},
156
+ "tapo_onvif": {
157
+ "onvif_port": 2020,
158
+ "invert_pan": True,
159
+ "ptz": True,
160
+ "stream_high": "/stream1",
161
+ "stream_low": "/stream2",
162
+ },
163
+ }
164
+
165
+
166
+ def _check_settings(cfg: DeviceConfig, s: dict[str, Any]) -> None:
167
+ def bad(key: str, why: str) -> ConfigError:
168
+ return ConfigError(f"camera '{cfg.id}': setting '{key}' {why}")
169
+
170
+ for key in ("onvif_port", "rtsp_port"):
171
+ v = s.get(key)
172
+ if v is None:
173
+ continue
174
+ if type(v) is not int or not 1 <= v <= 65535:
175
+ raise bad(key, "must be an integer between 1 and 65535")
176
+ for key in ("invert_pan", "ptz"):
177
+ if key in s and type(s[key]) is not bool:
178
+ raise bad(key, "must be true or false")
179
+ for key in ("stream_high", "stream_low"):
180
+ v = s.get(key)
181
+ if v is not None and not (isinstance(v, str) and v.startswith("/")):
182
+ raise bad(key, "must be a string starting with '/' (an RTSP path)")
183
+
184
+
185
+ def make_camera(
186
+ cfg: DeviceConfig,
187
+ media_dir: Path,
188
+ onvif_factory: Callable[..., Any] | None = None,
189
+ ) -> "OnvifCamera":
190
+ """Apply per-type defaults, validate settings, build the adapter (PTZ only if ptz is on)."""
191
+ settings = {**TYPE_DEFAULTS.get(cfg.type, TYPE_DEFAULTS["onvif"]), **cfg.settings}
192
+ _check_settings(cfg, settings)
193
+ resolved = dataclasses.replace(cfg, settings=settings)
194
+ cls = OnvifPtzCamera if settings.get("ptz") else OnvifCamera
195
+ return cls(resolved, media_dir, onvif_factory)
196
+
197
+
198
+ class OnvifCamera:
199
+ """RTSP media via ffmpeg; stream paths from settings or looked up over ONVIF."""
200
+
201
+ def __init__(
202
+ self,
203
+ cfg: DeviceConfig,
204
+ media_dir: Path,
205
+ onvif_factory: Callable[..., Any] | None = None,
206
+ ) -> None:
207
+ self._cfg = cfg
208
+ self._media_dir = media_dir
209
+ self._rtsp_port = int(cfg.settings.get("rtsp_port", DEFAULT_RTSP_PORT))
210
+ self._onvif_port = int(cfg.settings.get("onvif_port", 80))
211
+ self._invert_pan = bool(cfg.settings.get("invert_pan", False))
212
+ self._onvif_factory = onvif_factory or _default_onvif_factory
213
+ self._connect_lock = asyncio.Lock()
214
+ self._cam: Any = None
215
+ self._profiles: list[str] = []
216
+ self._ptz: Any = None
217
+ self._use_continuous = False
218
+ self._streams: dict[str, tuple[int, str]] = {}
219
+
220
+ # --- media -----------------------------------------------------------------
221
+
222
+ async def rtsp_url(self, quality: Quality) -> str:
223
+ c = self._cfg
224
+ port, path = await self._stream(quality)
225
+ creds = f"{quote(c.user, safe='')}:{quote(c.password, safe='')}"
226
+ host = f"[{c.host}]" if ":" in c.host else c.host # IPv6 literal
227
+ return f"rtsp://{creds}@{host}:{port}{path}"
228
+
229
+ async def _stream(self, quality: Quality) -> tuple[int, str]:
230
+ """Return (port, path+query) for a quality: configured path, else ONVIF lookup."""
231
+ key = f"stream_{'high' if quality == 'high' else 'low'}"
232
+ configured = self._cfg.settings.get(key)
233
+ if configured:
234
+ return self._rtsp_port, configured
235
+ if quality not in self._streams:
236
+ try:
237
+ self._streams[quality] = await self._onvif(lambda: self._lookup_stream(quality))
238
+ except DeviceError as e:
239
+ raise DeviceError(
240
+ f"{e}; set '{key}' in the camera settings to give the RTSP path"
241
+ ) from None
242
+ return self._streams[quality]
243
+
244
+ async def _lookup_stream(self, quality: Quality) -> tuple[int, str]:
245
+ c = self._cfg
246
+ try:
247
+ _, profiles = await self._client()
248
+ token = profiles[0] if quality == "high" or len(profiles) < 2 else profiles[1]
249
+ media = await self._cam.create_media_service()
250
+ reply = await media.GetStreamUri(
251
+ {
252
+ "ProfileToken": token,
253
+ "StreamSetup": {"Stream": "RTP-Unicast", "Transport": {"Protocol": "RTSP"}},
254
+ }
255
+ )
256
+ parts = urlsplit(str(reply.Uri))
257
+ if parts.scheme != "rtsp" or not parts.hostname:
258
+ raise ValueError("camera returned a non-RTSP stream URI")
259
+ port = self._rtsp_port if "rtsp_port" in c.settings else parts.port or self._rtsp_port
260
+ except CameraMcpError:
261
+ raise
262
+ except Exception as e: # noqa: BLE001 — zeep/aiohttp raise many types
263
+ msg = scrub(mask_url(f"{type(e).__name__}: {e}"), [c.password, c.user])
264
+ raise DeviceError(
265
+ f"{c.id}: could not get the {quality} stream URI over ONVIF ({msg})"
266
+ ) from None
267
+ path = parts.path or "/"
268
+ return port, f"{path}?{parts.query}" if parts.query else path
269
+
270
+ @property
271
+ def _context(self) -> str:
272
+ return f"{self._cfg.id} ({self._cfg.host}:{self._rtsp_port})"
273
+
274
+ def _folder(self) -> Path:
275
+ return ensure_folder(
276
+ self._media_dir, self._cfg.uid, self._cfg.id, self._cfg.scene or self._cfg.id
277
+ )
278
+
279
+ async def snapshot(self, quality: Quality) -> Path:
280
+ url = await self.rtsp_url(quality) # before reserving: a failed lookup leaves no file
281
+ out = media_path(self._folder(), self._cfg.id, "jpg")
282
+ cmd = snapshot_cmd(url, out)
283
+ return await run_ffmpeg(cmd, out, SNAPSHOT_TIMEOUT, self._context, [self._cfg.password])
284
+
285
+ async def record(self, seconds: int, quality: Quality) -> Path:
286
+ if not 1 <= seconds <= MAX_RECORD_SECONDS:
287
+ raise ValueError(f"seconds must be between 1 and {MAX_RECORD_SECONDS}, got {seconds}")
288
+ url = await self.rtsp_url(quality)
289
+ out = media_path(self._folder(), self._cfg.id, "mp4")
290
+ cmd = record_cmd(url, out, seconds)
291
+ timeout = seconds + RECORD_TIMEOUT_EXTRA
292
+ return await run_ffmpeg(cmd, out, timeout, self._context, [self._cfg.password])
293
+
294
+ async def aclose(self) -> None:
295
+ """Close the cached ONVIF client (its HTTP session); the next call reconnects."""
296
+ async with self._connect_lock:
297
+ cam, self._cam = self._cam, None
298
+ self._reset_services()
299
+ if cam is not None:
300
+ # The client is already dropped, so a second aclose has nothing left to close.
301
+ await _close_client(cam)
302
+
303
+ def _reset_services(self) -> None:
304
+ pass
305
+
306
+ # --- ONVIF plumbing --------------------------------------------------------
307
+
308
+ async def _client(self) -> tuple[Any, list[str]]:
309
+ async with self._connect_lock:
310
+ if self._cam is None:
311
+ c = self._cfg
312
+ cam = self._onvif_factory(c.host, self._onvif_port, c.user, c.password)
313
+ await cam.update_xaddrs()
314
+ media = await cam.create_media_service()
315
+ profiles = await media.GetProfiles()
316
+ if not profiles:
317
+ raise DeviceError(f"{c.id}: camera reports no media profiles")
318
+ self._profiles = [str(p.token) for p in profiles]
319
+ self._cam = cam
320
+ return self._cam, self._profiles
321
+
322
+ async def _onvif[T](self, op: Callable[[], Awaitable[T]]) -> T:
323
+ c = self._cfg
324
+ try:
325
+ return await asyncio.wait_for(op(), ONVIF_TIMEOUT)
326
+ except CameraMcpError:
327
+ raise
328
+ except TimeoutError:
329
+ raise DeviceError(
330
+ f"{c.id}: ONVIF timed out after {ONVIF_TIMEOUT:.0f}s "
331
+ f"({c.host}:{self._onvif_port} unreachable?)"
332
+ ) from None
333
+ except Exception as e: # noqa: BLE001 — zeep/aiohttp raise many types
334
+ msg = scrub(f"{type(e).__name__}: {e}", [c.password])
335
+ raise DeviceError(f"{c.id}: ONVIF error: {msg}") from None
336
+
337
+
338
+ class OnvifPtzCamera(OnvifCamera):
339
+ """An ONVIF camera with pan/tilt (and presets)."""
340
+
341
+ # --- PTZ -------------------------------------------------------------------
342
+
343
+ async def move(self, direction: Direction, amount: Amount) -> tuple[float, float] | None:
344
+ vx, vy = VECTORS[direction]
345
+ if self._invert_pan:
346
+ vx = -vx
347
+
348
+ async def op() -> tuple[Any, str]:
349
+ ptz, profile = await self._connect()
350
+ if not self._use_continuous:
351
+ step = RELATIVE_STEPS[amount]
352
+ try:
353
+ await ptz.RelativeMove(
354
+ {
355
+ "ProfileToken": profile,
356
+ "Translation": {"PanTilt": {"x": vx * step, "y": vy * step}},
357
+ }
358
+ )
359
+ except zeep.exceptions.Fault as e:
360
+ # A SOAP fault means the camera rejects RelativeMove; transport errors
361
+ # propagate instead so a network blip doesn't switch modes for good.
362
+ log.info("%s: RelativeMove rejected (%s); using ContinuousMove",
363
+ self._cfg.id, type(e).__name__) # fmt: skip
364
+ self._use_continuous = True
365
+ if self._use_continuous:
366
+ speed = CONTINUOUS_SPEED
367
+ await ptz.ContinuousMove(
368
+ {
369
+ "ProfileToken": profile,
370
+ "Velocity": {"PanTilt": {"x": vx * speed, "y": vy * speed}},
371
+ }
372
+ )
373
+ try:
374
+ await asyncio.sleep(CONTINUOUS_DURATIONS[amount])
375
+ finally:
376
+ # Always stop, even if cancelled, or the motor runs to its end stop.
377
+ await self._stop(ptz, profile)
378
+ return ptz, profile
379
+
380
+ ptz, profile = await self._onvif(op)
381
+ # The move is done; waiting for it to settle has its own budget and never fails it.
382
+ try:
383
+ return await asyncio.wait_for(self._position(ptz, profile), SETTLE_TIMEOUT)
384
+ except TimeoutError:
385
+ log.info("%s: position did not settle within %.0fs", self._cfg.id, SETTLE_TIMEOUT)
386
+ return None
387
+
388
+ async def list_presets(self) -> list[Preset]:
389
+ async def op() -> list[Preset]:
390
+ ptz, profile = await self._connect()
391
+ presets = await ptz.GetPresets({"ProfileToken": profile}) or []
392
+ return [Preset(name=str(p.Name or p.token), token=str(p.token)) for p in presets]
393
+
394
+ return await self._onvif(op)
395
+
396
+ async def _find_preset(self, preset: str) -> Preset:
397
+ presets = await self.list_presets()
398
+ match = next((p for p in presets if preset in (p.name, p.token)), None)
399
+ if match is None:
400
+ available = ", ".join(p.name for p in presets) or "(none)"
401
+ raise DeviceError(f"preset '{preset}' not found; available: {available}")
402
+ return match
403
+
404
+ async def goto_preset(self, preset: str) -> None:
405
+ match = await self._find_preset(preset)
406
+
407
+ async def op() -> None:
408
+ ptz, profile = await self._connect()
409
+ await ptz.GotoPreset({"ProfileToken": profile, "PresetToken": match.token})
410
+
411
+ await self._onvif(op)
412
+
413
+ async def save_preset(self, name: str, overwrite: bool = False) -> Preset:
414
+ if not 1 <= len(name) <= 32 or any(ord(c) < 32 or ord(c) == 127 for c in name):
415
+ raise ValueError("preset name must be 1-32 characters with no control characters")
416
+ existing = next((p for p in await self.list_presets() if p.name == name), None)
417
+ if existing and not overwrite:
418
+ raise DeviceError(f"preset '{name}' already exists; pass overwrite=true to replace it")
419
+
420
+ async def op() -> Preset:
421
+ ptz, profile = await self._connect()
422
+ req = {"ProfileToken": profile, "PresetName": name}
423
+ if existing:
424
+ req["PresetToken"] = existing.token
425
+ token = await ptz.SetPreset(req)
426
+ return Preset(name, str(token))
427
+
428
+ return await self._onvif(op)
429
+
430
+ async def remove_preset(self, preset: str) -> Preset:
431
+ match = await self._find_preset(preset)
432
+
433
+ async def op() -> Preset:
434
+ ptz, profile = await self._connect()
435
+ await ptz.RemovePreset({"ProfileToken": profile, "PresetToken": match.token})
436
+ return match
437
+
438
+ return await self._onvif(op)
439
+
440
+ async def position(self) -> tuple[float, float] | None:
441
+ async def op() -> tuple[float, float] | None:
442
+ ptz, profile = await self._connect()
443
+ try:
444
+ pan_tilt = (await ptz.GetStatus({"ProfileToken": profile})).Position.PanTilt
445
+ return (float(pan_tilt.x), float(pan_tilt.y))
446
+ except Exception: # noqa: BLE001 — position is optional
447
+ return None
448
+
449
+ return await self._onvif(op)
450
+
451
+ def _reset_services(self) -> None:
452
+ self._ptz = None
453
+
454
+ async def _connect(self) -> tuple[Any, str]:
455
+ cam, profiles = await self._client()
456
+ async with self._connect_lock:
457
+ if self._ptz is None:
458
+ self._ptz = await cam.create_ptz_service()
459
+ return self._ptz, profiles[0]
460
+
461
+ async def _stop(self, ptz: Any, profile: str) -> None:
462
+ stop = ptz.Stop({"ProfileToken": profile, "PanTilt": True, "Zoom": False})
463
+ try:
464
+ await asyncio.shield(asyncio.wait_for(stop, STOP_TIMEOUT))
465
+ except Exception as e: # noqa: BLE001 — best effort; report but don't mask the cause
466
+ log.warning("%s: Stop failed (%s)", self._cfg.id, type(e).__name__)
467
+
468
+ @staticmethod
469
+ async def _position(ptz: Any, profile: str) -> tuple[float, float] | None:
470
+ """Wait until the camera stops moving; return its final position if it reports one."""
471
+ last: tuple[float, float] | None = None
472
+ for _ in range(SETTLE_MAX_POLLS):
473
+ try:
474
+ status = await ptz.GetStatus({"ProfileToken": profile})
475
+ pan_tilt = status.Position.PanTilt
476
+ pos = (float(pan_tilt.x), float(pan_tilt.y))
477
+ except Exception: # noqa: BLE001 — position is optional
478
+ return None
479
+ if pos == last:
480
+ return pos
481
+ last = pos
482
+ await asyncio.sleep(SETTLE_POLL)
483
+ return last
ipcam_mcp/baselines.py ADDED
@@ -0,0 +1,100 @@
1
+ """Named reference snapshots for camera_compare, stored in <camera folder>/baselines/."""
2
+
3
+ import contextlib
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ from ipcam_mcp.config import NAME_PATTERN
14
+ from ipcam_mcp.store import parse_time
15
+
16
+ _NAME_RE = re.compile(NAME_PATTERN)
17
+
18
+
19
+ @dataclass
20
+ class Baseline:
21
+ name: str
22
+ path: Path
23
+ taken: datetime
24
+ quality: str
25
+ position: tuple[float, float] | None
26
+ scene: str | None = None # None: saved before scenes existed
27
+
28
+
29
+ def check_name(name: str) -> None:
30
+ if not _NAME_RE.fullmatch(name):
31
+ raise ValueError(
32
+ f"invalid baseline name '{name}': use lowercase letters, digits, '-' or '_' "
33
+ "(1-32 characters, starting with a letter or digit)."
34
+ )
35
+
36
+
37
+ def _dir(folder: Path) -> Path:
38
+ return folder / "baselines"
39
+
40
+
41
+ def _replace_atomic(dest: Path, write) -> None:
42
+ fd, tmp = tempfile.mkstemp(dir=dest.parent, prefix=".tmp-", suffix=dest.suffix)
43
+ os.close(fd)
44
+ try:
45
+ write(Path(tmp))
46
+ os.replace(tmp, dest)
47
+ except BaseException:
48
+ with contextlib.suppress(FileNotFoundError):
49
+ os.unlink(tmp)
50
+ raise
51
+
52
+
53
+ def save_baseline(
54
+ folder: Path,
55
+ name: str,
56
+ snapshot: Path,
57
+ quality: str,
58
+ position: tuple[float, float] | None,
59
+ scene: str | None = None,
60
+ ) -> Baseline:
61
+ check_name(name)
62
+ d = _dir(folder)
63
+ d.mkdir(parents=True, exist_ok=True)
64
+ taken = datetime.now().astimezone()
65
+ meta = {
66
+ "taken": taken.isoformat(timespec="seconds"),
67
+ "quality": quality,
68
+ "position": list(position) if position is not None else None,
69
+ "scene": scene,
70
+ }
71
+ jpg = d / f"{name}.jpg"
72
+ _replace_atomic(jpg, lambda tmp: shutil.copyfile(snapshot, tmp))
73
+ _replace_atomic(d / f"{name}.json", lambda tmp: tmp.write_text(json.dumps(meta)))
74
+ return Baseline(name, jpg, parse_time(meta["taken"]), quality, position, scene)
75
+
76
+
77
+ def list_baselines(folder: Path) -> list[str]:
78
+ d = _dir(folder)
79
+ if not d.is_dir():
80
+ return []
81
+ return sorted(p.stem for p in d.glob("*.json") if (d / f"{p.stem}.jpg").is_file())
82
+
83
+
84
+ def load_baseline(folder: Path, name: str) -> Baseline:
85
+ check_name(name)
86
+ d = _dir(folder)
87
+ jpg, meta_path = d / f"{name}.jpg", d / f"{name}.json"
88
+ if not (jpg.is_file() and meta_path.is_file()):
89
+ available = ", ".join(list_baselines(folder)) or "none"
90
+ raise ValueError(f"baseline '{name}' not found; available: {available}")
91
+ meta = json.loads(meta_path.read_text())
92
+ pos = meta.get("position")
93
+ return Baseline(
94
+ name,
95
+ jpg,
96
+ parse_time(meta["taken"]),
97
+ meta.get("quality", "high"),
98
+ (float(pos[0]), float(pos[1])) if pos else None,
99
+ meta.get("scene"),
100
+ )
@@ -0,0 +1,44 @@
1
+ """Capability protocols that camera adapters implement.
2
+
3
+ Tools are defined per capability, so any adapter implementing ``Camera`` works with
4
+ the ``camera_*`` tools, and so on. Add a new Protocol here for a new camera feature.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Literal, Protocol, runtime_checkable
10
+
11
+ Quality = Literal["high", "low"]
12
+ Direction = Literal["left", "right", "up", "down"]
13
+ Amount = Literal["small", "medium", "large"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Preset:
18
+ name: str
19
+ token: str
20
+
21
+
22
+ @runtime_checkable
23
+ class Camera(Protocol):
24
+ async def snapshot(self, quality: Quality) -> Path: ...
25
+
26
+ async def record(self, seconds: int, quality: Quality) -> Path: ...
27
+
28
+
29
+ @runtime_checkable
30
+ class PTZ(Protocol):
31
+ async def move(self, direction: Direction, amount: Amount) -> tuple[float, float] | None: ...
32
+
33
+ async def list_presets(self) -> list[Preset]: ...
34
+
35
+ async def goto_preset(self, preset: str) -> None: ...
36
+
37
+ async def save_preset(self, name: str, overwrite: bool = False) -> Preset: ...
38
+
39
+ async def remove_preset(self, preset: str) -> Preset: ...
40
+
41
+ async def position(self) -> tuple[float, float] | None: ...
42
+
43
+
44
+ CAPABILITIES: dict[str, type] = {"camera": Camera, "ptz": PTZ}