feat(plugins): isolate device integrations
This commit is contained in:
+9
-14
@@ -22,10 +22,7 @@ from k1link.sessions import (
|
||||
SessionRecordingMaterializer,
|
||||
SessionRecordingPreparationManager,
|
||||
SessionStore,
|
||||
recover_stale_active_session_marker,
|
||||
resolve_missioncore_evidence_dir,
|
||||
)
|
||||
from k1link.web.camera_archive import recover_incomplete_camera_archives
|
||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
from k1link.web.plugin_runtime import (
|
||||
@@ -42,13 +39,14 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
||||
|
||||
|
||||
legacy_observation_sessions_root = REPOSITORY_ROOT / "sessions"
|
||||
observation_sessions_root = resolve_missioncore_evidence_dir(REPOSITORY_ROOT)
|
||||
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||
session_store = SessionStore(REPOSITORY_ROOT)
|
||||
session_recording_materializer = SessionRecordingMaterializer(session_store.data_dir)
|
||||
session_recording_materializer = SessionRecordingMaterializer(
|
||||
session_store.data_dir,
|
||||
exporters=plugin_environment.recording_exporters,
|
||||
)
|
||||
session_recorded_media_inspector = RecordedMediaInspector(
|
||||
session_store.data_dir / "recorded-media-preparations"
|
||||
)
|
||||
@@ -76,8 +74,9 @@ def refresh_observation_catalog() -> tuple[str, ...]:
|
||||
"""Discover completed or recoverable local evidence without copying payloads."""
|
||||
|
||||
imported = [
|
||||
*session_store.import_legacy_viewer_live(legacy_observation_sessions_root),
|
||||
*session_store.import_legacy_viewer_live(observation_sessions_root),
|
||||
session_id
|
||||
for archive in plugin_environment.observation_archives
|
||||
for session_id in session_store.reconcile_archive(archive)
|
||||
]
|
||||
return tuple(dict.fromkeys(imported))
|
||||
|
||||
@@ -138,12 +137,8 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
# Recovery is intentionally a one-shot startup phase. The archive
|
||||
# helper owns a cross-process lease, while ordinary catalog requests
|
||||
# only perform discovery and therefore never touch a live writer.
|
||||
await asyncio.to_thread(recover_stale_active_session_marker, observation_sessions_root)
|
||||
for sessions_root in (
|
||||
legacy_observation_sessions_root,
|
||||
observation_sessions_root,
|
||||
):
|
||||
await asyncio.to_thread(recover_incomplete_camera_archives, sessions_root)
|
||||
for archive in plugin_environment.observation_archives:
|
||||
await asyncio.to_thread(archive.recover, archive.root)
|
||||
# Full evidence discovery, hashing and RRD queue reconciliation can be
|
||||
# expensive on field captures. Start it immediately in the background
|
||||
# instead of holding the ASGI startup gate.
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from k1link.sessions.plugin_contract import ObservationArchiveSource, RecordingExporter
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
||||
from k1link.web.plugin_runtime import (
|
||||
DevicePluginDispatcher,
|
||||
@@ -25,6 +26,23 @@ class InstalledDevicePluginEnvironment:
|
||||
legacy_routers: tuple[APIRouter, ...]
|
||||
_contributions: tuple[DevicePluginRuntimeContribution, ...]
|
||||
|
||||
@property
|
||||
def observation_archives(self) -> tuple[ObservationArchiveSource, ...]:
|
||||
return tuple(
|
||||
archive
|
||||
for contribution in self._contributions
|
||||
if contribution.observation is not None
|
||||
for archive in contribution.observation.archives
|
||||
)
|
||||
|
||||
@property
|
||||
def recording_exporters(self) -> dict[str, RecordingExporter]:
|
||||
return {
|
||||
contribution.adapter.plugin_id: contribution.observation.recording_exporter
|
||||
for contribution in self._contributions
|
||||
if contribution.observation is not None
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
cleanup_errors = _close_contributions(self._contributions)
|
||||
if cleanup_errors:
|
||||
@@ -103,6 +121,31 @@ def _load_contribution(
|
||||
raise DevicePluginCompositionError(
|
||||
f"Device-plugin manifest/runtime actions mismatch for {adapter.plugin_id}"
|
||||
)
|
||||
observation = contribution.observation
|
||||
if observation is not None:
|
||||
if not observation.archives:
|
||||
raise DevicePluginCompositionError(
|
||||
f"Device-plugin observation contribution is empty for {adapter.plugin_id}"
|
||||
)
|
||||
archive_ids: set[str] = set()
|
||||
archive_roots: set[Path] = set()
|
||||
for archive in observation.archives:
|
||||
if archive.plugin_id != adapter.plugin_id:
|
||||
raise DevicePluginCompositionError(
|
||||
"Device-plugin observation/runtime id mismatch: "
|
||||
f"{archive.plugin_id} != {adapter.plugin_id}"
|
||||
)
|
||||
if archive.archive_id in archive_ids:
|
||||
raise DevicePluginCompositionError(
|
||||
f"Duplicate observation archive id for {adapter.plugin_id}"
|
||||
)
|
||||
resolved_root = archive.root.expanduser().resolve()
|
||||
if resolved_root in archive_roots:
|
||||
raise DevicePluginCompositionError(
|
||||
f"Duplicate observation archive root for {adapter.plugin_id}"
|
||||
)
|
||||
archive_ids.add(archive.archive_id)
|
||||
archive_roots.add(resolved_root)
|
||||
except Exception as exc:
|
||||
_add_cleanup_notes(exc, _close_contributions((contribution,)))
|
||||
raise
|
||||
|
||||
@@ -2,11 +2,16 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter
|
||||
from missioncore_plugin_sdk.v0alpha2 import RuntimeActionInvocation, RuntimeActionResult
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.sessions.plugin_contract import ObservationRuntimeContribution
|
||||
|
||||
STATE_READ_ACTION_ID = "state.read"
|
||||
|
||||
|
||||
@@ -20,7 +25,7 @@ class DevicePluginActionAdapter(Protocol):
|
||||
plugin_id: str
|
||||
action_ids: frozenset[str]
|
||||
|
||||
async def invoke(self, action_id: str, payload: Mapping[str, Any]) -> dict[str, Any]: ...
|
||||
async def invoke(self, invocation: RuntimeActionInvocation) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
def _noop() -> None:
|
||||
@@ -33,6 +38,7 @@ class DevicePluginRuntimeContribution:
|
||||
|
||||
adapter: DevicePluginActionAdapter
|
||||
legacy_routers: tuple[APIRouter, ...] = ()
|
||||
observation: ObservationRuntimeContribution | None = None
|
||||
close: Callable[[], None] = _noop
|
||||
|
||||
|
||||
@@ -71,8 +77,33 @@ class DevicePluginDispatcher:
|
||||
adapter = self._adapters.get(plugin_id)
|
||||
if adapter is None:
|
||||
raise PluginNotFoundError(f"Device plugin is not installed: {plugin_id}")
|
||||
if action_id not in adapter.action_ids:
|
||||
raise PluginActionNotFoundError(
|
||||
f"Device plugin {plugin_id} does not declare action {action_id}"
|
||||
)
|
||||
return await adapter.invoke(action_id, payload)
|
||||
return await invoke_device_plugin_adapter(adapter, action_id, payload)
|
||||
|
||||
|
||||
async def invoke_device_plugin_adapter(
|
||||
adapter: DevicePluginActionAdapter,
|
||||
action_id: str,
|
||||
payload: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Validate SDK request/result envelopes around one plugin action call."""
|
||||
|
||||
if action_id not in adapter.action_ids:
|
||||
raise PluginActionNotFoundError(
|
||||
f"Device plugin {adapter.plugin_id} does not declare action {action_id}"
|
||||
)
|
||||
invocation = RuntimeActionInvocation(
|
||||
invocation_id=uuid4().hex,
|
||||
plugin_id=adapter.plugin_id,
|
||||
action_id=action_id,
|
||||
requested_at=datetime.now(UTC),
|
||||
parameters=dict(payload),
|
||||
)
|
||||
output = await adapter.invoke(invocation)
|
||||
result = RuntimeActionResult(
|
||||
invocation_id=invocation.invocation_id,
|
||||
plugin_id=invocation.plugin_id,
|
||||
action_id=invocation.action_id,
|
||||
completed_at=datetime.now(UTC),
|
||||
output=dict(output),
|
||||
)
|
||||
return dict(result.output)
|
||||
|
||||
@@ -31,16 +31,15 @@ from k1link.sessions import (
|
||||
SessionStore,
|
||||
validate_recorded_media_timeline,
|
||||
)
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
from k1link.viewer.rrd_export import (
|
||||
from k1link.viewer.recorded import (
|
||||
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
||||
)
|
||||
from k1link.viewer.rrd_export import (
|
||||
RrdExportError,
|
||||
from k1link.viewer.recorded import (
|
||||
RecordedBlueprintError,
|
||||
recorded_blueprint_rrd,
|
||||
)
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
|
||||
DEFAULT_REPLAY_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
|
||||
SAFE_SOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
|
||||
SAFE_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
@@ -234,7 +233,6 @@ def build_session_router(
|
||||
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
|
||||
media_inspector: RecordedMediaInspector | None = None,
|
||||
allow_synchronous_recording_fallback: bool = False,
|
||||
replay_plugin_id: str = DEFAULT_REPLAY_PLUGIN_ID,
|
||||
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
||||
) -> APIRouter:
|
||||
"""Build generic host APIs without exposing filesystem locators to clients."""
|
||||
@@ -388,7 +386,7 @@ def build_session_router(
|
||||
"schema_version": "missioncore.observation-session-replay/v1",
|
||||
"launch": {
|
||||
"kind": "plugin-action",
|
||||
"plugin_id": replay_plugin_id,
|
||||
"plugin_id": command.plugin_id,
|
||||
"action_id": replay_action_id,
|
||||
"session_id": command.session_id,
|
||||
"speed": command.speed,
|
||||
@@ -671,7 +669,7 @@ def build_session_router(
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except RrdExportError as exc:
|
||||
except RecordedBlueprintError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Не удалось подготовить настройки визуализатора.",
|
||||
|
||||
@@ -1,974 +0,0 @@
|
||||
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
|
||||
from k1link.web.camera_archive import (
|
||||
CameraArchiveError,
|
||||
CameraArchiveKind,
|
||||
CameraArchiveStatus,
|
||||
CameraArchiveWriter,
|
||||
)
|
||||
|
||||
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
|
||||
CAMERA_DRAIN_TIMEOUT_SECONDS: Final = 5.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraProcessLease:
|
||||
generation: int
|
||||
source_id: CameraSourceId
|
||||
process: subprocess.Popen[bytes]
|
||||
stderr_tail: deque[str]
|
||||
segments: queue.Queue[tuple[str, bytes] | None] = field(
|
||||
default_factory=lambda: queue.Queue(maxsize=MAX_QUEUED_SEGMENTS)
|
||||
)
|
||||
failure_code: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CameraProducer:
|
||||
generation: int
|
||||
source_id: CameraSourceId
|
||||
process: subprocess.Popen[bytes]
|
||||
archive: CameraArchiveWriter | None
|
||||
stderr_tail: deque[str] = field(default_factory=lambda: deque(maxlen=12))
|
||||
delivery: CameraProcessLease | None = None
|
||||
init_segment: bytes | None = None
|
||||
failure_code: str | None = None
|
||||
stop_requested: bool = False
|
||||
drain_requested: bool = False
|
||||
reader_started: bool = False
|
||||
reader_done: threading.Event = field(default_factory=threading.Event)
|
||||
|
||||
|
||||
class XgridsK1CameraGateway:
|
||||
"""One fail-closed K1 producer with independent archive and preview planes.
|
||||
|
||||
During an acquisition the gateway, rather than a browser WebSocket, owns
|
||||
FFmpeg. Every complete fMP4 segment is durably appended before it can enter
|
||||
the bounded preview queue. Attaching, dropping, or disconnecting a browser
|
||||
therefore cannot stop or back-pressure the source-of-record camera stream.
|
||||
Outside an acquisition the legacy lazy-preview lifecycle remains available.
|
||||
"""
|
||||
|
||||
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._lifecycle_lock = threading.Lock()
|
||||
self._revision = 0
|
||||
self._generation = 0
|
||||
self._phase = "idle"
|
||||
self._source_id: CameraSourceId | None = None
|
||||
self._target_host: str | None = None
|
||||
self._producer: _CameraProducer | None = None
|
||||
self._recording_root: Path | None = None
|
||||
self._archive_summaries: list[dict[str, Any]] = []
|
||||
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,
|
||||
},
|
||||
"recording": {
|
||||
"active": self._recording_root is not None,
|
||||
"session": (
|
||||
self._recording_root.name if self._recording_root is not None else None
|
||||
),
|
||||
"active_epoch": (
|
||||
self._producer.generation
|
||||
if self._producer is not None and self._producer.archive is not None
|
||||
else None
|
||||
),
|
||||
"completed_epochs": len(self._archive_summaries),
|
||||
"last_summary": (
|
||||
dict(self._archive_summaries[-1]) if self._archive_summaries else None
|
||||
),
|
||||
},
|
||||
"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)
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
self._require_open_locked()
|
||||
if self._ffmpeg_path is None:
|
||||
self._generation += 1
|
||||
self._revision += 1
|
||||
self._source_id = source_id
|
||||
self._target_host = target
|
||||
self._set_error_locked(
|
||||
"ffmpeg-unavailable",
|
||||
"Локальный 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_producer, old_delivery = self._detach_producer_locked()
|
||||
self._generation += 1
|
||||
self._revision += 1
|
||||
self._source_id = source_id
|
||||
self._target_host = target
|
||||
self._phase = "selected"
|
||||
self._error = None
|
||||
recording_active = self._recording_root is not None
|
||||
|
||||
self._shutdown_producer(
|
||||
old_producer,
|
||||
old_delivery,
|
||||
status="complete",
|
||||
failure_code="source-switch",
|
||||
)
|
||||
if recording_active:
|
||||
self._spawn_selected_producer()
|
||||
return self.snapshot()
|
||||
|
||||
def stop(self, generation: int) -> dict[str, Any]:
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
if self._source_id is None:
|
||||
return self.snapshot()
|
||||
if generation != self._generation:
|
||||
raise ValueError("camera preview generation устарело")
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
self._revision += 1
|
||||
self._phase = "idle"
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._error = None
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
delivery,
|
||||
status="complete",
|
||||
failure_code="source-stopped",
|
||||
)
|
||||
return self.snapshot()
|
||||
|
||||
def stop_current(self) -> dict[str, Any]:
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
producer, delivery = self._detach_producer_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._recording_root = None
|
||||
self._error = None
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
delivery,
|
||||
status="interrupted",
|
||||
failure_code="gateway-stop",
|
||||
)
|
||||
return self.snapshot()
|
||||
|
||||
def start_recording(self, session_dir: Path) -> dict[str, Any]:
|
||||
"""Make an existing observation session the camera recording root."""
|
||||
|
||||
root = session_dir.expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
raise ValueError("observation session directory does not exist")
|
||||
if not root.is_relative_to(self._repository_root):
|
||||
raise ValueError("camera recording root must stay inside the repository")
|
||||
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
self._require_open_locked()
|
||||
if self._recording_root is not None:
|
||||
if self._recording_root == root:
|
||||
return self.snapshot()
|
||||
raise RuntimeError("для camera gateway уже активна другая acquisition-сессия")
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
self._recording_root = root
|
||||
self._archive_summaries = []
|
||||
selected = self._source_id is not None
|
||||
if selected:
|
||||
self._phase = "selected"
|
||||
self._revision += 1
|
||||
|
||||
# A pre-acquisition browser-owned process cannot become evidence
|
||||
# retrospectively. Restart it at a clean codec epoch instead.
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
delivery,
|
||||
status="interrupted",
|
||||
failure_code="recording-start-restart",
|
||||
)
|
||||
if selected:
|
||||
self._spawn_selected_producer()
|
||||
return self.snapshot()
|
||||
|
||||
def stop_recording(
|
||||
self,
|
||||
*,
|
||||
status: CameraArchiveStatus = "complete",
|
||||
failure_code: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Stop the acquisition-owned producer and seal its active epoch."""
|
||||
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
recording_was_active = self._recording_root is not None
|
||||
self._recording_root = None
|
||||
if self._source_id is not None and self._phase != "error":
|
||||
self._phase = "selected"
|
||||
if recording_was_active or producer is not None:
|
||||
self._revision += 1
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
delivery,
|
||||
status=status,
|
||||
failure_code=failure_code,
|
||||
)
|
||||
return self.snapshot()
|
||||
|
||||
def open_delivery(self, generation: int) -> CameraProcessLease:
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
self._require_open_locked()
|
||||
if generation != self._generation or self._source_id is None:
|
||||
raise ValueError("camera preview generation не активно")
|
||||
producer = self._producer
|
||||
if producer is None:
|
||||
producer = self._spawn_selected_producer()
|
||||
with self._lock:
|
||||
if self._producer is not producer or producer.generation != generation:
|
||||
raise ValueError("camera preview generation не активно")
|
||||
if producer.delivery is not None:
|
||||
raise RuntimeError("для camera preview уже открыт browser consumer")
|
||||
lease = CameraProcessLease(
|
||||
generation=producer.generation,
|
||||
source_id=producer.source_id,
|
||||
process=producer.process,
|
||||
stderr_tail=producer.stderr_tail,
|
||||
)
|
||||
producer.delivery = lease
|
||||
if producer.init_segment is not None:
|
||||
lease.segments.put_nowait(("init", producer.init_segment))
|
||||
self._revision += 1
|
||||
return lease
|
||||
|
||||
def mark_streaming(self, lease: CameraProcessLease) -> None:
|
||||
with self._lock:
|
||||
producer = self._producer
|
||||
if producer is None or producer.delivery is not lease:
|
||||
return
|
||||
self._mark_streaming_locked(producer)
|
||||
|
||||
def release_delivery(self, lease: CameraProcessLease, *, client_closed: bool) -> None:
|
||||
producer_to_stop: _CameraProducer | None = None
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
producer = self._producer
|
||||
if producer is None or producer.delivery is not lease:
|
||||
return
|
||||
producer.delivery = None
|
||||
self._revision += 1
|
||||
# During acquisition the browser is a disposable observer. In
|
||||
# legacy preview-only mode retain the old lazy-owner behavior.
|
||||
if self._recording_root is None:
|
||||
producer.stop_requested = True
|
||||
self._producer = None
|
||||
producer_to_stop = producer
|
||||
self._phase = "selected"
|
||||
self._error = None
|
||||
if producer_to_stop is not None:
|
||||
self._shutdown_producer(
|
||||
producer_to_stop,
|
||||
None,
|
||||
status="interrupted",
|
||||
failure_code=(lease.failure_code or "browser-disconnected"),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lifecycle_lock:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
self._revision += 1
|
||||
self._phase = "idle"
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._recording_root = None
|
||||
self._error = None
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
delivery,
|
||||
status="interrupted",
|
||||
failure_code="gateway-closed",
|
||||
)
|
||||
|
||||
def _spawn_selected_producer(self) -> _CameraProducer:
|
||||
with self._lock:
|
||||
self._require_open_locked()
|
||||
source_id = self._source_id
|
||||
target_host = self._target_host
|
||||
ffmpeg_path = self._ffmpeg_path
|
||||
generation = self._generation
|
||||
recording_root = self._recording_root
|
||||
if source_id is None or target_host is None or ffmpeg_path is None:
|
||||
raise RuntimeError("camera preview runtime не готов")
|
||||
if self._producer is not None:
|
||||
return self._producer
|
||||
|
||||
archive: CameraArchiveWriter | None = None
|
||||
if recording_root is not None:
|
||||
try:
|
||||
archive = CameraArchiveWriter(recording_root, source_id, generation)
|
||||
except (OSError, ValueError, CameraArchiveError) as exc:
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"camera-storage-failed",
|
||||
"Не удалось открыть долговременное хранилище camera stream.",
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Не удалось открыть долговременное хранилище camera stream."
|
||||
) from exc
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
_build_ffmpeg_argv(ffmpeg_path, target_host, source_id),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
shell=False,
|
||||
start_new_session=(os.name == "posix"),
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
if archive is not None:
|
||||
with suppress(CameraArchiveError):
|
||||
self._record_archive_summary(
|
||||
archive.close(status="failed", failure_code="ffmpeg-start-failed")
|
||||
)
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"ffmpeg-start-failed",
|
||||
"Не удалось запустить локальный camera adapter.",
|
||||
)
|
||||
raise RuntimeError("Не удалось запустить локальный camera adapter.") from exc
|
||||
|
||||
if process.stdout is None or process.stderr is None:
|
||||
_terminate_process(process)
|
||||
if archive is not None:
|
||||
self._record_archive_summary(
|
||||
archive.close(status="failed", failure_code="ffmpeg-pipes-unavailable")
|
||||
)
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"ffmpeg-pipes-unavailable",
|
||||
"Camera adapter не открыл media pipes.",
|
||||
)
|
||||
raise RuntimeError("camera adapter не открыл media pipes")
|
||||
|
||||
producer = _CameraProducer(
|
||||
generation=generation,
|
||||
source_id=source_id,
|
||||
process=process,
|
||||
archive=archive,
|
||||
)
|
||||
with self._lock:
|
||||
if (
|
||||
self._closed
|
||||
or generation != self._generation
|
||||
or source_id != self._source_id
|
||||
or target_host != self._target_host
|
||||
):
|
||||
producer.stop_requested = True
|
||||
stale = True
|
||||
else:
|
||||
self._producer = producer
|
||||
self._revision += 1
|
||||
self._phase = "connecting"
|
||||
self._error = None
|
||||
stale = False
|
||||
if stale:
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
None,
|
||||
status="interrupted",
|
||||
failure_code="stale-generation",
|
||||
)
|
||||
raise RuntimeError("camera generation изменилась во время запуска adapter")
|
||||
|
||||
threading.Thread(
|
||||
target=_drain_stderr,
|
||||
args=(producer,),
|
||||
name=f"k1-camera-stderr-{generation}",
|
||||
daemon=True,
|
||||
).start()
|
||||
producer.reader_started = True
|
||||
threading.Thread(
|
||||
target=_read_fmp4_stdout,
|
||||
args=(self, producer),
|
||||
name=f"k1-camera-fmp4-{generation}",
|
||||
daemon=True,
|
||||
).start()
|
||||
return producer
|
||||
|
||||
def _publish_segment(
|
||||
self,
|
||||
producer: _CameraProducer,
|
||||
kind: CameraArchiveKind,
|
||||
payload: bytes,
|
||||
) -> bool:
|
||||
if len(payload) > MAX_FMP4_SEGMENT_BYTES:
|
||||
self._mark_producer_failure(
|
||||
producer,
|
||||
"segment-too-large",
|
||||
"Camera adapter отклонил слишком большой video segment.",
|
||||
)
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
producer_owned = self._producer is producer or (
|
||||
producer.drain_requested and producer.archive is not None
|
||||
)
|
||||
if not producer_owned or producer.stop_requested:
|
||||
return False
|
||||
archive = producer.archive
|
||||
if archive is not None:
|
||||
try:
|
||||
# Source of record first; preview is always expendable.
|
||||
archive.append(kind, payload)
|
||||
except (CameraArchiveError, OSError, ValueError):
|
||||
self._mark_producer_failure(
|
||||
producer,
|
||||
"camera-storage-failed",
|
||||
"Долговременная запись camera stream завершилась ошибкой.",
|
||||
)
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
producer_owned = self._producer is producer or (
|
||||
producer.drain_requested and producer.archive is not None
|
||||
)
|
||||
if not producer_owned or producer.stop_requested:
|
||||
return False
|
||||
if kind == "init":
|
||||
producer.init_segment = payload
|
||||
else:
|
||||
self._mark_streaming_locked(producer)
|
||||
delivery = producer.delivery
|
||||
if delivery is None:
|
||||
return True
|
||||
try:
|
||||
delivery.segments.put_nowait((kind, payload))
|
||||
except queue.Full:
|
||||
self._drop_slow_delivery(producer, delivery)
|
||||
return True
|
||||
|
||||
def _drop_slow_delivery(
|
||||
self,
|
||||
producer: _CameraProducer,
|
||||
delivery: CameraProcessLease,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if self._producer is producer and producer.delivery is delivery:
|
||||
producer.delivery = None
|
||||
delivery.failure_code = "consumer-too-slow"
|
||||
self._revision += 1
|
||||
_close_segment_queue(delivery)
|
||||
|
||||
def _mark_producer_failure(
|
||||
self,
|
||||
producer: _CameraProducer,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
producer_owned = self._producer is producer or producer.drain_requested
|
||||
if not producer_owned or producer.stop_requested:
|
||||
return
|
||||
producer.failure_code = code
|
||||
self._set_error_locked(code, message)
|
||||
with suppress(OSError):
|
||||
producer.process.terminate()
|
||||
|
||||
def _producer_ended(self, producer: _CameraProducer) -> None:
|
||||
with self._lock:
|
||||
delivery = producer.delivery
|
||||
producer.delivery = None
|
||||
owns_shutdown = self._producer is producer and not producer.stop_requested
|
||||
if owns_shutdown:
|
||||
self._producer = None
|
||||
self._revision += 1
|
||||
if producer.failure_code is None:
|
||||
producer.failure_code = "camera-source-ended"
|
||||
self._set_error_locked(
|
||||
"camera-source-ended",
|
||||
_safe_ffmpeg_message(producer.stderr_tail),
|
||||
)
|
||||
if delivery is not None:
|
||||
delivery.failure_code = producer.failure_code or "camera-source-ended"
|
||||
_close_segment_queue(delivery)
|
||||
if not owns_shutdown:
|
||||
return
|
||||
_terminate_process(producer.process)
|
||||
status: CameraArchiveStatus = (
|
||||
"interrupted"
|
||||
if producer.failure_code
|
||||
in {"camera-source-ended", "incomplete-fmp4-fragment"}
|
||||
else "failed"
|
||||
)
|
||||
try:
|
||||
self._finalize_archive(producer, status, producer.failure_code)
|
||||
except CameraArchiveError:
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"camera-storage-finalize-failed",
|
||||
"Не удалось завершить долговременную запись camera stream.",
|
||||
)
|
||||
|
||||
def _shutdown_producer(
|
||||
self,
|
||||
producer: _CameraProducer | None,
|
||||
delivery: CameraProcessLease | None,
|
||||
*,
|
||||
status: CameraArchiveStatus,
|
||||
failure_code: str | None,
|
||||
) -> None:
|
||||
if delivery is not None:
|
||||
delivery.failure_code = producer.failure_code if producer is not None else failure_code
|
||||
_close_segment_queue(delivery)
|
||||
if producer is None:
|
||||
return
|
||||
_terminate_process(producer.process, close_streams=False)
|
||||
drained = (
|
||||
not producer.reader_started
|
||||
or producer.reader_done.wait(timeout=CAMERA_DRAIN_TIMEOUT_SECONDS)
|
||||
)
|
||||
_close_process_streams(producer.process)
|
||||
if not drained:
|
||||
producer.failure_code = "camera-drain-timeout"
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"camera-drain-timeout",
|
||||
"Camera adapter не завершил долговременную запись вовремя.",
|
||||
)
|
||||
effective_failure_code = producer.failure_code or failure_code
|
||||
effective_status: CameraArchiveStatus = (
|
||||
"interrupted"
|
||||
if producer.failure_code == "incomplete-fmp4-fragment"
|
||||
else "failed"
|
||||
if producer.failure_code is not None
|
||||
else status
|
||||
)
|
||||
if delivery is not None:
|
||||
delivery.failure_code = effective_failure_code
|
||||
try:
|
||||
self._finalize_archive(
|
||||
producer,
|
||||
effective_status,
|
||||
effective_failure_code,
|
||||
)
|
||||
except CameraArchiveError:
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
"camera-storage-finalize-failed",
|
||||
"Не удалось завершить долговременную запись camera stream.",
|
||||
)
|
||||
raise
|
||||
|
||||
def _finalize_archive(
|
||||
self,
|
||||
producer: _CameraProducer,
|
||||
status: CameraArchiveStatus,
|
||||
failure_code: str | None,
|
||||
) -> None:
|
||||
archive = producer.archive
|
||||
producer.archive = None
|
||||
if archive is None:
|
||||
return
|
||||
self._record_archive_summary(archive.close(status=status, failure_code=failure_code))
|
||||
|
||||
def _record_archive_summary(self, summary: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self._archive_summaries.append(dict(summary))
|
||||
|
||||
def _detach_producer_locked(
|
||||
self,
|
||||
) -> tuple[_CameraProducer | None, CameraProcessLease | None]:
|
||||
producer = self._producer
|
||||
self._producer = None
|
||||
if producer is None:
|
||||
return None, None
|
||||
producer.drain_requested = producer.archive is not None
|
||||
producer.stop_requested = not producer.drain_requested
|
||||
delivery = producer.delivery
|
||||
producer.delivery = None
|
||||
return producer, delivery
|
||||
|
||||
def _mark_streaming_locked(self, producer: _CameraProducer) -> None:
|
||||
if self._producer is producer and self._phase != "streaming":
|
||||
self._revision += 1
|
||||
self._phase = "streaming"
|
||||
|
||||
def _set_error_locked(self, code: str, message: str) -> None:
|
||||
self._revision += 1
|
||||
self._phase = "error"
|
||||
self._error = {"code": code, "message": message}
|
||||
|
||||
def _require_open_locked(self) -> None:
|
||||
if self._closed:
|
||||
raise RuntimeError("camera gateway уже закрыт")
|
||||
|
||||
|
||||
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 _close_segment_queue(lease: CameraProcessLease) -> None:
|
||||
try:
|
||||
lease.segments.put_nowait(None)
|
||||
except queue.Full:
|
||||
while True:
|
||||
try:
|
||||
lease.segments.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
lease.segments.put_nowait(None)
|
||||
|
||||
|
||||
def _read_fmp4_stdout(
|
||||
gateway: XgridsK1CameraGateway,
|
||||
producer: _CameraProducer,
|
||||
) -> None:
|
||||
stream = producer.process.stdout
|
||||
if stream is None:
|
||||
try:
|
||||
gateway._mark_producer_failure(
|
||||
producer,
|
||||
"invalid-fmp4",
|
||||
"Camera adapter вернул некорректный fMP4 stream.",
|
||||
)
|
||||
gateway._producer_ended(producer)
|
||||
finally:
|
||||
producer.reader_done.set()
|
||||
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 gateway._publish_segment(
|
||||
producer,
|
||||
"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 gateway._publish_segment(
|
||||
producer,
|
||||
"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:
|
||||
gateway._mark_producer_failure(
|
||||
producer,
|
||||
"invalid-fmp4",
|
||||
"Camera adapter вернул некорректный fMP4 stream.",
|
||||
)
|
||||
elif fragment_parts:
|
||||
gateway._mark_producer_failure(
|
||||
producer,
|
||||
"incomplete-fmp4-fragment",
|
||||
"Camera stream завершился на неполном video fragment.",
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
gateway._mark_producer_failure(
|
||||
producer,
|
||||
"invalid-fmp4",
|
||||
"Camera adapter вернул некорректный fMP4 stream.",
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
gateway._producer_ended(producer)
|
||||
finally:
|
||||
producer.reader_done.set()
|
||||
|
||||
|
||||
def _drain_stderr(producer: _CameraProducer) -> None:
|
||||
stream = producer.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:
|
||||
producer.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,
|
||||
*,
|
||||
close_streams: bool = True,
|
||||
) -> None:
|
||||
if process is None:
|
||||
return
|
||||
try:
|
||||
if process.poll() is None:
|
||||
if os.name == "posix":
|
||||
# The process can have exited or lost its dedicated process
|
||||
# group between poll() and killpg(). Either outcome means
|
||||
# there is no group left for us to signal; still reap the
|
||||
# child and finish the archive instead of aborting teardown.
|
||||
with suppress(OSError):
|
||||
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:
|
||||
if close_streams:
|
||||
_close_process_streams(process)
|
||||
|
||||
|
||||
def _close_process_streams(process: subprocess.Popen[bytes]) -> None:
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,84 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||
|
||||
from k1link.web.plugin_runtime import PluginExecutionError
|
||||
from k1link.web.xgrids_k1_facade import (
|
||||
ACTION_DISCOVERY_SCAN,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
ACTION_STATE_READ,
|
||||
ACTION_STREAM_START_LIVE,
|
||||
ACTION_STREAM_START_REPLAY,
|
||||
ACTION_STREAM_STOP,
|
||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||
BleScanRequest,
|
||||
ConnectRequest,
|
||||
LiveRequest,
|
||||
ReplayRequest,
|
||||
ViewerSettingsRequest,
|
||||
XgridsK1PluginFacade,
|
||||
)
|
||||
|
||||
|
||||
def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
|
||||
"""Temporary flat API kept for scripts created before the plugin boundary."""
|
||||
|
||||
router = APIRouter(include_in_schema=True)
|
||||
|
||||
@router.get("/api/state", deprecated=True)
|
||||
async def get_state() -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_STATE_READ, {})
|
||||
|
||||
@router.post("/api/ble/scan", deprecated=True)
|
||||
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_DISCOVERY_SCAN, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
|
||||
|
||||
@router.post("/api/connect", deprecated=True)
|
||||
async def connect(request: ConnectRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_NETWORK_PROVISION, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
|
||||
) from exc
|
||||
|
||||
@router.post("/api/session/live", deprecated=True)
|
||||
async def start_live(request: LiveRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_STREAM_START_LIVE, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/api/session/replay", deprecated=True)
|
||||
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return await adapter.invoke(ACTION_STREAM_START_REPLAY, request.model_dump())
|
||||
except (PluginExecutionError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/api/session/stop", deprecated=True)
|
||||
async def stop_session() -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_STREAM_STOP, {})
|
||||
|
||||
@router.post("/api/viewer/settings", deprecated=True)
|
||||
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
|
||||
return await adapter.invoke(ACTION_VIEWER_SETTINGS_UPDATE, request.model_dump())
|
||||
|
||||
@router.websocket("/api/events")
|
||||
async def events(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
await websocket.send_json({"state": await adapter.invoke(ACTION_STATE_READ, {})})
|
||||
await asyncio.sleep(0.5)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user