FleetSuite: camera discovery — scan + pick instead of guessing indices
Add a discovery endpoint + UI so cameras are picked from a detected list
rather than typing OpenCV indices blind.
- Name enumeration works WITHOUT OpenCV (macOS system_profiler,
Linux /sys/class/video4linux), so discovery is useful before the [ui]
extra is installed; when cv2 IS present, each not-in-use index is briefly
opened to confirm it works and read its resolution.
- GET /api/cameras/discover returns {available, cv2, cameras:[{index, name,
width, height, in_use, unavailable}]}; indices already bound to a FleetSuite
camera are marked in_use and not re-opened (their stream owns them).
- CameraManager auto-scans on mount with a "scan" button, lists detected
cameras (name · idx · resolution) with one-click add, flags missing OpenCV
with an install hint, and keeps manual-by-index as a fallback.
Verified on the bench: detects all 4 USB capture cameras with cv2 absent;
one-click add registers + marks in_use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4e45ec962e
commit
fb9a2afedf
@@ -350,6 +350,16 @@ def _mount_cameras(api: APIRouter) -> None:
|
||||
async def list_cameras(request: Request):
|
||||
return await rc.list_all(request.app.state.db)
|
||||
|
||||
@api.get("/cameras/discover")
|
||||
async def discover_cameras(request: Request):
|
||||
# Indices already bound to a FleetSuite camera — don't re-open those.
|
||||
in_use = {
|
||||
str(c["device_index"])
|
||||
for c in await rc.list_all(request.app.state.db)
|
||||
if c.get("device_index") is not None
|
||||
}
|
||||
return await asyncio.to_thread(camera_stream.discover, in_use)
|
||||
|
||||
@api.post("/cameras")
|
||||
async def add_camera(request: Request, body: dict = Body(...)):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
|
||||
@@ -9,24 +9,121 @@ reports the error and the stream endpoint 503s instead of crashing.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web.camera_stream")
|
||||
|
||||
BOUNDARY = "frame"
|
||||
_FPS = 10.0
|
||||
_MAX_INDEX = 10 # don't probe past this
|
||||
|
||||
|
||||
def _import_cv2():
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
|
||||
try: # quiet the noisy "can't open camera" warnings during probing
|
||||
cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_SILENT)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return cv2
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _enumerate_names() -> list[tuple[int, str]]:
|
||||
"""Best-effort camera names from the OS, WITHOUT activating any device.
|
||||
Returns ``[(index, name), ...]``. The index is the OpenCV capture index
|
||||
(enumeration order on macOS, the videoN number on Linux)."""
|
||||
if sys.platform == "darwin":
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["system_profiler", "SPCameraDataType", "-json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
items = json.loads(out.stdout).get("SPCameraDataType", [])
|
||||
return [(i, it.get("_name", f"camera {i}")) for i, it in enumerate(items)]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if sys.platform.startswith("linux"):
|
||||
out: list[tuple[int, str]] = []
|
||||
for node in sorted(Path("/sys/class/video4linux").glob("video*")):
|
||||
try:
|
||||
idx = int(node.name[len("video") :])
|
||||
name = (node / "name").read_text().strip()
|
||||
out.append((idx, name or f"video{idx}"))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def _probe_resolution(cv2, index: int) -> dict | None:
|
||||
"""Open a capture index briefly to confirm it works + read its resolution.
|
||||
Activates the camera momentarily; returns None if it won't open/read."""
|
||||
try:
|
||||
cap = cv2.VideoCapture(index)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
try:
|
||||
if not cap.isOpened():
|
||||
return None
|
||||
ok, _ = cap.read()
|
||||
if not ok:
|
||||
return None
|
||||
return {
|
||||
"width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0),
|
||||
"height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0),
|
||||
}
|
||||
finally:
|
||||
cap.release()
|
||||
|
||||
|
||||
def discover(skip: set[str] | None = None, probe_resolution: bool = True) -> dict:
|
||||
"""Enumerate attached cameras.
|
||||
|
||||
Name enumeration needs no OpenCV (works even without the ``[ui]`` extra), so
|
||||
discovery is useful before streaming is installed. When cv2 IS present, each
|
||||
not-in-use index is briefly opened to confirm it works and read its
|
||||
resolution. ``skip`` is the set of indices already bound to a FleetSuite
|
||||
camera — they're marked ``in_use`` and not re-opened (their stream owns them).
|
||||
"""
|
||||
skip = skip or set()
|
||||
cv2 = _import_cv2()
|
||||
named = _enumerate_names()
|
||||
|
||||
# If the OS gave us no names but cv2 is here, fall back to index probing.
|
||||
if not named and cv2 is not None:
|
||||
named = [(i, f"camera {i}") for i in range(_MAX_INDEX)]
|
||||
|
||||
cameras: list[dict] = []
|
||||
for index, name in named:
|
||||
if index >= _MAX_INDEX:
|
||||
continue
|
||||
entry: dict = {"index": index, "name": name, "in_use": str(index) in skip}
|
||||
if cv2 is not None and not entry["in_use"] and probe_resolution:
|
||||
res = _probe_resolution(cv2, index)
|
||||
if res is None:
|
||||
# Named by the OS but cv2 couldn't open it — surface, don't drop.
|
||||
entry["unavailable"] = True
|
||||
else:
|
||||
entry.update(res)
|
||||
cameras.append(entry)
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"cv2": cv2 is not None,
|
||||
"cameras": cameras,
|
||||
}
|
||||
|
||||
|
||||
def probe(device_index: str) -> dict:
|
||||
"""Can we open this device? Returns ``{ok, error}``."""
|
||||
cv2 = _import_cv2()
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { useCamerasStore } from "../stores/cameras";
|
||||
|
||||
interface Discovered {
|
||||
index: number;
|
||||
name: string;
|
||||
in_use: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
unavailable?: boolean;
|
||||
}
|
||||
|
||||
const cameras = useCamerasStore();
|
||||
const name = ref("");
|
||||
const index = ref("0");
|
||||
const adding = ref(false);
|
||||
const manual = ref(false);
|
||||
|
||||
const discovered = ref<Discovered[]>([]);
|
||||
const scanning = ref(false);
|
||||
const scanned = ref(false);
|
||||
const hasBackend = ref(true); // cv2 present → live preview possible
|
||||
|
||||
async function scan() {
|
||||
scanning.value = true;
|
||||
try {
|
||||
const res = await api.get<{ cv2: boolean; cameras: Discovered[] }>(
|
||||
"/api/cameras/discover",
|
||||
);
|
||||
hasBackend.value = res.cv2;
|
||||
discovered.value = res.cameras;
|
||||
scanned.value = true;
|
||||
} catch {
|
||||
discovered.value = [];
|
||||
scanned.value = true;
|
||||
} finally {
|
||||
scanning.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(scan);
|
||||
|
||||
async function quickAdd(cam: Discovered) {
|
||||
await cameras.add(cam.name, String(cam.index));
|
||||
await scan(); // refresh in_use flags
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!name.value.trim()) return;
|
||||
@@ -14,10 +53,17 @@ async function add() {
|
||||
await cameras.add(name.value, index.value);
|
||||
name.value = "";
|
||||
index.value = "0";
|
||||
await scan();
|
||||
} finally {
|
||||
adding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function res(c: Discovered): string {
|
||||
if (c.unavailable) return "can't open";
|
||||
if (c.width && c.height) return `${c.width}×${c.height}`;
|
||||
return hasBackend.value ? "" : "no preview backend";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,8 +71,62 @@ async function add() {
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="w-1 h-3.5 rounded-full bg-indigo-500/80" />
|
||||
<h3 class="section-label">USB Cameras</h3>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
@click="scan"
|
||||
:disabled="scanning"
|
||||
class="text-xs px-2.5 py-1 rounded border border-indigo-700 text-indigo-300 hover:bg-indigo-600/20 disabled:opacity-40 fs-display"
|
||||
>
|
||||
{{ scanning ? "scanning…" : "⟳ scan" }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
|
||||
<!-- discovered cameras -->
|
||||
<ul v-if="discovered.length" class="space-y-1 mb-3">
|
||||
<li
|
||||
v-for="c in discovered"
|
||||
:key="c.index"
|
||||
class="flex items-center gap-2 text-xs px-2 py-1.5 rounded bg-slate-950/40 border border-slate-800"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="c.unavailable ? 'bg-rose-500' : 'bg-emerald-400'" />
|
||||
<span class="text-slate-200 truncate">{{ c.name }}</span>
|
||||
<span class="mono text-slate-500">idx {{ c.index }}</span>
|
||||
<span v-if="res(c)" class="mono text-slate-600">{{ res(c) }}</span>
|
||||
<span class="ml-auto">
|
||||
<span v-if="c.in_use" class="text-emerald-400/70">added ✓</span>
|
||||
<button
|
||||
v-else
|
||||
@click="quickAdd(c)"
|
||||
class="px-2 py-0.5 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50"
|
||||
>
|
||||
add
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else-if="scanned && !scanning"
|
||||
class="text-xs text-slate-600 mb-3"
|
||||
>
|
||||
no cameras detected — connect a USB capture device and scan again
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="scanned && !hasBackend"
|
||||
class="text-[11px] text-amber-400/80 mb-3"
|
||||
>
|
||||
live preview needs OpenCV — install the bench extra:
|
||||
<span class="mono">pip install -e '.[ui]'</span> (discovery still works without it)
|
||||
</p>
|
||||
|
||||
<!-- manual fallback -->
|
||||
<button
|
||||
@click="manual = !manual"
|
||||
class="text-[11px] text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
{{ manual ? "▾" : "▸" }} add by index manually
|
||||
</button>
|
||||
<div v-if="manual" class="flex flex-wrap gap-2 mt-2">
|
||||
<input
|
||||
v-model="name"
|
||||
placeholder="name"
|
||||
@@ -45,7 +145,9 @@ async function add() {
|
||||
add camera
|
||||
</button>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
|
||||
<!-- registered cameras -->
|
||||
<ul v-if="cameras.list.length" class="space-y-1 mt-3 pt-3 border-t border-slate-800">
|
||||
<li
|
||||
v-for="c in cameras.list"
|
||||
:key="c.id"
|
||||
@@ -64,9 +166,6 @@ async function add() {
|
||||
remove
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="cameras.list.length === 0" class="text-xs text-slate-600">
|
||||
no cameras yet — add one by its OpenCV device index
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user