agent-core-webcam 0.8.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.
- agent_core_webcam/__init__.py +1 -0
- agent_core_webcam/audit.py +50 -0
- agent_core_webcam/endpoint.py +419 -0
- agent_core_webcam/fake.py +142 -0
- agent_core_webcam/mcp.py +96 -0
- agent_core_webcam/opencv_backend.py +107 -0
- agent_core_webcam/plugin.py +87 -0
- agent_core_webcam/protocol.py +67 -0
- agent_core_webcam-0.8.2.dist-info/METADATA +11 -0
- agent_core_webcam-0.8.2.dist-info/RECORD +12 -0
- agent_core_webcam-0.8.2.dist-info/WHEEL +4 -0
- agent_core_webcam-0.8.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""agent-core-webcam — Webcam capture endpoint for agent_core."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Append-only JSONL audit log for webcam tool invocations.
|
|
2
|
+
|
|
3
|
+
Each ``capture_webcam_frame`` and ``list_cameras`` call writes one line.
|
|
4
|
+
Schema is documented in the design spec. Failures are swallowed so an
|
|
5
|
+
audit failure never breaks a capture.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from agent_core.audit import JsonlAuditLog
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AuditEvent:
|
|
21
|
+
"""One line in the webcam audit log."""
|
|
22
|
+
|
|
23
|
+
timestamp: datetime
|
|
24
|
+
tool: str
|
|
25
|
+
result: str # "ok" | "error"
|
|
26
|
+
data: dict[str, Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuditLog(JsonlAuditLog[AuditEvent]):
|
|
30
|
+
"""Append-only JSONL audit log."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, path: Path) -> None:
|
|
33
|
+
super().__init__(path)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def default_path(endpoint_name: str) -> Path:
|
|
37
|
+
"""Returns ``~/.agent-core/webcam/<endpoint_name>/audit.jsonl``."""
|
|
38
|
+
return Path.home() / ".agent-core" / "webcam" / endpoint_name / "audit.jsonl"
|
|
39
|
+
|
|
40
|
+
def _serialize(self, event: AuditEvent) -> str:
|
|
41
|
+
payload = {
|
|
42
|
+
"timestamp": event.timestamp.isoformat(),
|
|
43
|
+
"tool": event.tool,
|
|
44
|
+
"result": event.result,
|
|
45
|
+
"data": event.data,
|
|
46
|
+
}
|
|
47
|
+
return json.dumps(payload, default=str, ensure_ascii=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["AuditEvent", "AuditLog"]
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""WebcamEndpoint — bus endpoint that exposes capture tools via MCP.
|
|
2
|
+
|
|
3
|
+
Implements the standard Endpoint protocol but ``deliver`` is a no-op:
|
|
4
|
+
webcam is tool-only, no inbox, no agent-to-agent envelopes. The
|
|
5
|
+
endpoint exists so MCP tools have somewhere to live and config to read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from agent_core_webcam.audit import AuditEvent, AuditLog
|
|
18
|
+
from agent_core_webcam.protocol import (
|
|
19
|
+
CameraBackend,
|
|
20
|
+
CameraBusyError,
|
|
21
|
+
CameraNotFoundError,
|
|
22
|
+
ReadTimeoutError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from agent_core.bus.envelope import Envelope
|
|
27
|
+
from agent_core.bus.handle import BusHandle
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _to_tuple(value: tuple[int, int] | list[int]) -> tuple[int, int]:
|
|
33
|
+
if isinstance(value, tuple):
|
|
34
|
+
if len(value) != 2:
|
|
35
|
+
raise ValueError(f"resolution must be [width, height], got {value!r}")
|
|
36
|
+
return value
|
|
37
|
+
if isinstance(value, list) and len(value) == 2:
|
|
38
|
+
return (int(value[0]), int(value[1]))
|
|
39
|
+
raise ValueError(f"resolution must be [width, height], got {value!r}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CaptureSuccess:
|
|
44
|
+
"""Successful capture result."""
|
|
45
|
+
|
|
46
|
+
png_bytes: bytes
|
|
47
|
+
file_path: Path | None
|
|
48
|
+
metadata: dict
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class CaptureError:
|
|
53
|
+
"""Failed capture — message is the agent-readable string."""
|
|
54
|
+
|
|
55
|
+
message: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ListCamerasSuccess:
|
|
60
|
+
"""list_cameras_safe success."""
|
|
61
|
+
|
|
62
|
+
cameras: list # list[CameraInfo] — avoid forward-ref import dance
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ListCamerasError:
|
|
67
|
+
"""list_cameras_safe failure — message is the agent-readable string."""
|
|
68
|
+
|
|
69
|
+
message: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class WebcamEndpoint:
|
|
73
|
+
"""Tool-only bus endpoint backing the webcam MCP tool surface."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
name: str,
|
|
79
|
+
captures_root: Path | str | None = None,
|
|
80
|
+
audit_log_path: Path | str | None = None,
|
|
81
|
+
default_camera_index: int = 0,
|
|
82
|
+
default_resolution: tuple[int, int] | list[int] = (1280, 720),
|
|
83
|
+
max_resolution: tuple[int, int] | list[int] = (3840, 2160),
|
|
84
|
+
capture_timeout_seconds: float = 3.0,
|
|
85
|
+
enabled: bool = True,
|
|
86
|
+
camera_backend: CameraBackend | None = None,
|
|
87
|
+
):
|
|
88
|
+
self.name = name
|
|
89
|
+
self.captures_root = (
|
|
90
|
+
Path(captures_root)
|
|
91
|
+
if captures_root is not None
|
|
92
|
+
else Path.home() / ".agent-core" / "webcam" / name
|
|
93
|
+
)
|
|
94
|
+
audit_path = (
|
|
95
|
+
Path(audit_log_path)
|
|
96
|
+
if audit_log_path is not None
|
|
97
|
+
else self.captures_root / "audit.jsonl"
|
|
98
|
+
)
|
|
99
|
+
self.audit_log = AuditLog(audit_path)
|
|
100
|
+
self.default_camera_index = default_camera_index
|
|
101
|
+
self.default_resolution = _to_tuple(default_resolution)
|
|
102
|
+
self.max_resolution = _to_tuple(max_resolution)
|
|
103
|
+
self.capture_timeout_seconds = capture_timeout_seconds
|
|
104
|
+
self.enabled = enabled
|
|
105
|
+
if camera_backend is None:
|
|
106
|
+
from agent_core_webcam.opencv_backend import OpenCVCameraBackend
|
|
107
|
+
camera_backend = OpenCVCameraBackend(timeout_seconds=capture_timeout_seconds)
|
|
108
|
+
self._backend: CameraBackend = camera_backend
|
|
109
|
+
self._handle: BusHandle | None = None
|
|
110
|
+
self._camera_locks: dict[int, asyncio.Lock] = {}
|
|
111
|
+
self._camera_locks_guard = asyncio.Lock()
|
|
112
|
+
|
|
113
|
+
async def start(self, bus: BusHandle) -> None:
|
|
114
|
+
self._handle = bus
|
|
115
|
+
log.info("WebcamEndpoint(name=%s) started; captures=%s", self.name, self.captures_root)
|
|
116
|
+
|
|
117
|
+
async def deliver(self, envelope: Envelope) -> None:
|
|
118
|
+
# Webcam is tool-only; envelopes addressed to us are unexpected.
|
|
119
|
+
# Log at debug, then ack so the bus doesn't redeliver or dead-letter.
|
|
120
|
+
log.debug(
|
|
121
|
+
"WebcamEndpoint(name=%s) ignoring delivered envelope %s", self.name, envelope.id
|
|
122
|
+
)
|
|
123
|
+
if self._handle is not None:
|
|
124
|
+
await self._handle.ack(envelope.id)
|
|
125
|
+
|
|
126
|
+
async def stop(self) -> None:
|
|
127
|
+
self._handle = None
|
|
128
|
+
log.info("WebcamEndpoint(name=%s) stopped", self.name)
|
|
129
|
+
|
|
130
|
+
async def _lock_for(self, camera_index: int) -> asyncio.Lock:
|
|
131
|
+
async with self._camera_locks_guard:
|
|
132
|
+
if camera_index not in self._camera_locks:
|
|
133
|
+
self._camera_locks[camera_index] = asyncio.Lock()
|
|
134
|
+
return self._camera_locks[camera_index]
|
|
135
|
+
|
|
136
|
+
async def capture_frame(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
camera_index: int | None = None,
|
|
140
|
+
resolution: tuple[int, int] | list[int] | None = None,
|
|
141
|
+
save: bool = True,
|
|
142
|
+
note: str | None = None,
|
|
143
|
+
) -> tuple[bytes, Path | None, dict]:
|
|
144
|
+
"""Capture one frame; return (png_bytes, file_path, metadata).
|
|
145
|
+
|
|
146
|
+
Returns ``file_path=None`` when ``save=False``. Always appends an
|
|
147
|
+
audit entry. Errors raise — Task 7 maps them to user-facing
|
|
148
|
+
messages at the MCP boundary.
|
|
149
|
+
"""
|
|
150
|
+
idx = camera_index if camera_index is not None else self.default_camera_index
|
|
151
|
+
res = _to_tuple(resolution) if resolution is not None else self.default_resolution
|
|
152
|
+
lock = await self._lock_for(idx)
|
|
153
|
+
async with lock:
|
|
154
|
+
# Enforce capture_timeout_seconds at the async layer: a hung
|
|
155
|
+
# backend read (OpenCV's cap.read() is uninterruptible) would
|
|
156
|
+
# otherwise hold this per-camera lock forever, wedging every future
|
|
157
|
+
# capture on this camera. wait_for releases the lock and surfaces a
|
|
158
|
+
# ReadTimeoutError; the orphaned worker thread is the lesser evil
|
|
159
|
+
# than a permanently-stuck endpoint.
|
|
160
|
+
try:
|
|
161
|
+
png_bytes = await asyncio.wait_for(
|
|
162
|
+
asyncio.to_thread(self._backend.capture, idx, res),
|
|
163
|
+
timeout=self.capture_timeout_seconds,
|
|
164
|
+
)
|
|
165
|
+
except TimeoutError as exc:
|
|
166
|
+
raise ReadTimeoutError(
|
|
167
|
+
f"camera {idx} did not return a frame within "
|
|
168
|
+
f"{self.capture_timeout_seconds}s"
|
|
169
|
+
) from exc
|
|
170
|
+
timestamp = datetime.now(UTC).astimezone()
|
|
171
|
+
try:
|
|
172
|
+
all_cams = await asyncio.to_thread(self._backend.list_cameras)
|
|
173
|
+
cam_name = next((c.name for c in all_cams if c.index == idx), f"camera {idx}")
|
|
174
|
+
except Exception:
|
|
175
|
+
cam_name = f"camera {idx}"
|
|
176
|
+
file_path: Path | None = None
|
|
177
|
+
if save:
|
|
178
|
+
file_path = self.captures_root / timestamp.strftime("%Y-%m-%d") / (
|
|
179
|
+
timestamp.strftime("%H%M%S-") + f"{timestamp.microsecond // 1000:03d}.png"
|
|
180
|
+
)
|
|
181
|
+
await asyncio.to_thread(self._write_png, file_path, png_bytes)
|
|
182
|
+
meta = {
|
|
183
|
+
"camera_index": idx,
|
|
184
|
+
"camera_name": cam_name,
|
|
185
|
+
"resolution": res,
|
|
186
|
+
"timestamp": timestamp.isoformat(),
|
|
187
|
+
"filesize": len(png_bytes),
|
|
188
|
+
"file_path": str(file_path) if file_path else None,
|
|
189
|
+
}
|
|
190
|
+
await self.audit_log.write(
|
|
191
|
+
AuditEvent(
|
|
192
|
+
timestamp=timestamp,
|
|
193
|
+
tool="capture_webcam_frame",
|
|
194
|
+
result="ok",
|
|
195
|
+
data={
|
|
196
|
+
"camera_index": idx,
|
|
197
|
+
"camera_name": cam_name,
|
|
198
|
+
"resolution": list(res),
|
|
199
|
+
"save": save,
|
|
200
|
+
"note": note,
|
|
201
|
+
"file_path": str(file_path) if file_path else None,
|
|
202
|
+
"filesize": len(png_bytes),
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
return png_bytes, file_path, meta
|
|
207
|
+
|
|
208
|
+
async def capture_frame_safe(
|
|
209
|
+
self,
|
|
210
|
+
*,
|
|
211
|
+
camera_index: int | None = None,
|
|
212
|
+
resolution: tuple[int, int] | list[int] | None = None,
|
|
213
|
+
save: bool = True,
|
|
214
|
+
note: str | None = None,
|
|
215
|
+
) -> CaptureSuccess | CaptureError:
|
|
216
|
+
"""Like ``capture_frame`` but maps every error to a CaptureError + audit.
|
|
217
|
+
|
|
218
|
+
The MCP tool wrapper uses this — it never wants to raise into FastMCP.
|
|
219
|
+
"""
|
|
220
|
+
if not self.enabled:
|
|
221
|
+
return await self._error(
|
|
222
|
+
user_message=(
|
|
223
|
+
"error: webcam endpoint is disabled (enabled=false in config). "
|
|
224
|
+
"Ask the operator if this is unexpected."
|
|
225
|
+
),
|
|
226
|
+
audit_error="endpoint disabled",
|
|
227
|
+
camera_index=camera_index,
|
|
228
|
+
resolution=_to_tuple(resolution) if resolution is not None else None,
|
|
229
|
+
save=save,
|
|
230
|
+
note=note,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Resolution validation + cap.
|
|
234
|
+
idx = camera_index if camera_index is not None else self.default_camera_index
|
|
235
|
+
try:
|
|
236
|
+
res = _to_tuple(resolution) if resolution is not None else self.default_resolution
|
|
237
|
+
except ValueError as exc:
|
|
238
|
+
return await self._error(
|
|
239
|
+
user_message=(
|
|
240
|
+
f"error: invalid resolution: {exc}. "
|
|
241
|
+
f"Expected [width, height] (a list of two ints)."
|
|
242
|
+
),
|
|
243
|
+
audit_error=f"invalid resolution: {exc}",
|
|
244
|
+
camera_index=idx,
|
|
245
|
+
save=save,
|
|
246
|
+
note=note,
|
|
247
|
+
)
|
|
248
|
+
if res[0] > self.max_resolution[0] or res[1] > self.max_resolution[1]:
|
|
249
|
+
return await self._error(
|
|
250
|
+
user_message=(
|
|
251
|
+
f"error: requested resolution {res[0]}x{res[1]} exceeds "
|
|
252
|
+
f"configured max {self.max_resolution[0]}x{self.max_resolution[1]}."
|
|
253
|
+
),
|
|
254
|
+
audit_error="resolution capped",
|
|
255
|
+
camera_index=idx,
|
|
256
|
+
resolution=res,
|
|
257
|
+
save=save,
|
|
258
|
+
note=note,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
png_bytes, file_path, meta = await self.capture_frame(
|
|
263
|
+
camera_index=idx,
|
|
264
|
+
resolution=res,
|
|
265
|
+
save=save,
|
|
266
|
+
note=note,
|
|
267
|
+
)
|
|
268
|
+
except CameraNotFoundError:
|
|
269
|
+
try:
|
|
270
|
+
cams = await asyncio.to_thread(self._backend.list_cameras)
|
|
271
|
+
available = [c.index for c in cams]
|
|
272
|
+
except Exception:
|
|
273
|
+
available = []
|
|
274
|
+
return await self._error(
|
|
275
|
+
user_message=(
|
|
276
|
+
f"error: no camera at index {idx} "
|
|
277
|
+
f"(host has {len(available)} cameras: indices {available}). "
|
|
278
|
+
f"Call list_cameras to see what's available."
|
|
279
|
+
),
|
|
280
|
+
audit_error=f"camera {idx} not found",
|
|
281
|
+
camera_index=idx,
|
|
282
|
+
resolution=res,
|
|
283
|
+
save=save,
|
|
284
|
+
note=note,
|
|
285
|
+
)
|
|
286
|
+
except CameraBusyError:
|
|
287
|
+
try:
|
|
288
|
+
cams = await asyncio.to_thread(self._backend.list_cameras)
|
|
289
|
+
cam_name = next(
|
|
290
|
+
(c.name for c in cams if c.index == idx),
|
|
291
|
+
f"camera {idx}",
|
|
292
|
+
)
|
|
293
|
+
except Exception:
|
|
294
|
+
cam_name = f"camera {idx}"
|
|
295
|
+
return await self._error(
|
|
296
|
+
user_message=(
|
|
297
|
+
f"error: camera {idx} ({cam_name}) is busy. "
|
|
298
|
+
f"Likely in use by another application (Zoom, browser, etc.). "
|
|
299
|
+
f"Try again in a moment."
|
|
300
|
+
),
|
|
301
|
+
audit_error="camera busy",
|
|
302
|
+
camera_index=idx,
|
|
303
|
+
resolution=res,
|
|
304
|
+
save=save,
|
|
305
|
+
note=note,
|
|
306
|
+
)
|
|
307
|
+
except ReadTimeoutError:
|
|
308
|
+
return await self._error(
|
|
309
|
+
user_message=(
|
|
310
|
+
f"error: camera opened but returned no frame within "
|
|
311
|
+
f"{self.capture_timeout_seconds}s. Camera may be initializing or obstructed."
|
|
312
|
+
),
|
|
313
|
+
audit_error="read timeout",
|
|
314
|
+
camera_index=idx,
|
|
315
|
+
resolution=res,
|
|
316
|
+
save=save,
|
|
317
|
+
note=note,
|
|
318
|
+
)
|
|
319
|
+
except OSError as exc:
|
|
320
|
+
return await self._error(
|
|
321
|
+
user_message=(
|
|
322
|
+
f"error: capture succeeded but failed to write to disk: {exc}. "
|
|
323
|
+
f"Image is unavailable; retry or set save=false."
|
|
324
|
+
),
|
|
325
|
+
audit_error=f"disk write failed: {exc}",
|
|
326
|
+
camera_index=idx,
|
|
327
|
+
resolution=res,
|
|
328
|
+
save=save,
|
|
329
|
+
note=note,
|
|
330
|
+
)
|
|
331
|
+
return CaptureSuccess(png_bytes=png_bytes, file_path=file_path, metadata=meta)
|
|
332
|
+
|
|
333
|
+
async def list_cameras_safe(self) -> ListCamerasSuccess | ListCamerasError:
|
|
334
|
+
"""Enumerate cameras, return list with audit. Best-effort — never raises."""
|
|
335
|
+
if not self.enabled:
|
|
336
|
+
timestamp = datetime.now(UTC).astimezone()
|
|
337
|
+
await self.audit_log.write(
|
|
338
|
+
AuditEvent(
|
|
339
|
+
timestamp=timestamp,
|
|
340
|
+
tool="list_cameras",
|
|
341
|
+
result="error",
|
|
342
|
+
data={"error": "endpoint disabled"},
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
return ListCamerasError(
|
|
346
|
+
message=(
|
|
347
|
+
"error: webcam endpoint is disabled (enabled=false in config). "
|
|
348
|
+
"Ask the operator if this is unexpected."
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
try:
|
|
352
|
+
cams = await asyncio.to_thread(self._backend.list_cameras)
|
|
353
|
+
except Exception as exc:
|
|
354
|
+
timestamp = datetime.now(UTC).astimezone()
|
|
355
|
+
await self.audit_log.write(
|
|
356
|
+
AuditEvent(
|
|
357
|
+
timestamp=timestamp,
|
|
358
|
+
tool="list_cameras",
|
|
359
|
+
result="error",
|
|
360
|
+
data={"error": f"backend list_cameras failed: {exc}"},
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
return ListCamerasError(
|
|
364
|
+
message=(
|
|
365
|
+
f"error: failed to enumerate cameras: {exc}. "
|
|
366
|
+
f"Check that the camera driver is responsive."
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
timestamp = datetime.now(UTC).astimezone()
|
|
370
|
+
await self.audit_log.write(
|
|
371
|
+
AuditEvent(
|
|
372
|
+
timestamp=timestamp,
|
|
373
|
+
tool="list_cameras",
|
|
374
|
+
result="ok",
|
|
375
|
+
data={"camera_count": len(cams)},
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
return ListCamerasSuccess(cameras=list(cams))
|
|
379
|
+
|
|
380
|
+
async def _error(
|
|
381
|
+
self,
|
|
382
|
+
*,
|
|
383
|
+
user_message: str,
|
|
384
|
+
audit_error: str,
|
|
385
|
+
camera_index: int | None = None,
|
|
386
|
+
resolution: tuple[int, int] | None = None,
|
|
387
|
+
save: bool = True,
|
|
388
|
+
note: str | None = None,
|
|
389
|
+
) -> CaptureError:
|
|
390
|
+
timestamp = datetime.now(UTC).astimezone()
|
|
391
|
+
await self.audit_log.write(
|
|
392
|
+
AuditEvent(
|
|
393
|
+
timestamp=timestamp,
|
|
394
|
+
tool="capture_webcam_frame",
|
|
395
|
+
result="error",
|
|
396
|
+
data={
|
|
397
|
+
"camera_index": camera_index,
|
|
398
|
+
"resolution": list(resolution) if resolution else None,
|
|
399
|
+
"save": save,
|
|
400
|
+
"note": note,
|
|
401
|
+
"error": audit_error,
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
return CaptureError(message=user_message)
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _write_png(path: Path, data: bytes) -> None:
|
|
409
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
path.write_bytes(data)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
__all__ = [
|
|
414
|
+
"CaptureError",
|
|
415
|
+
"CaptureSuccess",
|
|
416
|
+
"ListCamerasError",
|
|
417
|
+
"ListCamerasSuccess",
|
|
418
|
+
"WebcamEndpoint",
|
|
419
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Deterministic camera backend for tests.
|
|
2
|
+
|
|
3
|
+
Mirrors ``OpenCVCameraBackend``'s behavior strictly — including refusing
|
|
4
|
+
argument shapes the real backend refuses (unknown camera index →
|
|
5
|
+
``CameraNotFoundError``). Produces minimal valid PNGs without any
|
|
6
|
+
imaging work, so tests stay fast.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import struct
|
|
12
|
+
import zlib
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from agent_core_webcam.protocol import (
|
|
17
|
+
CameraBusyError,
|
|
18
|
+
CameraInfo,
|
|
19
|
+
CameraNotFoundError,
|
|
20
|
+
ReadTimeoutError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FluentMode:
|
|
25
|
+
"""Descriptor that makes a mode-setter work both as a classmethod
|
|
26
|
+
(``FakeCameraBackend.with_busy(0)`` creates a fresh instance) and as
|
|
27
|
+
an instance method (``fake.with_busy(0)`` mutates + returns the
|
|
28
|
+
existing instance), so call-chains like
|
|
29
|
+
``FakeCameraBackend.with_cameras([0,1]).with_busy(0)`` compose
|
|
30
|
+
correctly.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, attr: str) -> None:
|
|
34
|
+
self._attr = attr
|
|
35
|
+
|
|
36
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
37
|
+
self._name = name
|
|
38
|
+
|
|
39
|
+
def __get__(self, obj: Any, objtype: type | None = None) -> Any:
|
|
40
|
+
attr = self._attr
|
|
41
|
+
|
|
42
|
+
if obj is None:
|
|
43
|
+
# Called on the class: FakeCameraBackend.with_busy(0)
|
|
44
|
+
def class_call(index: int) -> FakeCameraBackend:
|
|
45
|
+
inst = FakeCameraBackend()
|
|
46
|
+
getattr(inst, attr).add(index)
|
|
47
|
+
return inst
|
|
48
|
+
|
|
49
|
+
return class_call
|
|
50
|
+
else:
|
|
51
|
+
# Called on an instance: fake.with_busy(0)
|
|
52
|
+
def instance_call(index: int) -> FakeCameraBackend:
|
|
53
|
+
getattr(obj, attr).add(index)
|
|
54
|
+
return obj
|
|
55
|
+
|
|
56
|
+
return instance_call
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class FakeCameraBackend:
|
|
61
|
+
"""In-memory camera backend with composable failure modes.
|
|
62
|
+
|
|
63
|
+
Use the classmethods (``with_cameras``, ``with_busy``, etc.) to
|
|
64
|
+
configure modes. Each returns a new or existing ``FakeCameraBackend``
|
|
65
|
+
so test setup reads naturally.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
available_indices: list[int] = field(default_factory=lambda: [0])
|
|
69
|
+
_busy: set[int] = field(default_factory=set)
|
|
70
|
+
_missing: set[int] = field(default_factory=set)
|
|
71
|
+
_timeout: set[int] = field(default_factory=set)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def with_cameras(cls, indices: list[int]) -> FakeCameraBackend:
|
|
75
|
+
return cls(available_indices=list(indices))
|
|
76
|
+
|
|
77
|
+
# Each _FluentMode below must have a matching `set[int]` field in the dataclass.
|
|
78
|
+
# Adding a new `with_X = _FluentMode("_X")` without also declaring
|
|
79
|
+
# `_X: set[int] = field(default_factory=set)` above will raise AttributeError
|
|
80
|
+
# on first use.
|
|
81
|
+
with_busy = _FluentMode("_busy")
|
|
82
|
+
with_missing = _FluentMode("_missing")
|
|
83
|
+
with_read_timeout = _FluentMode("_timeout")
|
|
84
|
+
|
|
85
|
+
def list_cameras(self) -> list[CameraInfo]:
|
|
86
|
+
"""List available cameras with their status.
|
|
87
|
+
|
|
88
|
+
Cameras marked `with_missing(idx)` where idx is NOT in `available_indices`
|
|
89
|
+
are NOT enumerated — "missing" means "this index doesn't exist on the host,"
|
|
90
|
+
so the fake mirrors real OpenCV's silence about indices it cannot open.
|
|
91
|
+
Cameras marked `with_busy(idx)` where idx IS in `available_indices` DO
|
|
92
|
+
appear in the list with `available=False` — "busy" means "exists but currently
|
|
93
|
+
in use elsewhere."
|
|
94
|
+
"""
|
|
95
|
+
out: list[CameraInfo] = []
|
|
96
|
+
for idx in self.available_indices:
|
|
97
|
+
available = idx not in self._busy and idx not in self._missing
|
|
98
|
+
out.append(CameraInfo(index=idx, name=f"Fake Camera {idx}", available=available))
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
def capture(self, index: int, resolution: tuple[int, int]) -> bytes:
|
|
102
|
+
if index in self._missing:
|
|
103
|
+
raise CameraNotFoundError(f"camera {index} marked missing")
|
|
104
|
+
if index not in self.available_indices:
|
|
105
|
+
raise CameraNotFoundError(f"camera {index} not in configured indices")
|
|
106
|
+
if index in self._busy:
|
|
107
|
+
raise CameraBusyError(f"camera {index} is busy")
|
|
108
|
+
if index in self._timeout:
|
|
109
|
+
raise ReadTimeoutError(f"camera {index} did not return a frame in time")
|
|
110
|
+
return _make_minimal_png(resolution)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _make_minimal_png(resolution: tuple[int, int]) -> bytes:
|
|
114
|
+
"""Produce a valid solid-gray PNG of the requested resolution.
|
|
115
|
+
|
|
116
|
+
Uses pure-Python PNG construction (no PIL/cv2 dependency) so the
|
|
117
|
+
fake stays light. The image is solid mid-gray RGB; tests only need
|
|
118
|
+
the bytes to be a valid PNG with the right dimensions.
|
|
119
|
+
"""
|
|
120
|
+
width, height = resolution
|
|
121
|
+
# IHDR: width, height, bit depth=8, color type=2 (RGB), compression, filter, interlace
|
|
122
|
+
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
|
123
|
+
raw = b""
|
|
124
|
+
row_bytes = width * 3
|
|
125
|
+
for _ in range(height):
|
|
126
|
+
raw += b"\x00" + (b"\x80" * row_bytes) # filter byte + gray pixels
|
|
127
|
+
idat = zlib.compress(raw, 9)
|
|
128
|
+
return (
|
|
129
|
+
b"\x89PNG\r\n\x1a\n"
|
|
130
|
+
+ _png_chunk(b"IHDR", ihdr)
|
|
131
|
+
+ _png_chunk(b"IDAT", idat)
|
|
132
|
+
+ _png_chunk(b"IEND", b"")
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _png_chunk(tag: bytes, data: bytes) -> bytes:
|
|
137
|
+
length = struct.pack(">I", len(data))
|
|
138
|
+
crc = struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
|
139
|
+
return length + tag + data + crc
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
__all__ = ["FakeCameraBackend"]
|
agent_core_webcam/mcp.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Agent-facing MCP tool surface for the webcam endpoint.
|
|
2
|
+
|
|
3
|
+
Two tools: ``capture_webcam_frame`` and ``list_cameras``. Each returns
|
|
4
|
+
a list of MCP content blocks. On error, only TextContent is returned —
|
|
5
|
+
no image. The endpoint's ``capture_frame_safe`` / ``list_cameras_safe``
|
|
6
|
+
methods do the error mapping; this module just adapts the result type
|
|
7
|
+
to MCP content blocks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from mcp.types import ImageContent, TextContent
|
|
17
|
+
|
|
18
|
+
from agent_core_webcam.endpoint import (
|
|
19
|
+
CaptureError,
|
|
20
|
+
CaptureSuccess,
|
|
21
|
+
ListCamerasError,
|
|
22
|
+
ListCamerasSuccess,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from fastmcp import FastMCP
|
|
27
|
+
|
|
28
|
+
from agent_core_webcam.endpoint import WebcamEndpoint
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register_webcam_tools(*, mcp: FastMCP, endpoint: WebcamEndpoint) -> None:
|
|
32
|
+
"""Register the two webcam tools on a FastMCP server."""
|
|
33
|
+
|
|
34
|
+
@mcp.tool(
|
|
35
|
+
name="capture_webcam_frame",
|
|
36
|
+
description=(
|
|
37
|
+
"Capture a single frame from a connected webcam. Returns the image "
|
|
38
|
+
"inline (so you can see it immediately) plus a saved file path you "
|
|
39
|
+
"can later attach to a Discord message, archive, or revisit. Use "
|
|
40
|
+
"camera_index=0 for the default camera; call list_cameras to "
|
|
41
|
+
"enumerate other devices."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
async def _capture(
|
|
45
|
+
camera_index: int = 0,
|
|
46
|
+
resolution: list[int] | None = None,
|
|
47
|
+
save: bool = True,
|
|
48
|
+
note: str | None = None,
|
|
49
|
+
) -> list[Any]:
|
|
50
|
+
"""Capture one frame; return [ImageContent, TextContent] on success."""
|
|
51
|
+
result = await endpoint.capture_frame_safe(
|
|
52
|
+
camera_index=camera_index,
|
|
53
|
+
resolution=list(resolution) if resolution else None,
|
|
54
|
+
save=save,
|
|
55
|
+
note=note,
|
|
56
|
+
)
|
|
57
|
+
if isinstance(result, CaptureError):
|
|
58
|
+
return [TextContent(type="text", text=result.message)]
|
|
59
|
+
assert isinstance(result, CaptureSuccess)
|
|
60
|
+
meta = result.metadata
|
|
61
|
+
text = (
|
|
62
|
+
f"Captured frame from camera {meta['camera_index']} ({meta['camera_name']}) "
|
|
63
|
+
f"at {meta['resolution'][0]}x{meta['resolution'][1]}.\n"
|
|
64
|
+
f"Path: {meta['file_path']}\n"
|
|
65
|
+
f"Timestamp: {meta['timestamp']}\n"
|
|
66
|
+
f"Filesize: {meta['filesize']} bytes"
|
|
67
|
+
)
|
|
68
|
+
return [
|
|
69
|
+
ImageContent(
|
|
70
|
+
type="image",
|
|
71
|
+
data=base64.b64encode(result.png_bytes).decode("ascii"),
|
|
72
|
+
mimeType="image/png",
|
|
73
|
+
),
|
|
74
|
+
TextContent(type="text", text=text),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
@mcp.tool(
|
|
78
|
+
name="list_cameras",
|
|
79
|
+
description=(
|
|
80
|
+
"List all webcams detected on this host. Use the returned `index` "
|
|
81
|
+
"as the `camera_index` argument to capture_webcam_frame."
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
async def _list_cameras() -> list[Any]:
|
|
85
|
+
result = await endpoint.list_cameras_safe()
|
|
86
|
+
if isinstance(result, ListCamerasError):
|
|
87
|
+
return [TextContent(type="text", text=result.message)]
|
|
88
|
+
assert isinstance(result, ListCamerasSuccess)
|
|
89
|
+
payload = [
|
|
90
|
+
{"index": c.index, "name": c.name, "available": c.available}
|
|
91
|
+
for c in result.cameras
|
|
92
|
+
]
|
|
93
|
+
return [TextContent(type="text", text=json.dumps(payload))]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ["register_webcam_tools"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Real-OpenCV CameraBackend.
|
|
2
|
+
|
|
3
|
+
Imports cv2 lazily inside method bodies so test environments that inject
|
|
4
|
+
a fake backend never need cv2 at module-import time. cv2's failure modes
|
|
5
|
+
are mapped onto our protocol exception taxonomy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from agent_core_webcam.protocol import (
|
|
11
|
+
CameraBackend,
|
|
12
|
+
CameraBusyError,
|
|
13
|
+
CameraInfo,
|
|
14
|
+
CameraNotFoundError,
|
|
15
|
+
WebcamError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Probe up to this many camera indices when listing. Most consumer hosts
|
|
19
|
+
# have 0–2 cameras; 8 is a generous ceiling without making list_cameras
|
|
20
|
+
# pay 100ms-each-failed-open across hundreds of indices.
|
|
21
|
+
_MAX_PROBE_INDEX = 8
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OpenCVCameraBackend:
|
|
25
|
+
"""cv2-backed CameraBackend.
|
|
26
|
+
|
|
27
|
+
Open + release per call. No long-lived state — the OS hardware LED
|
|
28
|
+
reflects "agent is looking right now" honestly.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, timeout_seconds: float = 3.0):
|
|
32
|
+
self._timeout = timeout_seconds
|
|
33
|
+
|
|
34
|
+
def list_cameras(self) -> list[CameraInfo]:
|
|
35
|
+
import cv2 # lazy import so cv2 isn't required by importing this module
|
|
36
|
+
|
|
37
|
+
out: list[CameraInfo] = []
|
|
38
|
+
for idx in range(_MAX_PROBE_INDEX):
|
|
39
|
+
cap = cv2.VideoCapture(idx)
|
|
40
|
+
if cap is None:
|
|
41
|
+
continue
|
|
42
|
+
opened = cap.isOpened()
|
|
43
|
+
if not opened:
|
|
44
|
+
cap.release()
|
|
45
|
+
# No camera at this index — stop probing further indices.
|
|
46
|
+
# On Windows DirectShow, indices are dense from 0 upward.
|
|
47
|
+
if idx == 0:
|
|
48
|
+
return []
|
|
49
|
+
break
|
|
50
|
+
name = self._best_effort_name(cap, idx)
|
|
51
|
+
cap.release()
|
|
52
|
+
out.append(CameraInfo(index=idx, name=name, available=True))
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _best_effort_name(cap, idx: int) -> str:
|
|
57
|
+
# cv2 doesn't expose device names cross-platform. On Windows
|
|
58
|
+
# CAP_DSHOW we could probe via WMI but that's a rabbit hole.
|
|
59
|
+
# Fall back to a generic label.
|
|
60
|
+
return f"Camera {idx}"
|
|
61
|
+
|
|
62
|
+
def capture(self, index: int, resolution: tuple[int, int]) -> bytes:
|
|
63
|
+
import cv2
|
|
64
|
+
|
|
65
|
+
cap = cv2.VideoCapture(index)
|
|
66
|
+
if cap is None or not cap.isOpened():
|
|
67
|
+
if cap is not None:
|
|
68
|
+
cap.release()
|
|
69
|
+
raise CameraNotFoundError(f"camera {index} could not be opened")
|
|
70
|
+
try:
|
|
71
|
+
cap.set(cv2.CAP_PROP_FRAME_WIDTH, resolution[0])
|
|
72
|
+
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, resolution[1])
|
|
73
|
+
# Honor the timeout via cv2's own read; cv2 doesn't expose a
|
|
74
|
+
# blocking timeout per se, so we rely on cv2 returning False
|
|
75
|
+
# when no frame is available. A truly hung driver would
|
|
76
|
+
# require process-level handling — out of scope for v1.
|
|
77
|
+
ok, frame = cap.read()
|
|
78
|
+
if not ok or frame is None:
|
|
79
|
+
# On Windows, "device opened but read failed" most
|
|
80
|
+
# commonly means another process holds the video pin
|
|
81
|
+
# exclusively (Zoom, browser camera, etc.).
|
|
82
|
+
raise CameraBusyError(f"camera {index} opened but read failed")
|
|
83
|
+
# cv2.imencode expects a BGR array (cv2's in-memory convention) and
|
|
84
|
+
# writes RGB-ordered PNG bytes — i.e. it handles the channel swap
|
|
85
|
+
# internally. Do NOT pre-convert with cvtColor(BGR2RGB) — that
|
|
86
|
+
# produces a doubly-swapped PNG with R and B reversed.
|
|
87
|
+
#
|
|
88
|
+
# This was confirmed in two ways on 2026-05-06: (1) a synthetic test
|
|
89
|
+
# decoding raw IDAT bytes from a known-color test pattern, and
|
|
90
|
+
# (2) end-to-end Pepper captures shown to Jeff. The version WITHOUT
|
|
91
|
+
# cvtColor renders the room correctly (cool monitor light reflecting
|
|
92
|
+
# off neutral surfaces); the version WITH cvtColor adds an artificial
|
|
93
|
+
# yellow cast.
|
|
94
|
+
ok2, buf = cv2.imencode(".png", frame)
|
|
95
|
+
if not ok2:
|
|
96
|
+
raise WebcamError(f"camera {index} returned a frame but PNG encode failed")
|
|
97
|
+
return bytes(buf.tobytes())
|
|
98
|
+
finally:
|
|
99
|
+
cap.release()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Static protocol satisfaction check (running the constructor is fine —
|
|
103
|
+
# cv2 is lazy-loaded inside method bodies).
|
|
104
|
+
_: CameraBackend = OpenCVCameraBackend()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["OpenCVCameraBackend"]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Agent_core entry-point hook surface for the webcam framework.
|
|
2
|
+
|
|
3
|
+
Three hookimpls:
|
|
4
|
+
|
|
5
|
+
* ``register_endpoint_types`` — exposes ``builtin.webcam`` so the bus
|
|
6
|
+
runner can construct a ``WebcamEndpoint`` from a yaml entry.
|
|
7
|
+
* ``reserved_endpoint_params`` — declares ``webcam`` so the runner pops
|
|
8
|
+
it from claude_code_mcp's params before constructing.
|
|
9
|
+
* ``wire_endpoints_after_registration`` — pairs every
|
|
10
|
+
``ClaudeCodeMCPEndpoint`` whose yaml params name a webcam endpoint
|
|
11
|
+
with that endpoint instance, appending a deferred mounter that
|
|
12
|
+
registers the two webcam tools on the FastMCP server at
|
|
13
|
+
``bus.start()`` time.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
import pluggy
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from agent_core.bus.protocol import Endpoint
|
|
24
|
+
from agent_core.plugins.specs import RunnerServices
|
|
25
|
+
|
|
26
|
+
hookimpl = pluggy.HookimplMarker("agent_core")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@hookimpl
|
|
30
|
+
def register_endpoint_types() -> dict[str, type[Any]]:
|
|
31
|
+
"""Register ``builtin.webcam`` as a bus endpoint type."""
|
|
32
|
+
from agent_core_webcam.endpoint import WebcamEndpoint
|
|
33
|
+
|
|
34
|
+
return {"builtin.webcam": WebcamEndpoint}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@hookimpl
|
|
38
|
+
def reserved_endpoint_params() -> list[str]:
|
|
39
|
+
"""The runner pops these keys from each endpoint's params before constructing."""
|
|
40
|
+
return ["webcam"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@hookimpl
|
|
44
|
+
def wire_endpoints_after_registration(
|
|
45
|
+
endpoints: dict[str, Endpoint],
|
|
46
|
+
raw_endpoint_configs: dict[str, dict[str, Any]],
|
|
47
|
+
services: RunnerServices,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Mount webcam tools on every MCP endpoint that names a webcam endpoint."""
|
|
50
|
+
del services # unused — kept for hookspec parity
|
|
51
|
+
from agent_core.endpoints.claude_code_mcp import ClaudeCodeMCPEndpoint
|
|
52
|
+
from agent_core_webcam.endpoint import WebcamEndpoint
|
|
53
|
+
from agent_core_webcam.mcp import register_webcam_tools
|
|
54
|
+
|
|
55
|
+
for name, endpoint in endpoints.items():
|
|
56
|
+
if not isinstance(endpoint, ClaudeCodeMCPEndpoint):
|
|
57
|
+
continue
|
|
58
|
+
raw = raw_endpoint_configs.get(name) or {}
|
|
59
|
+
params = raw.get("params") or {}
|
|
60
|
+
webcam_name = params.get("webcam")
|
|
61
|
+
if not webcam_name:
|
|
62
|
+
continue
|
|
63
|
+
webcam = endpoints.get(webcam_name)
|
|
64
|
+
if webcam is None:
|
|
65
|
+
available = sorted(n for n, e in endpoints.items() if isinstance(e, WebcamEndpoint))
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"endpoint {name!r} names webcam={webcam_name!r} but no endpoint with "
|
|
68
|
+
f"that name is registered. Available WebcamEndpoint names: {available}"
|
|
69
|
+
)
|
|
70
|
+
if not isinstance(webcam, WebcamEndpoint):
|
|
71
|
+
available = sorted(n for n, e in endpoints.items() if isinstance(e, WebcamEndpoint))
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"endpoint {name!r} names webcam={webcam_name!r}, but that endpoint is "
|
|
74
|
+
f"a {type(webcam).__name__}, not a WebcamEndpoint. "
|
|
75
|
+
f"Available WebcamEndpoint names: {available}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _mounter(
|
|
79
|
+
bus_handle,
|
|
80
|
+
*,
|
|
81
|
+
webcam: WebcamEndpoint = webcam,
|
|
82
|
+
mcp_endpoint: ClaudeCodeMCPEndpoint = endpoint,
|
|
83
|
+
) -> None:
|
|
84
|
+
del bus_handle # webcam tools don't publish onto the bus
|
|
85
|
+
register_webcam_tools(mcp=mcp_endpoint._mcp, endpoint=webcam)
|
|
86
|
+
|
|
87
|
+
endpoint.deferred_tool_mounters.append(_mounter)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Camera backend protocol + exception taxonomy.
|
|
2
|
+
|
|
3
|
+
The ``CameraBackend`` Protocol is the seam that lets ``WebcamEndpoint``
|
|
4
|
+
work against either ``OpenCVCameraBackend`` (real) or ``FakeCameraBackend``
|
|
5
|
+
(deterministic for tests). All failure modes the endpoint maps to
|
|
6
|
+
agent-readable error messages descend from ``WebcamError`` so the
|
|
7
|
+
endpoint's exception handling stays simple.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WebcamError(Exception):
|
|
17
|
+
"""Base for every error a CameraBackend may raise."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CameraNotFoundError(WebcamError):
|
|
21
|
+
"""The requested camera index does not exist on this host."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CameraBusyError(WebcamError):
|
|
25
|
+
"""The camera opened but is in use by another process."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReadTimeoutError(WebcamError):
|
|
29
|
+
"""The camera opened but did not return a frame within the timeout."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class CameraInfo:
|
|
34
|
+
"""One enumerated camera on the host."""
|
|
35
|
+
|
|
36
|
+
index: int
|
|
37
|
+
name: str
|
|
38
|
+
available: bool
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class CameraBackend(Protocol):
|
|
43
|
+
"""Hardware-access seam used by WebcamEndpoint."""
|
|
44
|
+
|
|
45
|
+
def list_cameras(self) -> list[CameraInfo]:
|
|
46
|
+
"""Enumerate cameras the host can see right now."""
|
|
47
|
+
|
|
48
|
+
def capture(self, index: int, resolution: tuple[int, int]) -> bytes:
|
|
49
|
+
"""Capture one frame from camera ``index`` at ``resolution``.
|
|
50
|
+
|
|
51
|
+
Returns PNG-encoded bytes (sRGB, RGB-ordered).
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
CameraNotFoundError: index not present on this host.
|
|
55
|
+
CameraBusyError: device opened but is in use elsewhere.
|
|
56
|
+
ReadTimeoutError: device opened but read() returned no frame.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"CameraBackend",
|
|
62
|
+
"CameraBusyError",
|
|
63
|
+
"CameraInfo",
|
|
64
|
+
"CameraNotFoundError",
|
|
65
|
+
"ReadTimeoutError",
|
|
66
|
+
"WebcamError",
|
|
67
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-core-webcam
|
|
3
|
+
Version: 0.8.2
|
|
4
|
+
Summary: Webcam endpoint for agent_core — agent-driven on-demand frame capture
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: agent-core-bus<0.9,>=0.8
|
|
8
|
+
Requires-Dist: fastmcp>=2.0
|
|
9
|
+
Requires-Dist: opencv-python>=5.0.0.93
|
|
10
|
+
Requires-Dist: pluggy>=1.6
|
|
11
|
+
Requires-Dist: pydantic>=2.7
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
agent_core_webcam/__init__.py,sha256=chVcO0DtqtnE6qEGbvU11I7vIffzEhCdrkCrYqVeQIk,68
|
|
2
|
+
agent_core_webcam/audit.py,sha256=i1fGHqWJNS1v_hE09O9MVzBlt51UICLEIb_cGUPyRJw,1372
|
|
3
|
+
agent_core_webcam/endpoint.py,sha256=wS-TcNLo065ge1s27SJpMrDjwoMoUlCADY2rA-za1TE,15441
|
|
4
|
+
agent_core_webcam/fake.py,sha256=gBRZfbbKvajaxBP2G_y2mfffPF0QMoiQoJj2NU-kMos,5224
|
|
5
|
+
agent_core_webcam/mcp.py,sha256=7LhAdXfVB-I91YOBjW-tlviqaoAPTJin0SuuSir9ku4,3343
|
|
6
|
+
agent_core_webcam/opencv_backend.py,sha256=FnKlZ9zAHs-fLrlYELAYfkaaspH6YkGCb7ar3QReEiI,4281
|
|
7
|
+
agent_core_webcam/plugin.py,sha256=SxRWfJ2HqTGKrTXxQnTYjdIvesgGvvARE_pOlTV6tm0,3297
|
|
8
|
+
agent_core_webcam/protocol.py,sha256=vbuIJ6pU2bpLrwx5866bgujKvgTOQNW2CSltuPcb4fU,1847
|
|
9
|
+
agent_core_webcam-0.8.2.dist-info/METADATA,sha256=YsH_DKsMhSXuxzDLN3F7nnvC_PJWACzhnIY4zRHqgjU,353
|
|
10
|
+
agent_core_webcam-0.8.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
agent_core_webcam-0.8.2.dist-info/entry_points.txt,sha256=K8iIBlcP5gMp7FX_yCVeD5jTrQzS9E61SMK5924cpE4,55
|
|
12
|
+
agent_core_webcam-0.8.2.dist-info/RECORD,,
|