feat(k1): add live cameras and reliable spatial following
This commit is contained in:
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
@@ -21,7 +22,12 @@ from k1link.viewer.metrics import BridgeMetrics
|
||||
PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"]
|
||||
PointPalette = Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
|
||||
|
||||
MAX_TRAJECTORY_POSES = 20_000
|
||||
MAX_TRAJECTORY_POSES = 2_000
|
||||
TRAJECTORY_APPEND_INTERVAL_NS = 500_000_000
|
||||
TRAJECTORY_FORCE_APPEND_NS = 2_000_000_000
|
||||
TRAJECTORY_MIN_DISTANCE_METERS = 0.02
|
||||
TRAJECTORY_PUBLISH_INTERVAL_NS = 500_000_000
|
||||
LIVE_GRPC_BUFFER_LIMIT = "32MiB"
|
||||
DEFAULT_GRPC_PORT = 9876
|
||||
DEFAULT_CORS_ORIGINS = (
|
||||
"http://127.0.0.1:5173",
|
||||
@@ -66,27 +72,52 @@ class RerunBridge:
|
||||
self.metrics = metrics or BridgeMetrics()
|
||||
self._settings_provider = settings_provider or RerunSceneSettings
|
||||
self._settings = self._settings_provider()
|
||||
self._recording = (recording_factory or rr.RecordingStream)("nodedc_mission_core_spatial")
|
||||
blueprint = _blueprint(self._settings)
|
||||
self._url = self._recording.serve_grpc(
|
||||
grpc_port=grpc_port,
|
||||
default_blueprint=blueprint,
|
||||
server_memory_limit="512MiB",
|
||||
newest_first=True,
|
||||
cors_allow_origin=list(cors_allow_origin),
|
||||
)
|
||||
self._recording.send_blueprint(
|
||||
blueprint,
|
||||
make_active=True,
|
||||
make_default=True,
|
||||
)
|
||||
self._recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||
self._recording.log(
|
||||
"/world/sensor_pose",
|
||||
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
|
||||
static=True,
|
||||
)
|
||||
self._path: deque[tuple[float, float, float]] = deque(maxlen=MAX_TRAJECTORY_POSES)
|
||||
if recording_factory is None:
|
||||
recording = rr.RecordingStream(
|
||||
"nodedc_mission_core_spatial",
|
||||
recording_id=uuid4(),
|
||||
)
|
||||
else:
|
||||
recording = recording_factory("nodedc_mission_core_spatial")
|
||||
try:
|
||||
blueprint = _blueprint(self._settings)
|
||||
url = recording.serve_grpc(
|
||||
grpc_port=grpc_port,
|
||||
default_blueprint=blueprint,
|
||||
# This is a reconnect cushion for the live preview, not the source
|
||||
# of record. Raw MQTT evidence is persisted independently. A large
|
||||
# late-client backlog can block the native SDK and freeze preview.
|
||||
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
|
||||
# Rerun 0.34.1 can replay ActivateStore before StoreInfo when an
|
||||
# evicted buffer is served newest-first, leaving late viewers on the
|
||||
# welcome screen. Preserve protocol order within the bounded cache.
|
||||
newest_first=False,
|
||||
cors_allow_origin=list(cors_allow_origin),
|
||||
)
|
||||
recording.send_blueprint(
|
||||
blueprint,
|
||||
make_active=True,
|
||||
make_default=True,
|
||||
)
|
||||
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||
recording.log(
|
||||
"/world/sensor_pose",
|
||||
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
|
||||
static=True,
|
||||
)
|
||||
# Do not expose a URL whose StoreInfo, blueprint and static scene are
|
||||
# still waiting in the SDK micro-batcher.
|
||||
recording.flush(timeout_sec=5.0)
|
||||
except BaseException:
|
||||
# Preserve the construction failure while still making a best
|
||||
# effort to release a partially started native listener.
|
||||
with suppress(BaseException):
|
||||
recording.disconnect()
|
||||
raise
|
||||
self._recording = recording
|
||||
self._url = url
|
||||
self._path: list[tuple[float, float, float]] = []
|
||||
self._last_path_append_source_ns = 0
|
||||
self._last_trajectory_publish_ns = 0
|
||||
self._last_point_count = 0
|
||||
self._closed = False
|
||||
@@ -96,13 +127,14 @@ class RerunBridge:
|
||||
return self._url
|
||||
|
||||
def begin_session(self, metrics: BridgeMetrics | None = None) -> None:
|
||||
"""Reset session-local state while keeping the process-wide server alive."""
|
||||
"""Reset session state while keeping the process-lifetime server alive."""
|
||||
if self._closed:
|
||||
raise RuntimeError("Rerun bridge is already closed")
|
||||
if metrics is not None:
|
||||
self.metrics = metrics
|
||||
self._settings = self._settings_provider()
|
||||
self._path.clear()
|
||||
self._last_path_append_source_ns = 0
|
||||
self._last_trajectory_publish_ns = 0
|
||||
self._last_point_count = 0
|
||||
self._recording.set_time("stream_time", timestamp=time.time())
|
||||
@@ -118,6 +150,10 @@ class RerunBridge:
|
||||
make_active=True,
|
||||
make_default=True,
|
||||
)
|
||||
# VisualizationRuntime publishes grpc_url only after this method
|
||||
# returns, so a late subscriber cannot race the initial StoreInfo and
|
||||
# blueprint through the SDK micro-batcher.
|
||||
self._recording.flush(timeout_sec=5.0)
|
||||
|
||||
def process(self, envelope: DecodedDataPlaneView) -> None:
|
||||
self._apply_latest_settings()
|
||||
@@ -158,12 +194,14 @@ class RerunBridge:
|
||||
self._closed = True
|
||||
recording = self._recording
|
||||
try:
|
||||
recording.flush(timeout_sec=5.0)
|
||||
try:
|
||||
recording.flush(timeout_sec=5.0)
|
||||
finally:
|
||||
recording.disconnect()
|
||||
finally:
|
||||
recording.disconnect()
|
||||
# The Python wrapper owns the native gRPC server. Release it immediately
|
||||
# instead of waiting for the publisher thread frame to be collected.
|
||||
del self._recording
|
||||
# The Python wrapper owns the native gRPC server. Release it even if
|
||||
# flush or disconnect fails instead of waiting for garbage collection.
|
||||
del self._recording
|
||||
|
||||
def _set_message_time(self, envelope: DecodedDataPlaneView) -> None:
|
||||
context = envelope.context
|
||||
@@ -207,25 +245,35 @@ class RerunBridge:
|
||||
)
|
||||
|
||||
def _publish_pose(self, frame: DecodedPoseView) -> None:
|
||||
publish_now_ns = time.monotonic_ns()
|
||||
position = (
|
||||
float(frame.position_xyz[0]),
|
||||
float(frame.position_xyz[1]),
|
||||
float(frame.position_xyz[2]),
|
||||
)
|
||||
self._recording.log(
|
||||
"/world/sensor_pose",
|
||||
rr.Transform3D(
|
||||
translation=frame.position_xyz,
|
||||
translation=position,
|
||||
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
|
||||
),
|
||||
)
|
||||
self._path.append(frame.position_xyz)
|
||||
|
||||
appended = self._append_trajectory_pose(
|
||||
position,
|
||||
frame.context.captured_at_epoch_ns,
|
||||
)
|
||||
if not self._settings.show_trajectory:
|
||||
self._recording.log("/world/trajectory", rr.Clear(recursive=False))
|
||||
return
|
||||
now_ns = time.monotonic_ns()
|
||||
if not appended:
|
||||
return
|
||||
if (
|
||||
len(self._path) > 2
|
||||
and len(self._path) % 20 != 0
|
||||
and now_ns - self._last_trajectory_publish_ns < 200_000_000
|
||||
publish_now_ns - self._last_trajectory_publish_ns
|
||||
< TRAJECTORY_PUBLISH_INTERVAL_NS
|
||||
):
|
||||
return
|
||||
self._last_trajectory_publish_ns = now_ns
|
||||
self._last_trajectory_publish_ns = publish_now_ns
|
||||
self._recording.log(
|
||||
"/world/trajectory",
|
||||
rr.LineStrips3D(
|
||||
@@ -235,6 +283,39 @@ class RerunBridge:
|
||||
),
|
||||
)
|
||||
|
||||
def _append_trajectory_pose(
|
||||
self,
|
||||
position: tuple[float, float, float],
|
||||
source_time_ns: int,
|
||||
) -> bool:
|
||||
if not self._path:
|
||||
self._path.append(position)
|
||||
self._last_path_append_source_ns = source_time_ns
|
||||
return True
|
||||
|
||||
elapsed_ns = source_time_ns - self._last_path_append_source_ns
|
||||
if elapsed_ns < 0:
|
||||
# A source clock discontinuity must not freeze trajectory sampling
|
||||
# until the timestamp catches up again. Preserve message order and
|
||||
# start a new sampling interval at the discontinuity.
|
||||
elapsed_ns = TRAJECTORY_FORCE_APPEND_NS
|
||||
if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
|
||||
return False
|
||||
if (
|
||||
math.dist(self._path[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
|
||||
and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
|
||||
):
|
||||
return False
|
||||
|
||||
self._path.append(position)
|
||||
self._last_path_append_source_ns = source_time_ns
|
||||
if len(self._path) > MAX_TRAJECTORY_POSES:
|
||||
last = self._path[-1]
|
||||
self._path = self._path[::2]
|
||||
if self._path[-1] != last:
|
||||
self._path.append(last)
|
||||
return True
|
||||
|
||||
|
||||
def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
|
||||
accumulation = max(0.0, settings.accumulation_seconds)
|
||||
@@ -255,12 +336,22 @@ def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
|
||||
),
|
||||
time_ranges=[time_range],
|
||||
),
|
||||
_live_time_panel(),
|
||||
auto_layout=False,
|
||||
auto_views=False,
|
||||
collapse_panels=True,
|
||||
)
|
||||
|
||||
|
||||
def _live_time_panel() -> rrb.TimePanel:
|
||||
"""Keep the hidden vendor timeline on its native live edge."""
|
||||
return rrb.TimePanel(
|
||||
timeline="stream_time",
|
||||
play_state="following",
|
||||
state="hidden",
|
||||
)
|
||||
|
||||
|
||||
def _point_colors(
|
||||
positions: np.ndarray,
|
||||
intensities: np.ndarray,
|
||||
|
||||
@@ -28,6 +28,10 @@ RuntimePhase = Literal[
|
||||
SourceMode = Literal["idle", "live", "replay"]
|
||||
StateCallback = Callable[[], None]
|
||||
BridgeFactory = Callable[..., RerunBridge]
|
||||
# TODO: replace the mixed-modality FIFO with a latest point-cloud slot and a
|
||||
# bounded pose queue. The compact queue protects acquisition from a slow
|
||||
# visualizer, but under sustained pressure it can still evict pose messages.
|
||||
PREVIEW_QUEUE_SIZE = 4
|
||||
|
||||
|
||||
class CanonicalNormalizer(Protocol):
|
||||
@@ -191,7 +195,13 @@ class VisualizationRuntime:
|
||||
self._bridge = None
|
||||
self._rerun_grpc_url = None
|
||||
if bridge is not None:
|
||||
bridge.close()
|
||||
try:
|
||||
bridge.close()
|
||||
except BaseException as exc:
|
||||
self._finish_error(
|
||||
f"Ошибка завершения визуального моста: {type(exc).__name__}: {exc}"
|
||||
)
|
||||
raise
|
||||
self._notify()
|
||||
|
||||
def _start(
|
||||
@@ -308,7 +318,7 @@ class VisualizationRuntime:
|
||||
*,
|
||||
running_phase: RuntimePhase,
|
||||
) -> None:
|
||||
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32)
|
||||
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=PREVIEW_QUEUE_SIZE)
|
||||
source_done = threading.Event()
|
||||
publisher_ready = threading.Event()
|
||||
publisher_aborted = threading.Event()
|
||||
@@ -334,23 +344,28 @@ class VisualizationRuntime:
|
||||
def publish() -> None:
|
||||
bridge: RerunBridge | None = None
|
||||
try:
|
||||
bridge = self._bridge
|
||||
with self._lock:
|
||||
bridge = self._bridge
|
||||
if self._closed:
|
||||
publisher_aborted.set()
|
||||
if publisher_aborted.is_set():
|
||||
publisher_ready.set()
|
||||
return
|
||||
if bridge is None:
|
||||
candidate = self._bridge_factory(
|
||||
grpc_port=self._grpc_port,
|
||||
metrics=self._metrics,
|
||||
settings_provider=self._current_scene_settings,
|
||||
)
|
||||
bridge = candidate
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
publisher_aborted.set()
|
||||
else:
|
||||
self._bridge = candidate
|
||||
bridge = candidate
|
||||
if publisher_aborted.is_set():
|
||||
candidate.close()
|
||||
publisher_ready.set()
|
||||
return
|
||||
if publisher_aborted.is_set():
|
||||
publisher_ready.set()
|
||||
return
|
||||
assert bridge is not None
|
||||
bridge.begin_session(self._metrics)
|
||||
with self._lock:
|
||||
@@ -402,12 +417,19 @@ class VisualizationRuntime:
|
||||
finally:
|
||||
if bridge is not None:
|
||||
with self._lock:
|
||||
close_bridge = self._closed and self._bridge is bridge
|
||||
if close_bridge:
|
||||
close_bridge = self._closed and (
|
||||
self._bridge is bridge or publisher_aborted.is_set()
|
||||
)
|
||||
if close_bridge and self._bridge is bridge:
|
||||
self._bridge = None
|
||||
self._rerun_grpc_url = None
|
||||
if close_bridge:
|
||||
bridge.close()
|
||||
try:
|
||||
bridge.close()
|
||||
except BaseException as exc:
|
||||
# Process shutdown must not become a false successful
|
||||
# idle state when the native bridge failed to close.
|
||||
publisher_error.append(exc)
|
||||
|
||||
def join_publisher() -> None:
|
||||
# Never orphan a publisher: the session thread remains its owner.
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user