feat(k1): integrate durable evidence with acquisition lifecycle
This commit is contained in:
parent
656f0c524d
commit
007ff9fff2
|
|
@ -514,9 +514,9 @@ class VisualizationRuntime:
|
||||||
callback()
|
callback()
|
||||||
|
|
||||||
|
|
||||||
def new_live_session_dir(repository_root: Path) -> Path:
|
def new_live_session_dir(sessions_root: Path) -> Path:
|
||||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
base = repository_root / "sessions" / f"{stamp}_viewer_live"
|
base = sessions_root / f"{stamp}_viewer_live"
|
||||||
candidate = base
|
candidate = base
|
||||||
suffix = 1
|
suffix = 1
|
||||||
while candidate.exists():
|
while candidate.exists():
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -13,6 +13,19 @@ from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from k1link import __version__
|
from k1link import __version__
|
||||||
|
from k1link.sessions import (
|
||||||
|
MaterializedRecording,
|
||||||
|
RecordedMediaInspector,
|
||||||
|
RecordedMediaManifest,
|
||||||
|
RecordingPreparationQueueFull,
|
||||||
|
ReplayCommand,
|
||||||
|
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.device_plugin_composition import load_installed_device_plugins
|
||||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||||
from k1link.web.plugin_runtime import (
|
from k1link.web.plugin_runtime import (
|
||||||
|
|
@ -23,21 +36,125 @@ from k1link.web.plugin_runtime import (
|
||||||
PluginExecutionError,
|
PluginExecutionError,
|
||||||
PluginNotFoundError,
|
PluginNotFoundError,
|
||||||
)
|
)
|
||||||
|
from k1link.web.session_api import build_session_router
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||||
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
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_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||||
|
session_store = SessionStore(REPOSITORY_ROOT)
|
||||||
|
session_recording_materializer = SessionRecordingMaterializer(session_store.data_dir)
|
||||||
|
session_recorded_media_inspector = RecordedMediaInspector(
|
||||||
|
session_store.data_dir / "recorded-media-preparations"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_recorded_media_for_launch(
|
||||||
|
command: ReplayCommand,
|
||||||
|
_: MaterializedRecording,
|
||||||
|
) -> tuple[RecordedMediaManifest, ...]:
|
||||||
|
"""Prepare all camera descriptors inside the background job."""
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
session_recorded_media_inspector.inspect(artifact, command)
|
||||||
|
for artifact in session_store.list_recorded_media(command.session_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
session_recording_preparation_manager = SessionRecordingPreparationManager(
|
||||||
|
session_recording_materializer,
|
||||||
|
ready_preparer=_prepare_recorded_media_for_launch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
return tuple(dict.fromkeys(imported))
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_replayable_recordings() -> tuple[str, ...]:
|
||||||
|
"""Reconcile finalized catalog sessions into the durable RRD work queue."""
|
||||||
|
|
||||||
|
enqueued: list[str] = []
|
||||||
|
cursor: str | None = None
|
||||||
|
while True:
|
||||||
|
page = session_store.list_recent(limit=100, cursor=cursor)
|
||||||
|
for summary in page.items:
|
||||||
|
if not summary.replayable or summary.status not in {
|
||||||
|
"ready",
|
||||||
|
"interrupted",
|
||||||
|
"failed",
|
||||||
|
}:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
command = session_store.prepare_replay(summary.session_id)
|
||||||
|
session_recording_preparation_manager.enqueue(
|
||||||
|
command,
|
||||||
|
retry_interrupted=True,
|
||||||
|
)
|
||||||
|
except RecordingPreparationQueueFull:
|
||||||
|
return tuple(enqueued)
|
||||||
|
except Exception:
|
||||||
|
# One stale/corrupt catalog row must not starve every valid
|
||||||
|
# finalized session that follows it in the reconciliation
|
||||||
|
# page. The row remains visible as failed/interrupted evidence
|
||||||
|
# and can be repaired independently.
|
||||||
|
continue
|
||||||
|
enqueued.append(summary.session_id)
|
||||||
|
cursor = page.next_cursor
|
||||||
|
if cursor is None:
|
||||||
|
return tuple(enqueued)
|
||||||
|
|
||||||
|
|
||||||
|
async def _recording_preparation_reconciler() -> None:
|
||||||
|
"""Discover newly completed/recovered captures without blocking requests."""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(refresh_observation_catalog)
|
||||||
|
await asyncio.to_thread(enqueue_replayable_recordings)
|
||||||
|
except Exception:
|
||||||
|
# A transient filesystem/catalog failure must not permanently
|
||||||
|
# disable preparation of sessions completed later in the run.
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
|
reconciler: asyncio.Task[None] | None = None
|
||||||
try:
|
try:
|
||||||
|
session_recording_preparation_manager.start()
|
||||||
|
# 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)
|
||||||
|
# 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.
|
||||||
|
reconciler = asyncio.create_task(_recording_preparation_reconciler())
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
if reconciler is not None:
|
||||||
|
reconciler.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await reconciler
|
||||||
|
await asyncio.to_thread(session_recording_preparation_manager.close)
|
||||||
plugin_environment.close()
|
plugin_environment.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -127,6 +244,18 @@ async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
|
||||||
for legacy_router in plugin_environment.legacy_routers:
|
for legacy_router in plugin_environment.legacy_routers:
|
||||||
app.include_router(legacy_router)
|
app.include_router(legacy_router)
|
||||||
|
|
||||||
|
app.include_router(
|
||||||
|
build_session_router(
|
||||||
|
session_store,
|
||||||
|
# Production discovery belongs to the startup/background reconciler.
|
||||||
|
# HTTP list/replay paths must never rescan evidence roots inline.
|
||||||
|
catalog_refresher=None,
|
||||||
|
recording_materializer=session_recording_materializer,
|
||||||
|
recording_preparation_manager=session_recording_preparation_manager,
|
||||||
|
media_inspector=session_recorded_media_inspector,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||||
if frontend_dist.is_dir():
|
if frontend_dist.is_dir():
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ from typing import IO, Any, Final, Literal
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from k1link.mqtt import validate_private_ipv4
|
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"]
|
CameraSourceId = Literal["sensor.camera.left", "sensor.camera.right"]
|
||||||
|
|
||||||
|
|
@ -31,6 +37,7 @@ CAMERA_EXCLUSIVE_GROUP: Final = "camera.preview.decoder"
|
||||||
MAX_FMP4_BOX_BYTES: Final = 8 * 1024 * 1024
|
MAX_FMP4_BOX_BYTES: Final = 8 * 1024 * 1024
|
||||||
MAX_FMP4_SEGMENT_BYTES: Final = 1024 * 1024
|
MAX_FMP4_SEGMENT_BYTES: Final = 1024 * 1024
|
||||||
MAX_QUEUED_SEGMENTS: Final = 4
|
MAX_QUEUED_SEGMENTS: Final = 4
|
||||||
|
CAMERA_DRAIN_TIMEOUT_SECONDS: Final = 5.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -38,34 +45,52 @@ class CameraProcessLease:
|
||||||
generation: int
|
generation: int
|
||||||
source_id: CameraSourceId
|
source_id: CameraSourceId
|
||||||
process: subprocess.Popen[bytes]
|
process: subprocess.Popen[bytes]
|
||||||
stderr_tail: deque[str] = field(default_factory=lambda: deque(maxlen=12))
|
stderr_tail: deque[str]
|
||||||
segments: queue.Queue[tuple[str, bytes] | None] = field(
|
segments: queue.Queue[tuple[str, bytes] | None] = field(
|
||||||
default_factory=lambda: queue.Queue(maxsize=MAX_QUEUED_SEGMENTS)
|
default_factory=lambda: queue.Queue(maxsize=MAX_QUEUED_SEGMENTS)
|
||||||
)
|
)
|
||||||
failure_code: str | None = None
|
failure_code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class XgridsK1CameraGateway:
|
@dataclass
|
||||||
"""One fail-closed K1 RTSP owner with a browser-safe fMP4 delivery plane.
|
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)
|
||||||
|
|
||||||
Selection and delivery are deliberately separate. A plugin action selects
|
|
||||||
one allowlisted producer and publishes an opaque generation. Only a matching
|
class XgridsK1CameraGateway:
|
||||||
WebSocket may then start FFmpeg. This keeps the device address and RTSP path
|
"""One fail-closed K1 producer with independent archive and preview planes.
|
||||||
out of the generic UI and prevents two browser windows from opening two K1
|
|
||||||
sessions.
|
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:
|
def __init__(self, repository_root: Path, plugin_id: str) -> None:
|
||||||
self._repository_root = repository_root.resolve()
|
self._repository_root = repository_root.resolve()
|
||||||
self._plugin_id = plugin_id
|
self._plugin_id = plugin_id
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
self._lifecycle_lock = threading.Lock()
|
||||||
self._revision = 0
|
self._revision = 0
|
||||||
self._generation = 0
|
self._generation = 0
|
||||||
self._phase = "idle"
|
self._phase = "idle"
|
||||||
self._source_id: CameraSourceId | None = None
|
self._source_id: CameraSourceId | None = None
|
||||||
self._target_host: str | None = None
|
self._target_host: str | None = None
|
||||||
self._process: subprocess.Popen[bytes] | None = None
|
self._producer: _CameraProducer | None = None
|
||||||
self._process_generation: int | None = None
|
self._recording_root: Path | None = None
|
||||||
|
self._archive_summaries: list[dict[str, Any]] = []
|
||||||
self._error: dict[str, str] | None = None
|
self._error: dict[str, str] | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._ffmpeg_path, self._ffmpeg_source = _resolve_ffmpeg(self._repository_root)
|
self._ffmpeg_path, self._ffmpeg_source = _resolve_ffmpeg(self._repository_root)
|
||||||
|
|
@ -97,6 +122,21 @@ class XgridsK1CameraGateway:
|
||||||
"exclusive_group": CAMERA_EXCLUSIVE_GROUP,
|
"exclusive_group": CAMERA_EXCLUSIVE_GROUP,
|
||||||
"max_active": 1,
|
"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,
|
"delivery": delivery,
|
||||||
"runtime_dependency": {
|
"runtime_dependency": {
|
||||||
"kind": "ffmpeg",
|
"kind": "ffmpeg",
|
||||||
|
|
@ -110,21 +150,18 @@ class XgridsK1CameraGateway:
|
||||||
if source_id not in CAMERA_SOURCE_PATHS:
|
if source_id not in CAMERA_SOURCE_PATHS:
|
||||||
raise ValueError("неизвестный camera source")
|
raise ValueError("неизвестный camera source")
|
||||||
target = validate_private_ipv4(target_host)
|
target = validate_private_ipv4(target_host)
|
||||||
|
with self._lifecycle_lock:
|
||||||
old_process: subprocess.Popen[bytes] | None = None
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._closed:
|
self._require_open_locked()
|
||||||
raise RuntimeError("camera gateway уже закрыт")
|
|
||||||
if self._ffmpeg_path is None:
|
if self._ffmpeg_path is None:
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
self._revision += 1
|
self._revision += 1
|
||||||
self._source_id = source_id
|
self._source_id = source_id
|
||||||
self._target_host = target
|
self._target_host = target
|
||||||
self._phase = "error"
|
self._set_error_locked(
|
||||||
self._error = {
|
"ffmpeg-unavailable",
|
||||||
"code": "ffmpeg-unavailable",
|
"Локальный camera adapter FFmpeg не найден.",
|
||||||
"message": "Локальный camera adapter FFmpeg не найден.",
|
)
|
||||||
}
|
|
||||||
raise RuntimeError("локальный camera adapter FFmpeg не найден")
|
raise RuntimeError("локальный camera adapter FFmpeg не найден")
|
||||||
if (
|
if (
|
||||||
self._source_id == source_id
|
self._source_id == source_id
|
||||||
|
|
@ -133,169 +170,517 @@ class XgridsK1CameraGateway:
|
||||||
):
|
):
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
|
|
||||||
old_process = self._detach_process_locked()
|
old_producer, old_delivery = self._detach_producer_locked()
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
self._revision += 1
|
self._revision += 1
|
||||||
self._source_id = source_id
|
self._source_id = source_id
|
||||||
self._target_host = target
|
self._target_host = target
|
||||||
self._phase = "selected"
|
self._phase = "selected"
|
||||||
self._error = None
|
self._error = None
|
||||||
|
recording_active = self._recording_root is not None
|
||||||
|
|
||||||
_terminate_process(old_process)
|
self._shutdown_producer(
|
||||||
|
old_producer,
|
||||||
|
old_delivery,
|
||||||
|
status="complete",
|
||||||
|
failure_code="source-switch",
|
||||||
|
)
|
||||||
|
if recording_active:
|
||||||
|
self._spawn_selected_producer()
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
|
|
||||||
def stop(self, generation: int) -> dict[str, Any]:
|
def stop(self, generation: int) -> dict[str, Any]:
|
||||||
old_process: subprocess.Popen[bytes] | None
|
with self._lifecycle_lock:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._source_id is None:
|
if self._source_id is None:
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
if generation != self._generation:
|
if generation != self._generation:
|
||||||
raise ValueError("camera preview generation устарело")
|
raise ValueError("camera preview generation устарело")
|
||||||
old_process = self._detach_process_locked()
|
producer, delivery = self._detach_producer_locked()
|
||||||
self._revision += 1
|
self._revision += 1
|
||||||
self._phase = "idle"
|
self._phase = "idle"
|
||||||
self._source_id = None
|
self._source_id = None
|
||||||
self._target_host = None
|
self._target_host = None
|
||||||
self._error = None
|
self._error = None
|
||||||
_terminate_process(old_process)
|
self._shutdown_producer(
|
||||||
|
producer,
|
||||||
|
delivery,
|
||||||
|
status="complete",
|
||||||
|
failure_code="source-stopped",
|
||||||
|
)
|
||||||
return self.snapshot()
|
return self.snapshot()
|
||||||
|
|
||||||
def stop_current(self) -> dict[str, Any]:
|
def stop_current(self) -> dict[str, Any]:
|
||||||
old_process: subprocess.Popen[bytes] | None
|
with self._lifecycle_lock:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
old_process = self._detach_process_locked()
|
producer, delivery = self._detach_producer_locked()
|
||||||
changed = self._source_id is not None or self._phase != "idle"
|
changed = self._source_id is not None or self._phase != "idle"
|
||||||
if changed:
|
if changed:
|
||||||
self._revision += 1
|
self._revision += 1
|
||||||
self._phase = "idle"
|
self._phase = "idle"
|
||||||
self._source_id = None
|
self._source_id = None
|
||||||
self._target_host = None
|
self._target_host = None
|
||||||
|
self._recording_root = None
|
||||||
self._error = None
|
self._error = None
|
||||||
_terminate_process(old_process)
|
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()
|
return self.snapshot()
|
||||||
|
|
||||||
def open_delivery(self, generation: int) -> CameraProcessLease:
|
def open_delivery(self, generation: int) -> CameraProcessLease:
|
||||||
|
with self._lifecycle_lock:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._closed:
|
self._require_open_locked()
|
||||||
raise RuntimeError("camera gateway закрыт")
|
|
||||||
if generation != self._generation or self._source_id is None:
|
if generation != self._generation or self._source_id is None:
|
||||||
raise ValueError("camera preview generation не активно")
|
raise ValueError("camera preview generation не активно")
|
||||||
if self._process is not None:
|
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")
|
raise RuntimeError("для camera preview уже открыт browser consumer")
|
||||||
if self._ffmpeg_path is None or self._target_host is None:
|
lease = CameraProcessLease(
|
||||||
raise RuntimeError("camera preview runtime не готов")
|
generation=producer.generation,
|
||||||
|
source_id=producer.source_id,
|
||||||
argv = _build_ffmpeg_argv(
|
process=producer.process,
|
||||||
self._ffmpeg_path,
|
stderr_tail=producer.stderr_tail,
|
||||||
self._target_host,
|
|
||||||
self._source_id,
|
|
||||||
)
|
)
|
||||||
|
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:
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
argv,
|
_build_ffmpeg_argv(ffmpeg_path, target_host, source_id),
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
shell=False,
|
shell=False,
|
||||||
start_new_session=(os.name == "posix"),
|
start_new_session=(os.name == "posix"),
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except (OSError, ValueError) as exc:
|
||||||
self._revision += 1
|
if archive is not None:
|
||||||
self._phase = "error"
|
with suppress(CameraArchiveError):
|
||||||
self._error = {
|
self._record_archive_summary(
|
||||||
"code": "ffmpeg-start-failed",
|
archive.close(status="failed", failure_code="ffmpeg-start-failed")
|
||||||
"message": "Не удалось запустить локальный camera adapter.",
|
)
|
||||||
}
|
with self._lock:
|
||||||
raise RuntimeError("не удалось запустить camera adapter") from exc
|
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:
|
if process.stdout is None or process.stderr is None:
|
||||||
_terminate_process(process)
|
_terminate_process(process)
|
||||||
raise RuntimeError("camera adapter не открыл media pipes")
|
if archive is not None:
|
||||||
lease = CameraProcessLease(
|
self._record_archive_summary(
|
||||||
generation=generation,
|
archive.close(status="failed", failure_code="ffmpeg-pipes-unavailable")
|
||||||
source_id=self._source_id,
|
|
||||||
process=process,
|
|
||||||
)
|
)
|
||||||
self._process = process
|
with self._lock:
|
||||||
self._process_generation = generation
|
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._revision += 1
|
||||||
self._phase = "connecting"
|
self._phase = "connecting"
|
||||||
self._error = None
|
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(
|
threading.Thread(
|
||||||
target=_drain_stderr,
|
target=_drain_stderr,
|
||||||
args=(lease,),
|
args=(producer,),
|
||||||
name=f"k1-camera-stderr-{generation}",
|
name=f"k1-camera-stderr-{generation}",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
|
producer.reader_started = True
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=_read_fmp4_stdout,
|
target=_read_fmp4_stdout,
|
||||||
args=(lease,),
|
args=(self, producer),
|
||||||
name=f"k1-camera-fmp4-{generation}",
|
name=f"k1-camera-fmp4-{generation}",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
return lease
|
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
|
||||||
|
|
||||||
def mark_streaming(self, lease: CameraProcessLease) -> None:
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if (
|
producer_owned = self._producer is producer or (
|
||||||
lease.generation != self._generation
|
producer.drain_requested and producer.archive is not None
|
||||||
or self._process is not lease.process
|
)
|
||||||
or self._phase == "streaming"
|
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
|
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._revision += 1
|
||||||
self._phase = "streaming"
|
self._phase = "streaming"
|
||||||
|
|
||||||
def release_delivery(self, lease: CameraProcessLease, *, client_closed: bool) -> None:
|
def _set_error_locked(self, code: str, message: str) -> 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
|
self._revision += 1
|
||||||
if client_closed:
|
|
||||||
self._phase = "selected"
|
|
||||||
self._error = None
|
|
||||||
else:
|
|
||||||
self._phase = "error"
|
self._phase = "error"
|
||||||
if lease.failure_code == "consumer-too-slow":
|
self._error = {"code": code, "message": message}
|
||||||
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:
|
def _require_open_locked(self) -> None:
|
||||||
old_process: subprocess.Popen[bytes] | None
|
|
||||||
with self._lock:
|
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
raise RuntimeError("camera gateway уже закрыт")
|
||||||
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]:
|
def _resolve_ffmpeg(repository_root: Path) -> tuple[Path | None, str]:
|
||||||
|
|
@ -394,36 +779,10 @@ def _read_mp4_box(stream: IO[bytes]) -> tuple[bytes, bytes]:
|
||||||
return box_type, header + _read_exact(stream, size - len(header))
|
return box_type, header + _read_exact(stream, size - len(header))
|
||||||
|
|
||||||
|
|
||||||
def _enqueue_segment(lease: CameraProcessLease, kind: str, payload: bytes) -> bool:
|
def _close_segment_queue(lease: CameraProcessLease) -> None:
|
||||||
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:
|
try:
|
||||||
lease.segments.put_nowait(None)
|
lease.segments.put_nowait(None)
|
||||||
except queue.Full:
|
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:
|
while True:
|
||||||
try:
|
try:
|
||||||
lease.segments.get_nowait()
|
lease.segments.get_nowait()
|
||||||
|
|
@ -432,11 +791,21 @@ def _finish_segment_queue(lease: CameraProcessLease) -> None:
|
||||||
lease.segments.put_nowait(None)
|
lease.segments.put_nowait(None)
|
||||||
|
|
||||||
|
|
||||||
def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
|
def _read_fmp4_stdout(
|
||||||
stream = lease.process.stdout
|
gateway: XgridsK1CameraGateway,
|
||||||
|
producer: _CameraProducer,
|
||||||
|
) -> None:
|
||||||
|
stream = producer.process.stdout
|
||||||
if stream is None:
|
if stream is None:
|
||||||
lease.failure_code = "invalid-fmp4"
|
try:
|
||||||
_finish_segment_queue(lease)
|
gateway._mark_producer_failure(
|
||||||
|
producer,
|
||||||
|
"invalid-fmp4",
|
||||||
|
"Camera adapter вернул некорректный fMP4 stream.",
|
||||||
|
)
|
||||||
|
gateway._producer_ended(producer)
|
||||||
|
finally:
|
||||||
|
producer.reader_done.set()
|
||||||
return
|
return
|
||||||
|
|
||||||
init_parts: list[bytes] = []
|
init_parts: list[bytes] = []
|
||||||
|
|
@ -448,7 +817,11 @@ def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
|
||||||
if not init_sent:
|
if not init_sent:
|
||||||
init_parts.append(box)
|
init_parts.append(box)
|
||||||
if box_type == b"moov":
|
if box_type == b"moov":
|
||||||
if not _enqueue_segment(lease, "init", b"".join(init_parts)):
|
if not gateway._publish_segment(
|
||||||
|
producer,
|
||||||
|
"init",
|
||||||
|
b"".join(init_parts),
|
||||||
|
):
|
||||||
return
|
return
|
||||||
init_sent = True
|
init_sent = True
|
||||||
continue
|
continue
|
||||||
|
|
@ -459,7 +832,11 @@ def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
|
||||||
if fragment_parts:
|
if fragment_parts:
|
||||||
fragment_parts.append(box)
|
fragment_parts.append(box)
|
||||||
if box_type == b"mdat":
|
if box_type == b"mdat":
|
||||||
if not _enqueue_segment(lease, "media", b"".join(fragment_parts)):
|
if not gateway._publish_segment(
|
||||||
|
producer,
|
||||||
|
"media",
|
||||||
|
b"".join(fragment_parts),
|
||||||
|
):
|
||||||
return
|
return
|
||||||
fragment_parts = []
|
fragment_parts = []
|
||||||
continue
|
continue
|
||||||
|
|
@ -469,15 +846,32 @@ def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
|
||||||
fragment_parts.append(box)
|
fragment_parts.append(box)
|
||||||
except EOFError:
|
except EOFError:
|
||||||
if not init_sent:
|
if not init_sent:
|
||||||
lease.failure_code = "invalid-fmp4"
|
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):
|
except (OSError, ValueError):
|
||||||
lease.failure_code = "invalid-fmp4"
|
gateway._mark_producer_failure(
|
||||||
|
producer,
|
||||||
|
"invalid-fmp4",
|
||||||
|
"Camera adapter вернул некорректный fMP4 stream.",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
_finish_segment_queue(lease)
|
try:
|
||||||
|
gateway._producer_ended(producer)
|
||||||
|
finally:
|
||||||
|
producer.reader_done.set()
|
||||||
|
|
||||||
|
|
||||||
def _drain_stderr(lease: CameraProcessLease) -> None:
|
def _drain_stderr(producer: _CameraProducer) -> None:
|
||||||
stream = lease.process.stderr
|
stream = producer.process.stderr
|
||||||
if stream is None:
|
if stream is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|
@ -487,7 +881,7 @@ def _drain_stderr(lease: CameraProcessLease) -> None:
|
||||||
return
|
return
|
||||||
text = line.decode("utf-8", errors="replace").strip()
|
text = line.decode("utf-8", errors="replace").strip()
|
||||||
if text:
|
if text:
|
||||||
lease.stderr_tail.append(text[-240:])
|
producer.stderr_tail.append(text[-240:])
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -504,13 +898,21 @@ def _safe_ffmpeg_message(stderr_tail: deque[str]) -> str:
|
||||||
return "Camera stream завершился до первого пригодного видеофрагмента."
|
return "Camera stream завершился до первого пригодного видеофрагмента."
|
||||||
|
|
||||||
|
|
||||||
def _terminate_process(process: subprocess.Popen[bytes] | None) -> None:
|
def _terminate_process(
|
||||||
|
process: subprocess.Popen[bytes] | None,
|
||||||
|
*,
|
||||||
|
close_streams: bool = True,
|
||||||
|
) -> None:
|
||||||
if process is None:
|
if process is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if process.poll() is None:
|
if process.poll() is None:
|
||||||
if os.name == "posix":
|
if os.name == "posix":
|
||||||
with suppress(ProcessLookupError):
|
# 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)
|
os.killpg(process.pid, signal.SIGINT)
|
||||||
else:
|
else:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
|
|
@ -524,6 +926,11 @@ def _terminate_process(process: subprocess.Popen[bytes] | None) -> None:
|
||||||
process.kill()
|
process.kill()
|
||||||
process.wait(timeout=1.0)
|
process.wait(timeout=1.0)
|
||||||
finally:
|
finally:
|
||||||
|
if close_streams:
|
||||||
|
_close_process_streams(process)
|
||||||
|
|
||||||
|
|
||||||
|
def _close_process_streams(process: subprocess.Popen[bytes]) -> None:
|
||||||
for stream in (process.stdout, process.stderr):
|
for stream in (process.stdout, process.stderr):
|
||||||
if stream is not None:
|
if stream is not None:
|
||||||
with suppress(OSError):
|
with suppress(OSError):
|
||||||
|
|
@ -536,9 +943,7 @@ def build_xgrids_k1_camera_router(
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
router = APIRouter(include_in_schema=False)
|
router = APIRouter(include_in_schema=False)
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket(f"/api/v1/device-plugins/{plugin_id}/camera-preview/{{generation}}")
|
||||||
f"/api/v1/device-plugins/{plugin_id}/camera-preview/{{generation}}"
|
|
||||||
)
|
|
||||||
async def camera_preview(websocket: WebSocket, generation: int) -> None:
|
async def camera_preview(websocket: WebSocket, generation: int) -> None:
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import importlib.util
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from contextlib import suppress
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, Protocol, cast
|
from typing import Any, Literal, Protocol, cast
|
||||||
|
|
@ -20,6 +22,7 @@ from k1link.ble.scanner import scan
|
||||||
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
|
||||||
from k1link.mqtt import validate_private_ipv4
|
from k1link.mqtt import validate_private_ipv4
|
||||||
from k1link.protocol.normalizer import normalize_k1_message
|
from k1link.protocol.normalizer import normalize_k1_message
|
||||||
|
from k1link.sessions import ActiveSessionLease, resolve_missioncore_evidence_dir
|
||||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||||
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
|
||||||
from k1link.web.device_lifecycle import (
|
from k1link.web.device_lifecycle import (
|
||||||
|
|
@ -181,6 +184,7 @@ class XgridsK1CompatibilityService:
|
||||||
|
|
||||||
def __init__(self, repository_root: Path) -> None:
|
def __init__(self, repository_root: Path) -> None:
|
||||||
self.repository_root = repository_root.resolve()
|
self.repository_root = repository_root.resolve()
|
||||||
|
self.evidence_root = resolve_missioncore_evidence_dir(self.repository_root)
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._provisioning_gate = threading.Lock()
|
self._provisioning_gate = threading.Lock()
|
||||||
self._provisioning_active = False
|
self._provisioning_active = False
|
||||||
|
|
@ -206,6 +210,7 @@ class XgridsK1CompatibilityService:
|
||||||
self._acquisition_out_dir: Path | None = None
|
self._acquisition_out_dir: Path | None = None
|
||||||
self._acquisition_start_operation_id: str | None = None
|
self._acquisition_start_operation_id: str | None = None
|
||||||
self._acquisition_stop_operation_id: str | None = None
|
self._acquisition_stop_operation_id: str | None = None
|
||||||
|
self._acquisition_session_lease: ActiveSessionLease | None = None
|
||||||
# The host-owned visual runtime receives the vendor normalizer
|
# The host-owned visual runtime receives the vendor normalizer
|
||||||
# explicitly. There is no implicit K1 decoder in the visual layer.
|
# explicitly. There is no implicit K1 decoder in the visual layer.
|
||||||
self.runtime = VisualizationRuntime(normalizer=normalize_k1_message)
|
self.runtime = VisualizationRuntime(normalizer=normalize_k1_message)
|
||||||
|
|
@ -216,7 +221,8 @@ class XgridsK1CompatibilityService:
|
||||||
|
|
||||||
def state(self) -> dict[str, Any]:
|
def state(self) -> dict[str, Any]:
|
||||||
runtime = self.runtime.snapshot()
|
runtime = self.runtime.snapshot()
|
||||||
self._reconcile_acquisition(runtime)
|
camera_preview = self.camera_preview.snapshot()
|
||||||
|
self._reconcile_acquisition(runtime, camera_preview)
|
||||||
camera_preview = self.camera_preview.snapshot()
|
camera_preview = self.camera_preview.snapshot()
|
||||||
metrics = runtime["metrics"]
|
metrics = runtime["metrics"]
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|
@ -401,9 +407,6 @@ class XgridsK1CompatibilityService:
|
||||||
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||||
if request.device_id not in known_ids:
|
if request.device_id not in known_ids:
|
||||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
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
|
# Unwrap once at the provisioning service boundary. The plain value is
|
||||||
# kept only in this stack frame, included in a keyed request digest, and
|
# kept only in this stack frame, included in a keyed request digest, and
|
||||||
# passed to the reviewed BLE write boundary; it is never journaled.
|
# passed to the reviewed BLE write boundary; it is never journaled.
|
||||||
|
|
@ -463,12 +466,17 @@ class XgridsK1CompatibilityService:
|
||||||
)
|
)
|
||||||
self._provisioning_active = True
|
self._provisioning_active = True
|
||||||
|
|
||||||
|
# A reprovision can replace both the device session and its address.
|
||||||
|
# Revoke preview/producer state only after the active-acquisition
|
||||||
|
# guard; a rejected network write must never stop evidence capture.
|
||||||
|
self.camera_preview.stop_current()
|
||||||
|
|
||||||
self._set_operation(
|
self._set_operation(
|
||||||
"provisioning",
|
"provisioning",
|
||||||
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
|
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
|
||||||
)
|
)
|
||||||
session_dir = _new_operation_session_dir(
|
session_dir = _new_operation_session_dir(
|
||||||
self.repository_root,
|
self.evidence_root,
|
||||||
"viewer_wifi_provisioning",
|
"viewer_wifi_provisioning",
|
||||||
)
|
)
|
||||||
session_dir.mkdir(parents=True, exist_ok=False)
|
session_dir.mkdir(parents=True, exist_ok=False)
|
||||||
|
|
@ -654,7 +662,7 @@ class XgridsK1CompatibilityService:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self._acquisition = acquisition
|
self._acquisition = acquisition
|
||||||
self._acquisition_out_dir = new_live_session_dir(self.repository_root)
|
self._acquisition_out_dir = new_live_session_dir(self.evidence_root)
|
||||||
self._acquisition_start_operation_id = None
|
self._acquisition_start_operation_id = None
|
||||||
self._acquisition_stop_operation_id = None
|
self._acquisition_stop_operation_id = None
|
||||||
self._compatibility_attestation = _attestation_snapshot(
|
self._compatibility_attestation = _attestation_snapshot(
|
||||||
|
|
@ -725,12 +733,27 @@ class XgridsK1CompatibilityService:
|
||||||
message_code="acquisition.start.waiting_receiver_ready",
|
message_code="acquisition.start.waiting_receiver_ready",
|
||||||
)
|
)
|
||||||
self._acquisition_start_operation_id = operation.operation_id
|
self._acquisition_start_operation_id = operation.operation_id
|
||||||
|
lease = ActiveSessionLease.acquire(out_dir.parent, out_dir)
|
||||||
|
with self._lock:
|
||||||
|
if self._acquisition_session_lease is not None:
|
||||||
|
lease.release()
|
||||||
|
raise RuntimeError("evidence-сессия уже удерживается активным acquisition")
|
||||||
|
self._acquisition_session_lease = lease
|
||||||
self.runtime.start_live(
|
self.runtime.start_live(
|
||||||
acquisition.target_host,
|
acquisition.target_host,
|
||||||
out_dir,
|
out_dir,
|
||||||
duration_seconds=acquisition.duration_seconds,
|
duration_seconds=acquisition.duration_seconds,
|
||||||
)
|
)
|
||||||
|
self._arm_camera_recording(out_dir)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
with suppress(Exception):
|
||||||
|
self.camera_preview.stop_recording(
|
||||||
|
status="failed",
|
||||||
|
failure_code="acquisition-start-failed",
|
||||||
|
)
|
||||||
|
with suppress(Exception):
|
||||||
|
self.runtime.stop()
|
||||||
|
self._release_acquisition_session_lease()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||||
acquisition.transition("failed", message_code="acquisition.start.failed")
|
acquisition.transition("failed", message_code="acquisition.start.failed")
|
||||||
|
|
@ -845,8 +868,10 @@ class XgridsK1CompatibilityService:
|
||||||
try:
|
try:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
acquisition.transition("stopping", message_code="acquisition.stopping")
|
acquisition.transition("stopping", message_code="acquisition.stopping")
|
||||||
self.camera_preview.stop_current()
|
self._stop_acquisition_sources(
|
||||||
self.runtime.stop()
|
camera_status="complete",
|
||||||
|
camera_failure_code=None,
|
||||||
|
)
|
||||||
self._cancel_pending_acquisition_operations(
|
self._cancel_pending_acquisition_operations(
|
||||||
exclude_operation_id=operation.operation_id,
|
exclude_operation_id=operation.operation_id,
|
||||||
reason_code="superseded-by-stop",
|
reason_code="superseded-by-stop",
|
||||||
|
|
@ -915,8 +940,10 @@ class XgridsK1CompatibilityService:
|
||||||
return self.state()
|
return self.state()
|
||||||
try:
|
try:
|
||||||
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||||
self.camera_preview.stop_current()
|
self._stop_acquisition_sources(
|
||||||
self.runtime.stop()
|
camera_status="interrupted",
|
||||||
|
camera_failure_code="acquisition-aborted",
|
||||||
|
)
|
||||||
self._cancel_pending_acquisition_operations(
|
self._cancel_pending_acquisition_operations(
|
||||||
exclude_operation_id=operation.operation_id,
|
exclude_operation_id=operation.operation_id,
|
||||||
reason_code="superseded-by-abort",
|
reason_code="superseded-by-abort",
|
||||||
|
|
@ -997,10 +1024,10 @@ class XgridsK1CompatibilityService:
|
||||||
def stop(self) -> dict[str, Any]:
|
def stop(self) -> dict[str, Any]:
|
||||||
"""Deprecated capture-only shim; it never claims that K1 stopped scanning."""
|
"""Deprecated capture-only shim; it never claims that K1 stopped scanning."""
|
||||||
|
|
||||||
self.camera_preview.stop_current()
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
acquisition = self._acquisition
|
acquisition = self._acquisition
|
||||||
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
||||||
|
self.camera_preview.stop_current()
|
||||||
self.runtime.stop()
|
self.runtime.stop()
|
||||||
return self.state()
|
return self.state()
|
||||||
return self.stop_acquisition(
|
return self.stop_acquisition(
|
||||||
|
|
@ -1012,6 +1039,16 @@ class XgridsK1CompatibilityService:
|
||||||
|
|
||||||
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]:
|
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]:
|
||||||
target = self._camera_target_for_session(request.device_session_id)
|
target = self._camera_target_for_session(request.device_session_id)
|
||||||
|
with self._lock:
|
||||||
|
acquisition = self._acquisition
|
||||||
|
out_dir = self._acquisition_out_dir
|
||||||
|
acquisition_active = (
|
||||||
|
acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES
|
||||||
|
)
|
||||||
|
if acquisition_active:
|
||||||
|
if out_dir is None:
|
||||||
|
raise RuntimeError("для acquisition не выделена evidence-сессия")
|
||||||
|
self._arm_camera_recording(out_dir, require_session=True)
|
||||||
self.camera_preview.select(request.source_id, target)
|
self.camera_preview.select(request.source_id, target)
|
||||||
return self.state()
|
return self.state()
|
||||||
|
|
||||||
|
|
@ -1035,6 +1072,8 @@ class XgridsK1CompatibilityService:
|
||||||
f"{type(camera_error).__name__}: {camera_error}"
|
f"{type(camera_error).__name__}: {camera_error}"
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
self._release_acquisition_session_lease()
|
||||||
if camera_error is not None:
|
if camera_error is not None:
|
||||||
raise camera_error
|
raise camera_error
|
||||||
|
|
||||||
|
|
@ -1053,6 +1092,71 @@ class XgridsK1CompatibilityService:
|
||||||
)
|
)
|
||||||
return self.state()
|
return self.state()
|
||||||
|
|
||||||
|
def _arm_camera_recording(
|
||||||
|
self,
|
||||||
|
out_dir: Path,
|
||||||
|
*,
|
||||||
|
require_session: bool = False,
|
||||||
|
) -> None:
|
||||||
|
camera = self.camera_preview.snapshot()
|
||||||
|
if camera["recording"]["active"]:
|
||||||
|
self.camera_preview.start_recording(out_dir)
|
||||||
|
return
|
||||||
|
if not out_dir.is_dir():
|
||||||
|
if not require_session and camera["active_source_id"] is None:
|
||||||
|
# The real runtime creates the session in its source thread. If
|
||||||
|
# no camera has been selected yet, defer arming until selection
|
||||||
|
# instead of racing that mkdir. No media can be lost yet.
|
||||||
|
return
|
||||||
|
deadline = time.monotonic() + 5.0
|
||||||
|
while not out_dir.is_dir():
|
||||||
|
runtime = self.runtime.snapshot()
|
||||||
|
if runtime.get("phase") == "error":
|
||||||
|
raise RuntimeError(
|
||||||
|
"evidence-сессия не создана: live runtime завершился ошибкой"
|
||||||
|
)
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise RuntimeError(
|
||||||
|
"evidence-сессия не появилась вовремя для записи camera stream"
|
||||||
|
)
|
||||||
|
time.sleep(0.01)
|
||||||
|
self.camera_preview.start_recording(out_dir)
|
||||||
|
|
||||||
|
def _stop_acquisition_sources(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
camera_status: Literal["complete", "interrupted", "failed"],
|
||||||
|
camera_failure_code: str | None,
|
||||||
|
) -> None:
|
||||||
|
camera_error: Exception | None = None
|
||||||
|
try:
|
||||||
|
self.camera_preview.stop_recording(
|
||||||
|
status=camera_status,
|
||||||
|
failure_code=camera_failure_code,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
camera_error = exc
|
||||||
|
try:
|
||||||
|
self.runtime.stop()
|
||||||
|
except Exception as exc:
|
||||||
|
if camera_error is not None:
|
||||||
|
exc.add_note(
|
||||||
|
"camera archive cleanup also failed: "
|
||||||
|
f"{type(camera_error).__name__}: {camera_error}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._release_acquisition_session_lease()
|
||||||
|
if camera_error is not None:
|
||||||
|
raise camera_error
|
||||||
|
|
||||||
|
def _release_acquisition_session_lease(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
lease = self._acquisition_session_lease
|
||||||
|
self._acquisition_session_lease = None
|
||||||
|
if lease is not None:
|
||||||
|
lease.release()
|
||||||
|
|
||||||
def _set_operation(self, phase: str, message: str) -> None:
|
def _set_operation(self, phase: str, message: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._operation_phase = phase
|
self._operation_phase = phase
|
||||||
|
|
@ -1127,13 +1231,20 @@ class XgridsK1CompatibilityService:
|
||||||
raise ValueError("указана неизвестная acquisition-сессия")
|
raise ValueError("указана неизвестная acquisition-сессия")
|
||||||
return acquisition
|
return acquisition
|
||||||
|
|
||||||
def _reconcile_acquisition(self, runtime: Mapping[str, Any]) -> None:
|
def _reconcile_acquisition(
|
||||||
|
self,
|
||||||
|
runtime: Mapping[str, Any],
|
||||||
|
camera: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
completed_operation_id: str | None = None
|
completed_operation_id: str | None = None
|
||||||
failed_operation_id: str | None = None
|
failed_operation_id: str | None = None
|
||||||
failed_stop_operation_id: str | None = None
|
failed_stop_operation_id: str | None = None
|
||||||
unconfirmed_stop_operation_id: str | None = None
|
unconfirmed_stop_operation_id: str | None = None
|
||||||
receiver_ready_operation_id: str | None = None
|
receiver_ready_operation_id: str | None = None
|
||||||
acquisition_id: str | None = None
|
acquisition_id: str | None = None
|
||||||
|
camera_terminal_status: Literal["complete", "failed"] | None = None
|
||||||
|
camera_failure_code: str | None = None
|
||||||
|
stop_runtime_for_camera_failure = False
|
||||||
with self._lock:
|
with self._lock:
|
||||||
acquisition = self._acquisition
|
acquisition = self._acquisition
|
||||||
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
||||||
|
|
@ -1143,7 +1254,42 @@ class XgridsK1CompatibilityService:
|
||||||
phase = runtime.get("phase")
|
phase = runtime.get("phase")
|
||||||
source_mode = runtime.get("source_mode")
|
source_mode = runtime.get("source_mode")
|
||||||
source_ready = runtime.get("source_ready") is True
|
source_ready = runtime.get("source_ready") is True
|
||||||
if acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
|
camera_recording = camera.get("recording")
|
||||||
|
camera_recording_active = (
|
||||||
|
isinstance(camera_recording, dict) and camera_recording.get("active") is True
|
||||||
|
)
|
||||||
|
camera_error = camera.get("error")
|
||||||
|
if camera.get("phase") == "error" and camera_recording_active:
|
||||||
|
waiting_for_start = acquisition.state in {
|
||||||
|
"starting",
|
||||||
|
"awaiting_external_start",
|
||||||
|
}
|
||||||
|
waiting_for_stop = acquisition.state in {
|
||||||
|
"awaiting_external_stop",
|
||||||
|
"stopping",
|
||||||
|
"finalizing",
|
||||||
|
}
|
||||||
|
camera_failure_code = (
|
||||||
|
str(camera_error.get("code"))
|
||||||
|
if isinstance(camera_error, dict) and camera_error.get("code")
|
||||||
|
else "camera-producer-failed"
|
||||||
|
)
|
||||||
|
acquisition.transition(
|
||||||
|
"failed",
|
||||||
|
message_code="acquisition.camera_failed",
|
||||||
|
result={
|
||||||
|
"receiver_stopped": False,
|
||||||
|
"device_state": "unknown",
|
||||||
|
"camera_failure_code": camera_failure_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if waiting_for_start:
|
||||||
|
failed_operation_id = self._acquisition_start_operation_id
|
||||||
|
if waiting_for_stop:
|
||||||
|
failed_stop_operation_id = self._acquisition_stop_operation_id
|
||||||
|
camera_terminal_status = "failed"
|
||||||
|
stop_runtime_for_camera_failure = True
|
||||||
|
elif acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
|
||||||
acquisition.transition("acquiring", message_code="acquisition.acquiring")
|
acquisition.transition("acquiring", message_code="acquisition.acquiring")
|
||||||
completed_operation_id = self._acquisition_start_operation_id
|
completed_operation_id = self._acquisition_start_operation_id
|
||||||
acquisition_id = acquisition.acquisition_id
|
acquisition_id = acquisition.acquisition_id
|
||||||
|
|
@ -1168,6 +1314,8 @@ class XgridsK1CompatibilityService:
|
||||||
"finalizing",
|
"finalizing",
|
||||||
}
|
}
|
||||||
acquisition.transition("failed", message_code="acquisition.runtime_failed")
|
acquisition.transition("failed", message_code="acquisition.runtime_failed")
|
||||||
|
camera_terminal_status = "failed"
|
||||||
|
camera_failure_code = "runtime-failed"
|
||||||
if waiting_for_start:
|
if waiting_for_start:
|
||||||
failed_operation_id = self._acquisition_start_operation_id
|
failed_operation_id = self._acquisition_start_operation_id
|
||||||
if waiting_for_stop:
|
if waiting_for_stop:
|
||||||
|
|
@ -1181,6 +1329,8 @@ class XgridsK1CompatibilityService:
|
||||||
message_code="acquisition.receiver_completed_without_point_data",
|
message_code="acquisition.receiver_completed_without_point_data",
|
||||||
result={"receiver_stopped": True, "device_state": "unknown"},
|
result={"receiver_stopped": True, "device_state": "unknown"},
|
||||||
)
|
)
|
||||||
|
camera_terminal_status = "failed"
|
||||||
|
camera_failure_code = "receiver-completed-without-point-data"
|
||||||
failed_operation_id = self._acquisition_start_operation_id
|
failed_operation_id = self._acquisition_start_operation_id
|
||||||
elif acquisition.state == "acquiring" and source_mode == "idle":
|
elif acquisition.state == "acquiring" and source_mode == "idle":
|
||||||
acquisition.transition(
|
acquisition.transition(
|
||||||
|
|
@ -1188,14 +1338,28 @@ class XgridsK1CompatibilityService:
|
||||||
message_code="acquisition.receiver_completed",
|
message_code="acquisition.receiver_completed",
|
||||||
result={"receiver_stopped": True, "device_state": "unknown"},
|
result={"receiver_stopped": True, "device_state": "unknown"},
|
||||||
)
|
)
|
||||||
|
camera_terminal_status = "complete"
|
||||||
elif acquisition.state == "awaiting_external_stop" and source_mode == "idle":
|
elif acquisition.state == "awaiting_external_stop" and source_mode == "idle":
|
||||||
acquisition.transition(
|
acquisition.transition(
|
||||||
"failed",
|
"failed",
|
||||||
message_code="acquisition.receiver_completed_before_stop_confirmation",
|
message_code="acquisition.receiver_completed_before_stop_confirmation",
|
||||||
result={"receiver_stopped": True, "device_state": "unknown"},
|
result={"receiver_stopped": True, "device_state": "unknown"},
|
||||||
)
|
)
|
||||||
|
camera_terminal_status = "failed"
|
||||||
|
camera_failure_code = "receiver-completed-before-stop-confirmation"
|
||||||
unconfirmed_stop_operation_id = self._acquisition_stop_operation_id
|
unconfirmed_stop_operation_id = self._acquisition_stop_operation_id
|
||||||
|
|
||||||
|
if camera_terminal_status is not None:
|
||||||
|
try:
|
||||||
|
self.camera_preview.stop_recording(
|
||||||
|
status=camera_terminal_status,
|
||||||
|
failure_code=camera_failure_code,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._release_acquisition_session_lease()
|
||||||
|
if stop_runtime_for_camera_failure:
|
||||||
|
self.runtime.stop()
|
||||||
|
|
||||||
if receiver_ready_operation_id is not None:
|
if receiver_ready_operation_id is not None:
|
||||||
self._operations.transition_if_pending(
|
self._operations.transition_if_pending(
|
||||||
receiver_ready_operation_id,
|
receiver_ready_operation_id,
|
||||||
|
|
@ -1581,9 +1745,9 @@ def _device_calibration_snapshot(active_profile_id: str | None) -> dict[str, Any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path:
|
def _new_operation_session_dir(sessions_root: Path, suffix: str) -> Path:
|
||||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
base = repository_root / "sessions" / f"{stamp}_{suffix}"
|
base = sessions_root / f"{stamp}_{suffix}"
|
||||||
candidate = base
|
candidate = base
|
||||||
serial = 1
|
serial = 1
|
||||||
while candidate.exists():
|
while candidate.exists():
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,357 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||||
|
from k1link.sessions import (
|
||||||
|
SessionRecordingMaterializer,
|
||||||
|
SessionRecordingPreparationManager,
|
||||||
|
SessionStore,
|
||||||
|
)
|
||||||
|
from k1link.web import app as app_module
|
||||||
|
from k1link.web.session_api import build_session_router
|
||||||
|
|
||||||
|
|
||||||
|
def _make_completed_session(root: Path, session_id: str) -> Path:
|
||||||
|
capture = root / session_id / "captures" / "mqtt_live"
|
||||||
|
capture.mkdir(parents=True)
|
||||||
|
topics = (
|
||||||
|
"lixel/application/report/lio_pcl",
|
||||||
|
"lixel/application/report/lio_pose",
|
||||||
|
)
|
||||||
|
raw = bytearray(RAW_MAGIC)
|
||||||
|
metadata: list[dict[str, object]] = []
|
||||||
|
for sequence, topic in enumerate(topics, start=1):
|
||||||
|
encoded_topic = topic.encode("utf-8")
|
||||||
|
payload = b"fixture"
|
||||||
|
frame_offset = len(raw)
|
||||||
|
raw.extend(FRAME_HEADER.pack(len(encoded_topic), len(payload)))
|
||||||
|
raw.extend(encoded_topic)
|
||||||
|
raw.extend(payload)
|
||||||
|
metadata.append(
|
||||||
|
{
|
||||||
|
"record_type": "message",
|
||||||
|
"sequence": sequence,
|
||||||
|
"received_at_epoch_ns": 1_000_000_000 + sequence,
|
||||||
|
"received_monotonic_ns": 2_000_000_000 + sequence,
|
||||||
|
"topic": topic,
|
||||||
|
"payload_bytes": len(payload),
|
||||||
|
"raw_frame_offset": frame_offset,
|
||||||
|
"raw_payload_offset": frame_offset + FRAME_HEADER.size + len(encoded_topic),
|
||||||
|
"raw_frame_bytes": FRAME_HEADER.size + len(encoded_topic) + len(payload),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
(capture / "mqtt.raw.k1mqtt").write_bytes(raw)
|
||||||
|
metadata_path = capture / "mqtt.metadata.jsonl"
|
||||||
|
metadata_path.write_text(
|
||||||
|
"".join(json.dumps(record) + "\n" for record in metadata),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(capture / "mqtt.summary.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"created_at_utc": "2026-07-16T20:56:32.699Z",
|
||||||
|
"completed_at_utc": "2026-07-16T20:57:02.699Z",
|
||||||
|
"capture_elapsed_seconds": 30.0,
|
||||||
|
"stop_reason": "external_stop",
|
||||||
|
"error": None,
|
||||||
|
"message_count": 2,
|
||||||
|
"raw_bytes": len(raw),
|
||||||
|
"topic_counts": {topic: 1 for topic in topics},
|
||||||
|
"artifact_hashes": {
|
||||||
|
"raw_sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
"metadata_jsonl_sha256": hashlib.sha256(metadata_path.read_bytes()).hexdigest(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return capture.parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
|
||||||
|
return next(
|
||||||
|
route.endpoint
|
||||||
|
for route in router.routes
|
||||||
|
if isinstance(route, APIRoute) and route.path == path and method in route.methods
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_refresh_discovers_new_completed_session_without_server_restart(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
refresh_calls = 0
|
||||||
|
|
||||||
|
def refresh() -> tuple[str, ...]:
|
||||||
|
nonlocal refresh_calls
|
||||||
|
refresh_calls += 1
|
||||||
|
return store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
router = build_session_router(store, catalog_refresher=refresh)
|
||||||
|
list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")
|
||||||
|
|
||||||
|
assert list_route(limit=20, cursor=None) == {"items": []}
|
||||||
|
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
listing = list_route(limit=20, cursor=None)
|
||||||
|
|
||||||
|
assert refresh_calls == 2
|
||||||
|
assert [item["id"] for item in listing["items"]] == [session.name]
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_and_replay_refresh_catalog_before_lookup(tmp_path: Path) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
refresh_calls = 0
|
||||||
|
|
||||||
|
def refresh() -> tuple[str, ...]:
|
||||||
|
nonlocal refresh_calls
|
||||||
|
refresh_calls += 1
|
||||||
|
return store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
router = build_session_router(store, catalog_refresher=refresh)
|
||||||
|
detail_route = _endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/observation-sessions/{session_id}",
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
replay_route = _endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/observation-sessions/{session_id}/replay",
|
||||||
|
"POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = detail_route(session_id=session.name)
|
||||||
|
replay = asyncio.run(replay_route(session_id=session.name, request=None))
|
||||||
|
|
||||||
|
assert detail["session_id"] == session.name
|
||||||
|
assert replay["launch"]["session_id"] == session.name
|
||||||
|
assert refresh_calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_refresh_failure_is_a_stable_service_error(tmp_path: Path) -> None:
|
||||||
|
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
|
||||||
|
|
||||||
|
def fail_refresh() -> None:
|
||||||
|
raise OSError("private local path must not escape")
|
||||||
|
|
||||||
|
router = build_session_router(store, catalog_refresher=fail_refresh)
|
||||||
|
list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
list_route(limit=20, cursor=None)
|
||||||
|
assert error.value.status_code == 503
|
||||||
|
assert "private local path" not in str(error.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_warmup_enqueues_every_finalized_replayable_session(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
|
||||||
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
payload = b"startup-prepared-recording"
|
||||||
|
destination.write_bytes(payload)
|
||||||
|
return {
|
||||||
|
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||||
|
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
"rrd_bytes": len(payload),
|
||||||
|
"timeline": "session_time",
|
||||||
|
"timeline_start_ns": 0,
|
||||||
|
"timeline_end_ns": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(app_module, "session_store", store)
|
||||||
|
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||||
|
try:
|
||||||
|
enqueued = app_module.enqueue_replayable_recordings()
|
||||||
|
deadline = time.monotonic() + 2
|
||||||
|
snapshot = manager.status(session.name)
|
||||||
|
while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.005)
|
||||||
|
snapshot = manager.status(session.name)
|
||||||
|
|
||||||
|
assert enqueued == (session.name,)
|
||||||
|
assert snapshot is not None
|
||||||
|
assert snapshot.state == "ready"
|
||||||
|
assert snapshot.recording is not None
|
||||||
|
finally:
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconciliation_skips_one_stale_session_and_prepares_later_valid_session(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
good = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
stale = _make_completed_session(sessions, "20260716T205633Z_viewer_live")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
(stale / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt").unlink()
|
||||||
|
|
||||||
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
payload = b"prepared-after-stale-row"
|
||||||
|
destination.write_bytes(payload)
|
||||||
|
return {
|
||||||
|
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||||
|
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
"rrd_bytes": len(payload),
|
||||||
|
"timeline": "session_time",
|
||||||
|
"timeline_start_ns": 0,
|
||||||
|
"timeline_end_ns": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(app_module, "session_store", store)
|
||||||
|
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||||
|
try:
|
||||||
|
enqueued = app_module.enqueue_replayable_recordings()
|
||||||
|
deadline = time.monotonic() + 2
|
||||||
|
snapshot = manager.status(good.name)
|
||||||
|
while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.005)
|
||||||
|
snapshot = manager.status(good.name)
|
||||||
|
|
||||||
|
assert enqueued == (good.name,)
|
||||||
|
assert manager.status(stale.name) is None
|
||||||
|
assert snapshot is not None and snapshot.state == "ready"
|
||||||
|
finally:
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconciler_requeues_only_a_restart_interrupted_job(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
started.set()
|
||||||
|
assert release.wait(timeout=2)
|
||||||
|
payload = f"restart-{calls}".encode()
|
||||||
|
destination.write_bytes(payload)
|
||||||
|
return {
|
||||||
|
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||||
|
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
"rrd_bytes": len(payload),
|
||||||
|
"timeline": "session_time",
|
||||||
|
"timeline_start_ns": 0,
|
||||||
|
"timeline_end_ns": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(app_module, "session_store", store)
|
||||||
|
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||||
|
try:
|
||||||
|
app_module.enqueue_replayable_recordings()
|
||||||
|
assert started.wait(timeout=1)
|
||||||
|
manager.close(timeout=0.001)
|
||||||
|
manager.start()
|
||||||
|
release.set()
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 2
|
||||||
|
snapshot = manager.status(session.name)
|
||||||
|
while (
|
||||||
|
snapshot is None or snapshot.state != "cancelled"
|
||||||
|
) and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.005)
|
||||||
|
snapshot = manager.status(session.name)
|
||||||
|
assert snapshot is not None and snapshot.state == "cancelled"
|
||||||
|
interrupted_id = snapshot.preparation_id
|
||||||
|
|
||||||
|
app_module.enqueue_replayable_recordings()
|
||||||
|
deadline = time.monotonic() + 2
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
snapshot = manager.status(session.name)
|
||||||
|
if snapshot is not None and snapshot.state == "ready":
|
||||||
|
break
|
||||||
|
time.sleep(0.005)
|
||||||
|
assert snapshot is not None and snapshot.state == "ready"
|
||||||
|
assert snapshot.preparation_id != interrupted_id
|
||||||
|
assert calls == 2
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconciler_does_not_loop_retry_a_genuine_failed_job(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
repository = tmp_path / "repo"
|
||||||
|
sessions = repository / "sessions"
|
||||||
|
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
|
||||||
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
|
store.import_legacy_viewer_live(sessions)
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def exporter(_source: Path, _destination: Path) -> dict[str, object]:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
raise OSError("genuine export failure")
|
||||||
|
|
||||||
|
manager = SessionRecordingPreparationManager(
|
||||||
|
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(app_module, "session_store", store)
|
||||||
|
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
|
||||||
|
try:
|
||||||
|
app_module.enqueue_replayable_recordings()
|
||||||
|
deadline = time.monotonic() + 2
|
||||||
|
snapshot = manager.status(session.name)
|
||||||
|
while (
|
||||||
|
snapshot is None or snapshot.state != "failed"
|
||||||
|
) and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.005)
|
||||||
|
snapshot = manager.status(session.name)
|
||||||
|
assert snapshot is not None and snapshot.state == "failed"
|
||||||
|
failed_id = snapshot.preparation_id
|
||||||
|
|
||||||
|
app_module.enqueue_replayable_recordings()
|
||||||
|
time.sleep(0.05)
|
||||||
|
unchanged = manager.status(session.name)
|
||||||
|
assert unchanged is not None
|
||||||
|
assert unchanged.preparation_id == failed_id
|
||||||
|
assert unchanged.state == "failed"
|
||||||
|
assert calls == 1
|
||||||
|
finally:
|
||||||
|
manager.close()
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import k1link.web.xgrids_k1_camera as camera_module
|
||||||
from k1link.web.xgrids_k1_camera import (
|
from k1link.web.xgrids_k1_camera import (
|
||||||
CAMERA_MEDIA_TYPE,
|
CAMERA_MEDIA_TYPE,
|
||||||
XgridsK1CameraGateway,
|
XgridsK1CameraGateway,
|
||||||
|
|
@ -40,6 +43,63 @@ def _fake_ffmpeg(tmp_path: Path) -> Path:
|
||||||
return executable
|
return executable
|
||||||
|
|
||||||
|
|
||||||
|
def _burst_ffmpeg(tmp_path: Path) -> Path:
|
||||||
|
executable = tmp_path / "burst-ffmpeg"
|
||||||
|
executable.write_text(
|
||||||
|
f"#!{sys.executable}\n"
|
||||||
|
"import sys, time\n"
|
||||||
|
"def box(kind, payload=b''):\n"
|
||||||
|
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||||
|
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
|
||||||
|
"sys.stdout.buffer.flush()\n"
|
||||||
|
"time.sleep(0.2)\n"
|
||||||
|
"for index in range(10):\n"
|
||||||
|
" sys.stdout.buffer.write(box(b'moof') + box(b'mdat', bytes([index])))\n"
|
||||||
|
" sys.stdout.buffer.flush()\n"
|
||||||
|
" time.sleep(0.01)\n"
|
||||||
|
"time.sleep(10)\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
executable.chmod(0o700)
|
||||||
|
return executable
|
||||||
|
|
||||||
|
|
||||||
|
def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path:
|
||||||
|
executable = tmp_path / "buffered-tail-ffmpeg"
|
||||||
|
executable.write_text(
|
||||||
|
f"#!{sys.executable}\n"
|
||||||
|
"import pathlib, sys, time\n"
|
||||||
|
"def box(kind, payload=b''):\n"
|
||||||
|
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||||
|
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
|
||||||
|
f"for index in range({fragments}):\n"
|
||||||
|
" sys.stdout.buffer.write(box(b'moof') + box(b'mdat', index.to_bytes(2, 'big')))\n"
|
||||||
|
"sys.stdout.buffer.flush()\n"
|
||||||
|
f"pathlib.Path({str(sentinel)!r}).write_text('ready')\n"
|
||||||
|
"time.sleep(10)\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
executable.chmod(0o700)
|
||||||
|
return executable
|
||||||
|
|
||||||
|
|
||||||
|
def _incomplete_tail_ffmpeg(tmp_path: Path, sentinel: Path) -> Path:
|
||||||
|
executable = tmp_path / "incomplete-tail-ffmpeg"
|
||||||
|
executable.write_text(
|
||||||
|
f"#!{sys.executable}\n"
|
||||||
|
"import pathlib, sys, time\n"
|
||||||
|
"def box(kind, payload=b''):\n"
|
||||||
|
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||||
|
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov') + box(b'moof'))\n"
|
||||||
|
"sys.stdout.buffer.flush()\n"
|
||||||
|
f"pathlib.Path({str(sentinel)!r}).write_text('ready')\n"
|
||||||
|
"time.sleep(10)\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
executable.chmod(0o700)
|
||||||
|
return executable
|
||||||
|
|
||||||
|
|
||||||
def _gateway(
|
def _gateway(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|
@ -48,6 +108,16 @@ def _gateway(
|
||||||
return XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
return XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_until(predicate: object, *, timeout: float = 3.0) -> None:
|
||||||
|
assert callable(predicate)
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if predicate():
|
||||||
|
return
|
||||||
|
time.sleep(0.01)
|
||||||
|
raise AssertionError("condition was not reached before timeout")
|
||||||
|
|
||||||
|
|
||||||
def test_camera_selection_is_exclusive_and_hides_device_transport(
|
def test_camera_selection_is_exclusive_and_hides_device_transport(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|
@ -90,9 +160,7 @@ def test_camera_ffmpeg_command_is_allowlisted_copy_remux() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
assert argv[0] == "/trusted/ffmpeg"
|
assert argv[0] == "/trusted/ffmpeg"
|
||||||
assert argv[argv.index("-i") + 1] == (
|
assert argv[argv.index("-i") + 1] == ("rtsp://10.0.0.24:8554/live/chn_left_main")
|
||||||
"rtsp://10.0.0.24:8554/live/chn_left_main"
|
|
||||||
)
|
|
||||||
assert argv[argv.index("-c:v") + 1] == "copy"
|
assert argv[argv.index("-c:v") + 1] == "copy"
|
||||||
assert argv[argv.index("-allowed_media_types") + 1] == "video"
|
assert argv[argv.index("-allowed_media_types") + 1] == "video"
|
||||||
assert argv[argv.index("-flush_packets") + 1] == "1"
|
assert argv[argv.index("-flush_packets") + 1] == "1"
|
||||||
|
|
@ -145,6 +213,279 @@ def test_gateway_emits_init_and_complete_media_segments_without_transcoding(
|
||||||
gateway.close()
|
gateway.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquisition_records_without_browser_and_source_switch_seals_epochs(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
gateway = _gateway(tmp_path, monkeypatch)
|
||||||
|
session = tmp_path / "sessions" / "camera-acquisition"
|
||||||
|
try:
|
||||||
|
left = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||||
|
with pytest.raises(ValueError, match="does not exist"):
|
||||||
|
gateway.start_recording(session)
|
||||||
|
assert session.exists() is False
|
||||||
|
session.mkdir(parents=True)
|
||||||
|
gateway.start_recording(session)
|
||||||
|
left_epoch = session / "media" / "sensor.camera.left" / f"epoch-{left['generation']}"
|
||||||
|
|
||||||
|
# No open_delivery/WebSocket exists: acquisition ownership alone starts
|
||||||
|
# FFmpeg and commits the init segment before any preview consumer.
|
||||||
|
_wait_until(
|
||||||
|
lambda: (
|
||||||
|
(left_epoch / "init.mp4").is_file()
|
||||||
|
and len(list((left_epoch / "segments").glob("*.m4s"))) == 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
|
||||||
|
|
||||||
|
right = gateway.select("sensor.camera.right", "192.168.1.20")
|
||||||
|
right_epoch = session / "media" / "sensor.camera.right" / f"epoch-{right['generation']}"
|
||||||
|
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
|
||||||
|
_wait_until(lambda: len(list((right_epoch / "segments").glob("*.m4s"))) == 1)
|
||||||
|
|
||||||
|
lease = gateway.open_delivery(right["generation"])
|
||||||
|
init_segment = lease.segments.get(timeout=1)
|
||||||
|
assert init_segment is not None and init_segment[0] == "init"
|
||||||
|
gateway.release_delivery(lease, client_closed=True)
|
||||||
|
|
||||||
|
# Browser disposal is not producer disposal while recording is active.
|
||||||
|
assert lease.process.poll() is None
|
||||||
|
assert gateway.snapshot()["recording"]["active"] is True
|
||||||
|
assert gateway.snapshot()["phase"] == "streaming"
|
||||||
|
|
||||||
|
stopped = gateway.stop_recording(status="complete")
|
||||||
|
assert stopped["recording"]["active"] is False
|
||||||
|
assert stopped["recording"]["completed_epochs"] == 2
|
||||||
|
|
||||||
|
summaries = []
|
||||||
|
for epoch in (left_epoch, right_epoch):
|
||||||
|
init = (epoch / "init.mp4").read_bytes()
|
||||||
|
entries = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in (epoch / "index.jsonl").read_text(encoding="utf-8").splitlines()
|
||||||
|
]
|
||||||
|
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||||
|
summaries.append(summary)
|
||||||
|
assert [entry["kind"] for entry in entries] == ["media"]
|
||||||
|
assert all(
|
||||||
|
entry["length"] == len((epoch / entry["path"]).read_bytes())
|
||||||
|
and hashlib.sha256((epoch / entry["path"]).read_bytes()).hexdigest()
|
||||||
|
== entry["sha256"]
|
||||||
|
for entry in entries
|
||||||
|
)
|
||||||
|
assert summary["status"] == "complete"
|
||||||
|
assert summary["segment_count"] == 1
|
||||||
|
assert summary["entry_count"] == 1
|
||||||
|
assert summary["valid_bytes"] == len(init) + sum(
|
||||||
|
entry["length"] for entry in entries
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summaries[0]["failure_code"] == "source-switch"
|
||||||
|
assert summaries[1]["failure_code"] is None
|
||||||
|
serialized = json.dumps(stopped)
|
||||||
|
assert "192.168.1.20" not in serialized
|
||||||
|
assert "rtsp://" not in serialized
|
||||||
|
finally:
|
||||||
|
gateway.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
gateway = _gateway(tmp_path, monkeypatch)
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
popen_called = False
|
||||||
|
|
||||||
|
class FailingArchive:
|
||||||
|
def __init__(self, *_: object, **__: object) -> None:
|
||||||
|
raise camera_module.CameraArchiveError("synthetic storage failure")
|
||||||
|
|
||||||
|
def forbidden_popen(*_: object, **__: object) -> object:
|
||||||
|
nonlocal popen_called
|
||||||
|
popen_called = True
|
||||||
|
raise AssertionError("FFmpeg must not start without durable storage")
|
||||||
|
|
||||||
|
monkeypatch.setattr(camera_module, "CameraArchiveWriter", FailingArchive)
|
||||||
|
monkeypatch.setattr(camera_module.subprocess, "Popen", forbidden_popen)
|
||||||
|
try:
|
||||||
|
gateway.select("sensor.camera.left", "192.168.1.20")
|
||||||
|
with pytest.raises(RuntimeError, match="хранилище"):
|
||||||
|
gateway.start_recording(session)
|
||||||
|
|
||||||
|
state = gateway.snapshot()
|
||||||
|
assert popen_called is False
|
||||||
|
assert state["phase"] == "error"
|
||||||
|
assert state["error"]["code"] == "camera-storage-failed"
|
||||||
|
assert "192.168.1.20" not in json.dumps(state)
|
||||||
|
finally:
|
||||||
|
gateway.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_storage_append_failure_fails_producer_and_epoch_loudly(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
original_append = camera_module.CameraArchiveWriter.append
|
||||||
|
|
||||||
|
def failing_append(
|
||||||
|
writer: object,
|
||||||
|
kind: object,
|
||||||
|
payload: bytes,
|
||||||
|
**timestamps: object,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if kind == "media":
|
||||||
|
raise camera_module.CameraArchiveError("synthetic media commit failure")
|
||||||
|
return original_append(writer, kind, payload, **timestamps) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
monkeypatch.setattr(camera_module.CameraArchiveWriter, "append", failing_append)
|
||||||
|
gateway = _gateway(tmp_path, monkeypatch)
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
try:
|
||||||
|
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||||
|
gateway.start_recording(session)
|
||||||
|
|
||||||
|
_wait_until(lambda: gateway.snapshot()["phase"] == "error")
|
||||||
|
state = gateway.snapshot()
|
||||||
|
assert state["error"]["code"] == "camera-storage-failed"
|
||||||
|
summary_path = (
|
||||||
|
session
|
||||||
|
/ "media"
|
||||||
|
/ "sensor.camera.left"
|
||||||
|
/ f"epoch-{selected['generation']}"
|
||||||
|
/ "summary.json"
|
||||||
|
)
|
||||||
|
_wait_until(
|
||||||
|
lambda: (
|
||||||
|
summary_path.is_file()
|
||||||
|
and json.loads(summary_path.read_text(encoding="utf-8"))["status"]
|
||||||
|
== "failed"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||||
|
assert summary["status"] == "failed"
|
||||||
|
assert summary["failure_code"] == "camera-storage-failed"
|
||||||
|
assert "192.168.1.20" not in json.dumps(state)
|
||||||
|
finally:
|
||||||
|
gateway.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_slow_browser_is_dropped_without_stopping_archive_producer(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("MISSIONCORE_FFMPEG_BINARY", str(_burst_ffmpeg(tmp_path)))
|
||||||
|
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
try:
|
||||||
|
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||||
|
gateway.start_recording(session)
|
||||||
|
lease = gateway.open_delivery(selected["generation"])
|
||||||
|
|
||||||
|
# Deliberately never drain the bounded queue.
|
||||||
|
_wait_until(lambda: lease.failure_code == "consumer-too-slow")
|
||||||
|
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
|
||||||
|
assert lease.process.poll() is None
|
||||||
|
assert gateway.snapshot()["recording"]["active"] is True
|
||||||
|
|
||||||
|
gateway.release_delivery(lease, client_closed=False)
|
||||||
|
epoch = (
|
||||||
|
session
|
||||||
|
/ "media"
|
||||||
|
/ "sensor.camera.left"
|
||||||
|
/ f"epoch-{selected['generation']}"
|
||||||
|
)
|
||||||
|
_wait_until(
|
||||||
|
lambda: len(list((epoch / "segments").glob("*.m4s"))) == 10,
|
||||||
|
)
|
||||||
|
gateway.stop_recording(status="complete")
|
||||||
|
|
||||||
|
summary_path = epoch / "summary.json"
|
||||||
|
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||||
|
assert summary["status"] == "complete"
|
||||||
|
assert summary["segment_count"] == 10
|
||||||
|
assert summary["media_segment_count"] == 10
|
||||||
|
finally:
|
||||||
|
gateway.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_stop_drains_ffmpeg_stdout_before_sealing_archive(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
fragment_count = 100
|
||||||
|
sentinel = tmp_path / "ffmpeg-wrote-buffered-tail"
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"MISSIONCORE_FFMPEG_BINARY",
|
||||||
|
str(_buffered_tail_ffmpeg(tmp_path, sentinel, fragments=fragment_count)),
|
||||||
|
)
|
||||||
|
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
try:
|
||||||
|
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||||
|
gateway.start_recording(session)
|
||||||
|
_wait_until(sentinel.is_file)
|
||||||
|
|
||||||
|
gateway.stop_recording(status="complete")
|
||||||
|
|
||||||
|
epoch = (
|
||||||
|
session
|
||||||
|
/ "media"
|
||||||
|
/ "sensor.camera.left"
|
||||||
|
/ f"epoch-{selected['generation']}"
|
||||||
|
)
|
||||||
|
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||||
|
assert summary["status"] == "complete"
|
||||||
|
assert summary["segment_count"] == fragment_count
|
||||||
|
assert len(list((epoch / "segments").glob("*.m4s"))) == fragment_count
|
||||||
|
assert len((epoch / "index.jsonl").read_text(encoding="utf-8").splitlines()) == (
|
||||||
|
fragment_count
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
gateway.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_stop_marks_incomplete_fragment_tail_interrupted(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
sentinel = tmp_path / "ffmpeg-wrote-incomplete-tail"
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"MISSIONCORE_FFMPEG_BINARY",
|
||||||
|
str(_incomplete_tail_ffmpeg(tmp_path, sentinel)),
|
||||||
|
)
|
||||||
|
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||||
|
session = tmp_path / "session"
|
||||||
|
session.mkdir()
|
||||||
|
try:
|
||||||
|
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||||
|
gateway.start_recording(session)
|
||||||
|
_wait_until(sentinel.is_file)
|
||||||
|
|
||||||
|
gateway.stop_recording(status="complete")
|
||||||
|
|
||||||
|
summary_path = (
|
||||||
|
session
|
||||||
|
/ "media"
|
||||||
|
/ "sensor.camera.left"
|
||||||
|
/ f"epoch-{selected['generation']}"
|
||||||
|
/ "summary.json"
|
||||||
|
)
|
||||||
|
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||||
|
assert summary["status"] == "interrupted"
|
||||||
|
assert summary["failure_code"] == "incomplete-fmp4-fragment"
|
||||||
|
assert summary["segment_count"] == 0
|
||||||
|
state = gateway.snapshot()
|
||||||
|
assert state["phase"] == "error"
|
||||||
|
assert state["error"]["code"] == "incomplete-fmp4-fragment"
|
||||||
|
finally:
|
||||||
|
gateway.close()
|
||||||
|
|
||||||
|
|
||||||
def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
|
def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue