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
|
- Camera visibility is local presentation, with its decoder kept mounted when
|
||||||
hidden. Acquisition and the durable camera producer remain onboard-owned.
|
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
|
## Validation and acceptance
|
||||||
|
|
||||||
Completed at draft time: both TypeScript projects; architecture/focused frontend
|
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
|
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.
|
channels and idle/resume. No real BLE/MQTT commands were issued for these checks.
|
||||||
Full Control Station regression: 802/802 passed. Control Station production
|
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
|
R12 packages target Node 0.8.9 and optional K1 0.1.9. Immutable source/artifact
|
||||||
identity, installation and acceptance are recorded after packaging.
|
identity, installation and acceptance are recorded after packaging.
|
||||||
|
|||||||
@@ -970,7 +970,9 @@ class XgridsK1CameraGateway:
|
|||||||
)
|
)
|
||||||
return self.snapshot()
|
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._lifecycle_lock:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._require_open_locked()
|
self._require_open_locked()
|
||||||
@@ -978,6 +980,8 @@ class XgridsK1CameraGateway:
|
|||||||
raise ValueError("camera preview generation не активно")
|
raise ValueError("camera preview generation не активно")
|
||||||
producer = self._producer
|
producer = self._producer
|
||||||
recording_active = self._recording_root is not None
|
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:
|
if producer is None:
|
||||||
# An acquisition owns its producer lifecycle. Once an epoch is
|
# An acquisition owns its producer lifecycle. Once an epoch is
|
||||||
# sealed, a disposable browser reconnect must not resurrect the
|
# sealed, a disposable browser reconnect must not resurrect the
|
||||||
|
|||||||
@@ -133,8 +133,23 @@ class NodeMediaPeers:
|
|||||||
while identifier in self.items and time.monotonic() < deadline:
|
while identifier in self.items and time.monotonic() < deadline:
|
||||||
state = self.camera.snapshot()
|
state = self.camera.snapshot()
|
||||||
delivery = state.get("delivery") or {}
|
delivery = state.get("delivery") or {}
|
||||||
if state.get("generation") is not None and delivery.get("media_type"):
|
recording = state.get("recording") or {}
|
||||||
lease = await asyncio.to_thread(self.camera.open_delivery, state["generation"])
|
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:
|
try:
|
||||||
channel.send(json.dumps({
|
channel.send(json.dumps({
|
||||||
"type": "camera-ready", "mime": delivery["media_type"],
|
"type": "camera-ready", "mime": delivery["media_type"],
|
||||||
@@ -143,8 +158,6 @@ class NodeMediaPeers:
|
|||||||
self.camera.release_delivery(lease, client_closed=True)
|
self.camera.release_delivery(lease, client_closed=True)
|
||||||
raise
|
raise
|
||||||
return lease
|
return lease
|
||||||
if state.get("phase") == "error":
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.25)
|
await asyncio.sleep(0.25)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
|
|||||||
|
|
||||||
class Camera:
|
class Camera:
|
||||||
def snapshot(self):
|
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.
|
# The old test's sub-16KB fake payload missed the actual fragmentation bug.
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -127,10 +128,12 @@ def test_camera_waits_for_post_calibration_producer_and_delivers_metadata():
|
|||||||
|
|
||||||
def snapshot(self):
|
def snapshot(self):
|
||||||
self.calls += 1
|
self.calls += 1
|
||||||
return {"generation": None} if self.calls < 2 else {
|
return {"generation": 3, "delivery": {"media_type": "video/mp4"},
|
||||||
"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)
|
self.opened.append(generation)
|
||||||
return "lease"
|
return "lease"
|
||||||
|
|
||||||
@@ -182,9 +185,12 @@ def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
|
|||||||
self.released = []
|
self.released = []
|
||||||
|
|
||||||
def snapshot(self):
|
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
|
assert generation == 1
|
||||||
return self.lease
|
return self.lease
|
||||||
|
|
||||||
|
|||||||
@@ -1984,3 +1984,18 @@ def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
service.close()
|
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