Admit onboard camera preview only after its acquisition producer is ready

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 16:44:38 +03:00
parent fa8ac760a1
commit 9e03404f23
5 changed files with 65 additions and 12 deletions
@@ -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.
@@ -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
+17 -4
View File
@@ -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
+12 -6
View File
@@ -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
+15
View File
@@ -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()