feat(k1): add live cameras and reliable spatial following

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 00:26:03 +03:00
parent a281faf923
commit 2bda1986bd
33 changed files with 2927 additions and 330 deletions
+569
View File
@@ -0,0 +1,569 @@
from __future__ import annotations
import asyncio
import os
import queue
import signal
import subprocess
import threading
from collections import deque
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO, Any, Final, Literal
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from k1link.mqtt import validate_private_ipv4
CameraSourceId = Literal["sensor.camera.left", "sensor.camera.right"]
CAMERA_SOURCE_PATHS: Final[dict[CameraSourceId, str]] = {
"sensor.camera.left": "/live/chn_left_main",
"sensor.camera.right": "/live/chn_right_main",
}
CAMERA_SOURCE_LABELS: Final[dict[CameraSourceId, str]] = {
"sensor.camera.left": "K1 · камера слева",
"sensor.camera.right": "K1 · камера справа",
}
CAMERA_MEDIA_TYPE: Final = 'video/mp4; codecs="avc1.641028"'
CAMERA_EXCLUSIVE_GROUP: Final = "camera.preview.decoder"
MAX_FMP4_BOX_BYTES: Final = 8 * 1024 * 1024
MAX_FMP4_SEGMENT_BYTES: Final = 1024 * 1024
MAX_QUEUED_SEGMENTS: Final = 4
@dataclass
class CameraProcessLease:
generation: int
source_id: CameraSourceId
process: subprocess.Popen[bytes]
stderr_tail: deque[str] = field(default_factory=lambda: deque(maxlen=12))
segments: queue.Queue[tuple[str, bytes] | None] = field(
default_factory=lambda: queue.Queue(maxsize=MAX_QUEUED_SEGMENTS)
)
failure_code: str | None = None
class XgridsK1CameraGateway:
"""One fail-closed K1 RTSP owner with a browser-safe fMP4 delivery plane.
Selection and delivery are deliberately separate. A plugin action selects
one allowlisted producer and publishes an opaque generation. Only a matching
WebSocket may then start FFmpeg. This keeps the device address and RTSP path
out of the generic UI and prevents two browser windows from opening two K1
sessions.
"""
def __init__(self, repository_root: Path, plugin_id: str) -> None:
self._repository_root = repository_root.resolve()
self._plugin_id = plugin_id
self._lock = threading.RLock()
self._revision = 0
self._generation = 0
self._phase = "idle"
self._source_id: CameraSourceId | None = None
self._target_host: str | None = None
self._process: subprocess.Popen[bytes] | None = None
self._process_generation: int | None = None
self._error: dict[str, str] | None = None
self._closed = False
self._ffmpeg_path, self._ffmpeg_source = _resolve_ffmpeg(self._repository_root)
def snapshot(self) -> dict[str, Any]:
with self._lock:
delivery = None
if (
self._source_id is not None
and self._ffmpeg_path is not None
and self._phase != "error"
):
delivery = {
"id": f"camera-preview-{self._generation}",
"kind": "mse-fmp4-websocket",
"url": (
f"/api/v1/device-plugins/{self._plugin_id}"
f"/camera-preview/{self._generation}"
),
"media_type": CAMERA_MEDIA_TYPE,
}
return {
"schema_version": "missioncore.camera-preview/v1alpha1",
"phase": self._phase,
"revision": self._revision,
"generation": self._generation if self._source_id is not None else None,
"active_source_id": self._source_id,
"activation": {
"exclusive_group": CAMERA_EXCLUSIVE_GROUP,
"max_active": 1,
},
"delivery": delivery,
"runtime_dependency": {
"kind": "ffmpeg",
"status": "available" if self._ffmpeg_path is not None else "missing",
"source": self._ffmpeg_source,
},
"error": dict(self._error) if self._error is not None else None,
}
def select(self, source_id: CameraSourceId, target_host: str) -> dict[str, Any]:
if source_id not in CAMERA_SOURCE_PATHS:
raise ValueError("неизвестный camera source")
target = validate_private_ipv4(target_host)
old_process: subprocess.Popen[bytes] | None = None
with self._lock:
if self._closed:
raise RuntimeError("camera gateway уже закрыт")
if self._ffmpeg_path is None:
self._generation += 1
self._revision += 1
self._source_id = source_id
self._target_host = target
self._phase = "error"
self._error = {
"code": "ffmpeg-unavailable",
"message": "Локальный camera adapter FFmpeg не найден.",
}
raise RuntimeError("локальный camera adapter FFmpeg не найден")
if (
self._source_id == source_id
and self._target_host == target
and self._phase in {"selected", "connecting", "streaming"}
):
return self.snapshot()
old_process = self._detach_process_locked()
self._generation += 1
self._revision += 1
self._source_id = source_id
self._target_host = target
self._phase = "selected"
self._error = None
_terminate_process(old_process)
return self.snapshot()
def stop(self, generation: int) -> dict[str, Any]:
old_process: subprocess.Popen[bytes] | None
with self._lock:
if self._source_id is None:
return self.snapshot()
if generation != self._generation:
raise ValueError("camera preview generation устарело")
old_process = self._detach_process_locked()
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._error = None
_terminate_process(old_process)
return self.snapshot()
def stop_current(self) -> dict[str, Any]:
old_process: subprocess.Popen[bytes] | None
with self._lock:
old_process = self._detach_process_locked()
changed = self._source_id is not None or self._phase != "idle"
if changed:
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._error = None
_terminate_process(old_process)
return self.snapshot()
def open_delivery(self, generation: int) -> CameraProcessLease:
with self._lock:
if self._closed:
raise RuntimeError("camera gateway закрыт")
if generation != self._generation or self._source_id is None:
raise ValueError("camera preview generation не активно")
if self._process is not None:
raise RuntimeError("для camera preview уже открыт browser consumer")
if self._ffmpeg_path is None or self._target_host is None:
raise RuntimeError("camera preview runtime не готов")
argv = _build_ffmpeg_argv(
self._ffmpeg_path,
self._target_host,
self._source_id,
)
try:
process = subprocess.Popen(
argv,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=False,
start_new_session=(os.name == "posix"),
)
except OSError as exc:
self._revision += 1
self._phase = "error"
self._error = {
"code": "ffmpeg-start-failed",
"message": "Не удалось запустить локальный camera adapter.",
}
raise RuntimeError("не удалось запустить camera adapter") from exc
if process.stdout is None or process.stderr is None:
_terminate_process(process)
raise RuntimeError("camera adapter не открыл media pipes")
lease = CameraProcessLease(
generation=generation,
source_id=self._source_id,
process=process,
)
self._process = process
self._process_generation = generation
self._revision += 1
self._phase = "connecting"
self._error = None
threading.Thread(
target=_drain_stderr,
args=(lease,),
name=f"k1-camera-stderr-{generation}",
daemon=True,
).start()
threading.Thread(
target=_read_fmp4_stdout,
args=(lease,),
name=f"k1-camera-fmp4-{generation}",
daemon=True,
).start()
return lease
def mark_streaming(self, lease: CameraProcessLease) -> None:
with self._lock:
if (
lease.generation != self._generation
or self._process is not lease.process
or self._phase == "streaming"
):
return
self._revision += 1
self._phase = "streaming"
def release_delivery(self, lease: CameraProcessLease, *, client_closed: bool) -> None:
_terminate_process(lease.process)
with self._lock:
if self._process is lease.process:
self._process = None
self._process_generation = None
if lease.generation != self._generation or self._source_id is None:
return
self._revision += 1
if client_closed:
self._phase = "selected"
self._error = None
else:
self._phase = "error"
if lease.failure_code == "consumer-too-slow":
message = (
"Browser не успевает принимать camera stream; "
"канал остановлен без накопления задержки."
)
elif lease.failure_code == "invalid-fmp4":
message = "Camera adapter вернул некорректный fMP4 stream."
elif lease.failure_code == "segment-too-large":
message = "Camera adapter отклонил слишком большой video segment."
else:
message = _safe_ffmpeg_message(lease.stderr_tail)
self._error = {
"code": lease.failure_code or "camera-source-ended",
"message": message,
}
def close(self) -> None:
old_process: subprocess.Popen[bytes] | None
with self._lock:
if self._closed:
return
self._closed = True
old_process = self._detach_process_locked()
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._error = None
_terminate_process(old_process)
def _detach_process_locked(self) -> subprocess.Popen[bytes] | None:
process = self._process
self._process = None
self._process_generation = None
return process
def _resolve_ffmpeg(repository_root: Path) -> tuple[Path | None, str]:
configured = os.environ.get("MISSIONCORE_FFMPEG_BINARY")
candidates: list[tuple[Path, str]] = []
if configured:
candidates.append((Path(configured).expanduser(), "configured"))
candidates.append((repository_root / ".runtime" / "ffmpeg" / "ffmpeg", "bundled-local"))
# Explicit development-only fallbacks. Product packaging must provide the
# repository-local binary or MISSIONCORE_FFMPEG_BINARY instead.
candidates.extend(
(
(Path("/opt/homebrew/bin/ffmpeg"), "development-system"),
(Path("/usr/local/bin/ffmpeg"), "development-system"),
)
)
for candidate, source in candidates:
try:
resolved = candidate.resolve(strict=True)
except OSError:
continue
if resolved.is_file() and os.access(resolved, os.X_OK):
return resolved, source
return None, "missing"
def _build_ffmpeg_argv(
ffmpeg_path: Path,
target_host: str,
source_id: CameraSourceId,
) -> list[str]:
target = validate_private_ipv4(target_host)
path = CAMERA_SOURCE_PATHS.get(source_id)
if path is None:
raise ValueError("неизвестный camera source")
upstream = f"rtsp://{target}:8554{path}"
return [
str(ffmpeg_path),
"-hide_banner",
"-loglevel",
"warning",
"-nostdin",
"-rtsp_transport",
"tcp",
"-allowed_media_types",
"video",
"-timeout",
"5000000",
"-probesize",
"4000000",
"-analyzeduration",
"2000000",
"-fflags",
"nobuffer",
"-i",
upstream,
"-map",
"0:v:0",
"-an",
"-sn",
"-dn",
"-c:v",
"copy",
"-f",
"mp4",
"-movflags",
"+empty_moov+default_base_moof+omit_tfhd_offset+frag_every_frame+skip_trailer",
"-flush_packets",
"1",
"pipe:1",
]
def _read_exact(stream: IO[bytes], size: int) -> bytes:
chunks: list[bytes] = []
remaining = size
while remaining:
chunk = stream.read(remaining)
if not chunk:
raise EOFError
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def _read_mp4_box(stream: IO[bytes]) -> tuple[bytes, bytes]:
header = _read_exact(stream, 8)
size = int.from_bytes(header[:4], "big")
box_type = header[4:8]
if size == 1:
extended = _read_exact(stream, 8)
size = int.from_bytes(extended, "big")
header += extended
if size == 0 or size < len(header) or size > MAX_FMP4_BOX_BYTES:
raise ValueError("invalid or unbounded ISO-BMFF box")
return box_type, header + _read_exact(stream, size - len(header))
def _enqueue_segment(lease: CameraProcessLease, kind: str, payload: bytes) -> bool:
if len(payload) > MAX_FMP4_SEGMENT_BYTES:
lease.failure_code = "segment-too-large"
_finish_segment_queue(lease)
with suppress(OSError):
lease.process.terminate()
return False
try:
lease.segments.put_nowait((kind, payload))
return True
except queue.Full:
lease.failure_code = "consumer-too-slow"
while True:
try:
lease.segments.get_nowait()
except queue.Empty:
break
lease.segments.put_nowait(None)
with suppress(OSError):
lease.process.terminate()
return False
def _finish_segment_queue(lease: CameraProcessLease) -> None:
try:
lease.segments.put_nowait(None)
except queue.Full:
# A full queue is itself a bounded-latency failure. Never leave FFmpeg
# back-pressured while the browser accumulates tens of seconds.
lease.failure_code = lease.failure_code or "consumer-too-slow"
while True:
try:
lease.segments.get_nowait()
except queue.Empty:
break
lease.segments.put_nowait(None)
def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
stream = lease.process.stdout
if stream is None:
lease.failure_code = "invalid-fmp4"
_finish_segment_queue(lease)
return
init_parts: list[bytes] = []
fragment_parts: list[bytes] = []
init_sent = False
try:
while True:
box_type, box = _read_mp4_box(stream)
if not init_sent:
init_parts.append(box)
if box_type == b"moov":
if not _enqueue_segment(lease, "init", b"".join(init_parts)):
return
init_sent = True
continue
if box_type == b"moof":
fragment_parts = [box]
continue
if fragment_parts:
fragment_parts.append(box)
if box_type == b"mdat":
if not _enqueue_segment(lease, "media", b"".join(fragment_parts)):
return
fragment_parts = []
continue
# `styp`/`sidx` are valid media-segment prefixes. Preserve them and
# wait for the following moof+mdat instead of forwarding raw boxes.
if box_type in {b"styp", b"sidx"}:
fragment_parts.append(box)
except EOFError:
if not init_sent:
lease.failure_code = "invalid-fmp4"
except (OSError, ValueError):
lease.failure_code = "invalid-fmp4"
finally:
_finish_segment_queue(lease)
def _drain_stderr(lease: CameraProcessLease) -> None:
stream = lease.process.stderr
if stream is None:
return
try:
while True:
line = stream.readline()
if not line:
return
text = line.decode("utf-8", errors="replace").strip()
if text:
lease.stderr_tail.append(text[-240:])
except (OSError, ValueError):
return
def _safe_ffmpeg_message(stderr_tail: deque[str]) -> str:
# Never reflect the RTSP URL or device address into browser state.
joined = " ".join(stderr_tail).lower()
if "connection refused" in joined:
return "Camera endpoint отклонил локальное соединение."
if "timed out" in joined or "timeout" in joined:
return "Camera endpoint не ответил за отведённое время."
if "invalid data" in joined or "could not find codec" in joined:
return "Camera endpoint вернул неподдерживаемый media stream."
return "Camera stream завершился до первого пригодного видеофрагмента."
def _terminate_process(process: subprocess.Popen[bytes] | None) -> None:
if process is None:
return
try:
if process.poll() is None:
if os.name == "posix":
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGINT)
else:
process.terminate()
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
process.terminate()
try:
process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=1.0)
finally:
for stream in (process.stdout, process.stderr):
if stream is not None:
with suppress(OSError):
stream.close()
def build_xgrids_k1_camera_router(
gateway: XgridsK1CameraGateway,
plugin_id: str,
) -> APIRouter:
router = APIRouter(include_in_schema=False)
@router.websocket(
f"/api/v1/device-plugins/{plugin_id}/camera-preview/{{generation}}"
)
async def camera_preview(websocket: WebSocket, generation: int) -> None:
await websocket.accept()
try:
lease = gateway.open_delivery(generation)
except (ValueError, RuntimeError):
await websocket.close(code=1008, reason="Camera preview lease is not active")
return
client_closed = False
try:
while True:
segment = await asyncio.to_thread(lease.segments.get)
if segment is None:
break
kind, payload = segment
if kind == "media":
gateway.mark_streaming(lease)
await websocket.send_bytes(payload)
except WebSocketDisconnect:
client_closed = True
except RuntimeError:
client_closed = True
finally:
gateway.release_delivery(lease, client_closed=client_closed)
with suppress(RuntimeError):
await websocket.close()
return router
+173 -18
View File
@@ -35,6 +35,14 @@ from k1link.web.plugin_runtime import (
PluginActionNotFoundError,
PluginExecutionError,
)
from k1link.web.xgrids_k1_camera import (
CAMERA_EXCLUSIVE_GROUP,
CAMERA_SOURCE_LABELS,
CAMERA_SOURCE_PATHS,
CameraSourceId,
XgridsK1CameraGateway,
build_xgrids_k1_camera_router,
)
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
@@ -55,6 +63,8 @@ ACTION_ACQUISITION_STATE_READ = "acquisition.state.read"
ACTION_STREAM_START_LIVE = "stream.start-live"
ACTION_STREAM_START_REPLAY = "stream.start-replay"
ACTION_STREAM_STOP = "stream.stop"
ACTION_CAMERA_PREVIEW_SELECT = "camera.preview.select"
ACTION_CAMERA_PREVIEW_STOP = "camera.preview.stop"
ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
RequestedStreamId = Literal[
@@ -145,6 +155,16 @@ class ReplayRequest(StrictRequest):
loop: bool = False
class CameraPreviewSelectRequest(StrictRequest):
source_id: CameraSourceId
device_session_id: str = Field(min_length=1, max_length=128)
class CameraPreviewStopRequest(StrictRequest):
device_session_id: str = Field(min_length=1, max_length=128)
generation: int = Field(ge=1)
class ViewerSettingsRequest(StrictRequest):
point_size: float = Field(default=2.5, ge=0.5, le=12.0)
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
@@ -189,10 +209,15 @@ class XgridsK1CompatibilityService:
# The host-owned visual runtime receives the vendor normalizer
# explicitly. There is no implicit K1 decoder in the visual layer.
self.runtime = VisualizationRuntime(normalizer=normalize_k1_message)
self.camera_preview = XgridsK1CameraGateway(
self.repository_root,
XGRIDS_K1_PLUGIN_ID,
)
def state(self) -> dict[str, Any]:
runtime = self.runtime.snapshot()
self._reconcile_acquisition(runtime)
camera_preview = self.camera_preview.snapshot()
metrics = runtime["metrics"]
with self._lock:
operation_phase = self._operation_phase
@@ -264,7 +289,11 @@ class XgridsK1CompatibilityService:
"attestation": compatibility_attestation,
"vendor_writes_enabled": False,
"camera_preview": (
"observed-runtime-adapter-pending"
(
"local-browser-adapter-available"
if camera_preview["runtime_dependency"]["status"] == "available"
else "local-runtime-dependency-missing"
)
if active_profile_id is not None
else "unverified"
),
@@ -292,8 +321,13 @@ class XgridsK1CompatibilityService:
else None
),
"connection_verification": connection_verification,
"sensor_catalog": _sensor_catalog(active_profile_id),
"sensor_catalog": _sensor_catalog(
active_profile_id,
device_session_id,
camera_preview,
),
"device_calibration": _device_calibration_snapshot(active_profile_id),
"camera_preview": camera_preview,
"acquisition": acquisition,
"operations": operation_documents,
"last_operation": operation_documents[-1] if operation_documents else None,
@@ -367,6 +401,9 @@ class XgridsK1CompatibilityService:
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
# A reprovision can replace both the device session and its address.
# Revoke the previous browser lease before any new device-side write.
self.camera_preview.stop_current()
# Unwrap once at the provisioning service boundary. The plain value is
# kept only in this stack frame, included in a keyed request digest, and
# passed to the reviewed BLE write boundary; it is never journaled.
@@ -808,6 +845,7 @@ class XgridsK1CompatibilityService:
try:
with self._lock:
acquisition.transition("stopping", message_code="acquisition.stopping")
self.camera_preview.stop_current()
self.runtime.stop()
self._cancel_pending_acquisition_operations(
exclude_operation_id=operation.operation_id,
@@ -877,6 +915,7 @@ class XgridsK1CompatibilityService:
return self.state()
try:
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
self.camera_preview.stop_current()
self.runtime.stop()
self._cancel_pending_acquisition_operations(
exclude_operation_id=operation.operation_id,
@@ -958,6 +997,7 @@ class XgridsK1CompatibilityService:
def stop(self) -> dict[str, Any]:
"""Deprecated capture-only shim; it never claims that K1 stopped scanning."""
self.camera_preview.stop_current()
with self._lock:
acquisition = self._acquisition
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
@@ -970,6 +1010,34 @@ class XgridsK1CompatibilityService:
)
)
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]:
target = self._camera_target_for_session(request.device_session_id)
self.camera_preview.select(request.source_id, target)
return self.state()
def stop_camera_preview(self, request: CameraPreviewStopRequest) -> dict[str, Any]:
self._camera_target_for_session(request.device_session_id)
self.camera_preview.stop(request.generation)
return self.state()
def close(self) -> None:
camera_error: Exception | None = None
try:
self.camera_preview.close()
except Exception as exc:
camera_error = exc
try:
self.runtime.close()
except Exception as exc:
if camera_error is not None:
exc.add_note(
"camera gateway cleanup also failed: "
f"{type(camera_error).__name__}: {camera_error}"
)
raise
if camera_error is not None:
raise camera_error
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]:
self.runtime.update_scene_settings(
RerunSceneSettings(
@@ -1034,6 +1102,22 @@ class XgridsK1CompatibilityService:
self._device_session_opened_at = _utc_now_iso()
return self._device_id, self._device_session_id
def _camera_target_for_session(self, device_session_id: str) -> str:
with self._lock:
current_session_id = self._device_session_id
target = self._k1_ip
attestation = self._compatibility_attestation
if current_session_id is None or device_session_id != current_session_id:
raise ValueError("указана неактивная device-сессия")
if attestation is None:
raise ValueError("точный compatibility-профиль K1 не подтверждён")
if target is None:
raise ValueError("у плагина нет подтверждённого локального адреса K1")
target = validate_private_ipv4(target)
if target == AP_FALLBACK_IPV4:
raise ValueError("адрес точки доступа K1 нельзя использовать как camera target")
return target
def _require_acquisition(self, acquisition_id: str | None) -> AcquisitionRecord:
with self._lock:
acquisition = self._acquisition
@@ -1212,6 +1296,10 @@ class XgridsK1ServicePort(Protocol):
def stop(self) -> dict[str, Any]: ...
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]: ...
def stop_camera_preview(self, request: CameraPreviewStopRequest) -> dict[str, Any]: ...
def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]: ...
@@ -1236,6 +1324,8 @@ class XgridsK1PluginFacade:
ACTION_STREAM_START_LIVE,
ACTION_STREAM_START_REPLAY,
ACTION_STREAM_STOP,
ACTION_CAMERA_PREVIEW_SELECT,
ACTION_CAMERA_PREVIEW_STOP,
ACTION_VIEWER_SETTINGS_UPDATE,
}
)
@@ -1309,6 +1399,18 @@ class XgridsK1PluginFacade:
if action_id == ACTION_STREAM_STOP:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.stop)
if action_id == ACTION_CAMERA_PREVIEW_SELECT:
select_camera_request = CameraPreviewSelectRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.select_camera_preview,
select_camera_request,
)
if action_id == ACTION_CAMERA_PREVIEW_STOP:
stop_camera_request = CameraPreviewStopRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.stop_camera_preview,
stop_camera_request,
)
if action_id == ACTION_VIEWER_SETTINGS_UPDATE:
settings_request = ViewerSettingsRequest.model_validate(payload)
return await asyncio.to_thread(
@@ -1362,8 +1464,67 @@ def _attestation_snapshot(
}
def _sensor_catalog(active_profile_id: str | None) -> dict[str, Any]:
def _sensor_catalog(
active_profile_id: str | None,
device_session_id: str | None,
camera_preview: Mapping[str, Any],
) -> dict[str, Any]:
camera_profile_active = active_profile_id is not None
camera_phase = str(camera_preview.get("phase") or "idle")
active_camera = camera_preview.get("active_source_id")
delivery = camera_preview.get("delivery")
dependency = camera_preview.get("runtime_dependency")
dependency_available = (
isinstance(dependency, Mapping) and dependency.get("status") == "available"
)
camera_streams: list[dict[str, Any]] = []
for source_id in CAMERA_SOURCE_PATHS:
side = "left" if source_id.endswith(".left") else "right"
selected = source_id == active_camera
if not camera_profile_active:
availability = "unverified"
elif not dependency_available:
availability = "unavailable"
elif selected and camera_phase == "streaming":
availability = "streaming"
elif selected and camera_phase == "error":
availability = "error"
elif selected:
availability = "connecting"
else:
availability = "available"
camera_streams.append(
{
"stream_id": f"camera.preview.{side}.live",
"source_id": source_id,
"semantic_channel_id": "camera.preview.live",
"label": CAMERA_SOURCE_LABELS[source_id],
"sensor_kind": "camera",
"modality": "encoded-video",
"availability": availability,
"decode_status": (
"rtsp-h264-observed-browser-remux"
if camera_profile_active and dependency_available
else (
"local-runtime-dependency-missing"
if camera_profile_active
else "profile-not-attested"
)
),
"endpoint_label": f"RTSP · {side}",
"activation": {
"group_id": CAMERA_EXCLUSIVE_GROUP,
"max_active": 1,
"selected": selected,
"controllable": bool(
camera_profile_active and device_session_id and dependency_available
),
},
"delivery": delivery if selected and isinstance(delivery, Mapping) else None,
"frame_id": None,
"coordinate_convention": "unresolved",
}
)
return {
"schema_version": "missioncore.sensor-catalog/v1alpha2",
"revision": active_profile_id or "unprofiled-evidence-only",
@@ -1404,19 +1565,7 @@ def _sensor_catalog(active_profile_id: str | None) -> dict[str, Any]:
"frame_id": None,
"coordinate_convention": None,
},
{
"stream_id": "camera.preview.live",
"sensor_kind": "camera-rig",
"modality": "encoded-video",
"availability": "observed" if camera_profile_active else "unverified",
"decode_status": (
"transport-observed-runtime-adapter-pending"
if camera_profile_active
else "profile-not-attested"
),
"frame_id": None,
"coordinate_convention": None,
},
*camera_streams,
],
}
@@ -1497,6 +1646,12 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu
adapter = XgridsK1PluginFacade(service)
return DevicePluginRuntimeContribution(
adapter=adapter,
legacy_routers=(build_xgrids_k1_legacy_router(adapter),),
close=service.runtime.close,
legacy_routers=(
build_xgrids_k1_legacy_router(adapter),
build_xgrids_k1_camera_router(
service.camera_preview,
XGRIDS_K1_PLUGIN_ID,
),
),
close=service.close,
)