Admit onboard camera preview only after its acquisition producer is ready
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user