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 +0 -0
- ipcam_mcp/adapters/__init__.py +1 -0
- ipcam_mcp/adapters/onvif.py +483 -0
- ipcam_mcp/baselines.py +100 -0
- ipcam_mcp/capabilities.py +44 -0
- ipcam_mcp/config.py +152 -0
- ipcam_mcp/config_writer.py +179 -0
- ipcam_mcp/errors.py +34 -0
- ipcam_mcp/imaging.py +142 -0
- ipcam_mcp/management.py +792 -0
- ipcam_mcp/media.py +136 -0
- ipcam_mcp/migrate.py +157 -0
- ipcam_mcp/registry.py +136 -0
- ipcam_mcp/scene_tools.py +116 -0
- ipcam_mcp/scenes.py +304 -0
- ipcam_mcp/server.py +625 -0
- ipcam_mcp/store.py +325 -0
- ipcam_mcp/tooling.py +34 -0
- ipcam_mcp-0.3.2.dist-info/METADATA +342 -0
- ipcam_mcp-0.3.2.dist-info/RECORD +23 -0
- ipcam_mcp-0.3.2.dist-info/WHEEL +4 -0
- ipcam_mcp-0.3.2.dist-info/entry_points.txt +3 -0
- ipcam_mcp-0.3.2.dist-info/licenses/LICENSE +21 -0
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}
|