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:
Ben Meadors
2026-06-23 17:30:21 -05:00
co-authored by Claude Opus 4.8
parent 4e45ec962e
commit fb9a2afedf
3 changed files with 212 additions and 6 deletions
+10
View File
@@ -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>