feat(plugins): isolate device integrations

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 19:29:32 +03:00
parent f9ffb7bd1c
commit 24a47318f2
122 changed files with 3304 additions and 1892 deletions
+9 -14
View File
@@ -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
+37 -6
View File
@@ -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)
+6 -8
View File
@@ -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="Не удалось подготовить настройки визуализатора.",
-974
View File
@@ -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
-84
View File
@@ -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