From 9e03404f23b9d6ee9d147f87886e29c25781d778 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 7 Sep 2026 16:44:38 +0300 Subject: [PATCH] Admit onboard camera preview only after its acquisition producer is ready --- ...2026-09-07-k1-onboard-live-template-r12.md | 17 ++++++++++++++- src/k1link/device_plugins/xgrids_k1/camera.py | 6 +++++- src/k1link/viewer/node_media.py | 21 +++++++++++++++---- tests/test_node_media.py | 18 ++++++++++------ tests/test_xgrids_camera_gateway.py | 15 +++++++++++++ 5 files changed, 65 insertions(+), 12 deletions(-) diff --git a/docs/audits/2026-09-07-k1-onboard-live-template-r12.md b/docs/audits/2026-09-07-k1-onboard-live-template-r12.md index ba6a793..40e3918 100644 --- a/docs/audits/2026-09-07-k1-onboard-live-template-r12.md +++ b/docs/audits/2026-09-07-k1-onboard-live-template-r12.md @@ -58,6 +58,15 @@ No operator/board wall-clock synchronization is required for the age gate. - Camera visibility is local presentation, with its decoder kept mounted when hidden. Acquisition and the durable camera producer remain onboard-owned. +The camera delivery descriptor is available before its acquisition producer and +first media commit. Onboard admission now waits for the active recording epoch, +its live producer and committed first media. It reobserves a replacement epoch +if admission races recovery. A `require_recording` lease guard forbids the Node +preview from lazily spawning a producer after STOP; legacy direct preview keeps +its existing default behavior. The camera gateway regression suite passes 43 +checks. Browser MSE setup also registers sourceopen before assigning the object +URL and ignores callbacks after decoder disposal (synthetic event regression). + ## Validation and acceptance Completed at draft time: both TypeScript projects; architecture/focused frontend @@ -66,7 +75,13 @@ assert shared template and masked initial presentation); 12 Python checks across native subscriber and media, including one bounded loopback WebRTC peer with two channels and idle/resume. No real BLE/MQTT commands were issued for these checks. Full Control Station regression: 802/802 passed. Control Station production -build passed. Node package build and physical acceptance remain pending. +build passed. Node UI production build and package generation passed. Final packages are +rebuilt from the final committed source after camera admission checks; physical +acceptance remains pending. + +Additional focused checks: 27 frontend enrollment/control/media checks, 4 media +framing/freshness/MSE checks, 25 Node bridge/subscriber checks, 14 installer +lifecycle checks, and the Node UI control-boundary test passed. R12 packages target Node 0.8.9 and optional K1 0.1.9. Immutable source/artifact identity, installation and acceptance are recorded after packaging. diff --git a/src/k1link/device_plugins/xgrids_k1/camera.py b/src/k1link/device_plugins/xgrids_k1/camera.py index 2f7460f..94add5a 100644 --- a/src/k1link/device_plugins/xgrids_k1/camera.py +++ b/src/k1link/device_plugins/xgrids_k1/camera.py @@ -970,7 +970,9 @@ class XgridsK1CameraGateway: ) return self.snapshot() - def open_delivery(self, generation: int) -> CameraProcessLease: + def open_delivery( + self, generation: int, *, require_recording: bool = False + ) -> CameraProcessLease: with self._lifecycle_lock: with self._lock: self._require_open_locked() @@ -978,6 +980,8 @@ class XgridsK1CameraGateway: raise ValueError("camera preview generation не активно") producer = self._producer recording_active = self._recording_root is not None + if require_recording and (not recording_active or producer is None): + raise RuntimeError("camera acquisition producer is not ready") if producer is None: # An acquisition owns its producer lifecycle. Once an epoch is # sealed, a disposable browser reconnect must not resurrect the diff --git a/src/k1link/viewer/node_media.py b/src/k1link/viewer/node_media.py index e1b8146..b14f9d3 100644 --- a/src/k1link/viewer/node_media.py +++ b/src/k1link/viewer/node_media.py @@ -133,8 +133,23 @@ class NodeMediaPeers: while identifier in self.items and time.monotonic() < deadline: state = self.camera.snapshot() delivery = state.get("delivery") or {} - if state.get("generation") is not None and delivery.get("media_type"): - lease = await asyncio.to_thread(self.camera.open_delivery, state["generation"]) + recording = state.get("recording") or {} + if recording.get("source_end_expected"): + break + if (state.get("generation") is not None and delivery.get("media_type") + and recording.get("active") is True + and recording.get("producer_alive") is True + and recording.get("media_ready") is True + and recording.get("active_epoch") == state["generation"]): + try: + lease = await asyncio.to_thread( + self.camera.open_delivery, state["generation"], require_recording=True + ) + except (ValueError, RuntimeError): + # The acquisition owner may replace an epoch between this + # snapshot and lease admission. Observe its next ready epoch. + await asyncio.sleep(0.25) + continue try: channel.send(json.dumps({ "type": "camera-ready", "mime": delivery["media_type"], @@ -143,8 +158,6 @@ class NodeMediaPeers: self.camera.release_delivery(lease, client_closed=True) raise return lease - if state.get("phase") == "error": - break await asyncio.sleep(0.25) return None diff --git a/tests/test_node_media.py b/tests/test_node_media.py index ebbd701..6f6a25e 100644 --- a/tests/test_node_media.py +++ b/tests/test_node_media.py @@ -71,7 +71,8 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch): class Camera: def snapshot(self): - return {"generation": None, "phase": "error"} + return {"generation": None, "phase": "error", + "recording": {"source_end_expected": True}} # The old test's sub-16KB fake payload missed the actual fragmentation bug. import numpy as np @@ -127,10 +128,12 @@ def test_camera_waits_for_post_calibration_producer_and_delivers_metadata(): def snapshot(self): self.calls += 1 - return {"generation": None} if self.calls < 2 else { - "generation": 3, "delivery": {"media_type": "video/mp4"}} + return {"generation": 3, "delivery": {"media_type": "video/mp4"}, + "recording": {"active": True, "producer_alive": self.calls >= 2, + "media_ready": self.calls >= 3, "active_epoch": 3}} - def open_delivery(self, generation): + def open_delivery(self, generation, *, require_recording=False): + assert require_recording self.opened.append(generation) return "lease" @@ -182,9 +185,12 @@ def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch): self.released = [] def snapshot(self): - return {"generation": 1, "delivery": {"media_type": "video/mp4"}} + return {"generation": 1, "delivery": {"media_type": "video/mp4"}, + "recording": {"active": True, "producer_alive": True, + "media_ready": True, "active_epoch": 1}} - def open_delivery(self, generation): + def open_delivery(self, generation, *, require_recording=False): + assert require_recording assert generation == 1 return self.lease diff --git a/tests/test_xgrids_camera_gateway.py b/tests/test_xgrids_camera_gateway.py index 77c89a4..a353579 100644 --- a/tests/test_xgrids_camera_gateway.py +++ b/tests/test_xgrids_camera_gateway.py @@ -1984,3 +1984,18 @@ def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe( ) finally: service.close() + + +def test_onboard_preview_never_spawns_a_camera_producer(tmp_path, monkeypatch): + gateway = _gateway(tmp_path, monkeypatch) + try: + selected = gateway.select("sensor.camera.right", "192.168.1.20") + monkeypatch.setattr( + gateway, "_spawn_selected_producer", + lambda: pytest.fail("an onboard preview must not own camera activation"), + ) + with pytest.raises(RuntimeError, match="producer is not ready"): + gateway.open_delivery(selected["generation"], require_recording=True) + assert gateway.snapshot()["recording"]["producer_alive"] is False + finally: + gateway.close()