Share the K1 live scene template and preserve idle media channels

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 16:38:39 +03:00
parent 309bdf759e
commit fa8ac760a1
49 changed files with 2813 additions and 2126 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ def test_private_release_contains_material_only_in_root_private_member(
position += 60 + length + length % 2
with tarfile.open(fileobj=io.BytesIO(members["control.tar.gz"]), mode="r:gz") as archive:
control = archive.extractfile("control").read().decode()
assert "Depends: mission-core-node (>= 0.8.0)" in control
assert "Depends: mission-core-node (>= 0.8.9)" in control
assert "Replaces: mission-core-node (<< 0.8.0)" in control
+38
View File
@@ -556,3 +556,41 @@ def test_native_node_rrd_opens_no_grpc_listener(monkeypatch):
subscriber.close()
subscriber.thread.join(6)
publisher.close()
@pytest.mark.parametrize("replacement", [False, True])
def test_preview_offer_cannot_cross_acquisition_boundary(replacement):
async def run():
device = bridge()
device.facade.current["acquisition"] = {"acquisition_id": "acq-one", "state": "running"}
device.facade.current["source_mode"] = "live"
class Peers:
opened = 0
closed = []
async def offer(self, _):
self.opened += 1
if replacement:
device.facade.current["acquisition"]["acquisition_id"] = "acq-two"
return {"peer_id": "synthetic-peer"}
async def close(self, identifier):
self.closed.append(identifier)
peers = Peers()
sensor = NodeK1Sensor(device, peers)
item = project_sensor(device.facade.current, "node-test")
command = {
"operation_id": "op_" + "a" * 32,
"action_id": "offer",
"session": {"device_id": item["id"], "session_id": "session-test"},
"parameters": {"acquisition_id": "acq-one" if replacement else "acq-old"},
}
with pytest.raises(ValueError):
await sensor.execute(command, "node-test")
assert peers.opened == int(replacement)
assert peers.closed == (["synthetic-peer"] if replacement else [])
assert all(action == "state.read" for action, _ in device.facade.actions)
asyncio.run(run())
+92
View File
@@ -59,6 +59,9 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
except queue.Empty:
return b""
def snapshot(self):
return {"type": "lidar-state", "sequence": 1, "age_ms": 0, "points": 5000}
def close(self):
pass
@@ -90,6 +93,8 @@ def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch):
@channel.on("message")
def message(data):
if isinstance(data, str):
return
payloads.append(data)
if len(payloads) > 1 and sum(map(len, payloads[1:])) == len(payload):
received.set()
@@ -153,3 +158,90 @@ def test_installed_camera_uses_declared_os_ffmpeg():
unit = (root / "plugins/xgrids-k1/packaging/mission-core-k1.service").read_text()
assert "Environment=MISSIONCORE_FFMPEG_BINARY=/usr/bin/ffmpeg" in unit
assert "iproute2, ffmpeg" in (root / "plugins/xgrids-k1/packaging/build_deb.py").read_text()
def test_native_rrd_idle_does_not_close_camera_or_peer(monkeypatch):
"""Regression: the real binary sink, two media channels, idle then resume."""
import json
import time
from types import SimpleNamespace
from k1link.data_plane import ConsumerFrameContext, DecodedPointCloudView
from k1link.viewer.node_rerun import NodeRerunBridge
class Segments:
def __init__(self):
self.queue = queue.Queue()
def get(self, timeout):
return self.queue.get(timeout=timeout)
class Camera:
def __init__(self):
self.lease = SimpleNamespace(segments=Segments())
self.released = []
def snapshot(self):
return {"generation": 1, "delivery": {"media_type": "video/mp4"}}
def open_delivery(self, generation):
assert generation == 1
return self.lease
def release_delivery(self, lease, **_):
self.released.append(lease)
def mark_streaming(self, _):
pass
async def run():
import aioice.ice
monkeypatch.setattr(aioice.ice, "get_host_addresses", lambda **_: ["127.0.0.1"])
hub, camera = NodeRerunBridge(), Camera()
peers = NodeMediaPeers(hub, camera)
client = RTCPeerConnection(RTCConfiguration(iceServers=[]))
rrd = client.createDataChannel("rrd", ordered=True)
video = client.createDataChannel("camera", ordered=True)
ready, resumed, camera_frame = asyncio.Event(), asyncio.Event(), asyncio.Event()
@rrd.on("message")
def rrd_message(data):
if isinstance(data, str):
value = json.loads(data)
if value["sequence"] == 1:
resumed.set()
else:
ready.set()
@video.on("message")
def camera_message(data):
if data == b"synthetic-camera-fragment":
camera_frame.set()
try:
await client.setLocalDescription(await client.createOffer())
answer = await peers.offer({"sdp": client.localDescription.sdp})
await client.setRemoteDescription(
RTCSessionDescription(sdp=answer["sdp"], type="answer")
)
await asyncio.wait_for(ready.wait(), 8)
await asyncio.sleep(1.5)
assert answer["peer_id"] in peers.items
assert rrd.readyState == video.readyState == "open"
assert not camera.released
now = time.monotonic_ns()
hub.process(DecodedPointCloudView(
ConsumerFrameContext(1, time.time_ns(), now, now, 1, True),
"world", ((1.0, 2.0, 3.0),), b"\x01",
))
camera.lease.segments.queue.put(("media", b"synthetic-camera-fragment"))
await asyncio.wait_for(resumed.wait(), 5)
await asyncio.wait_for(camera_frame.wait(), 5)
assert rrd.readyState == video.readyState == "open"
finally:
await client.close()
await peers.close_all()
hub.close()
assert not peers.items
asyncio.run(run())
+80
View File
@@ -0,0 +1,80 @@
"""Native RRD empty reads are pauses, not peer EOF. No device/network involved."""
import time
from k1link.data_plane import ConsumerFrameContext, DecodedPointCloudView
from k1link.viewer.node_rerun import NodeRerunBridge, RrdSubscriber
from k1link.viewer.rerun_bridge import RerunSceneSettings
def points(sequence=1, age=0):
now = time.monotonic_ns()
return DecodedPointCloudView(
context=ConsumerFrameContext(sequence, time.time_ns(), now - int(age * 1e9), now, 1, True),
frame_id="world", positions_xyz=((1.0, 2.0, 3.0),), intensities=b"\x01",
)
def drain_until(subscriber, predicate, seconds=3):
deadline = time.monotonic() + seconds
payloads = []
while time.monotonic() < deadline:
payload = subscriber.read()
assert payload is not None, "idle native binary read retired the live subscriber"
if payload:
assert payload.startswith(b"RRF2")
payloads.append(payload)
if predicate():
return payloads
raise AssertionError("native subscriber did not make progress")
def test_native_idle_read_keeps_subscriber_alive_and_resumes():
subscriber = RrdSubscriber(RerunSceneSettings)
try:
assert drain_until(subscriber, lambda: subscriber.output.empty())
# This forces several SDK reads with no data; the previous len(None)
# retired both RRD and camera before a first point could arrive.
assert subscriber.read() == b""
assert not subscriber.closed.is_set()
subscriber.offer(points())
assert drain_until(subscriber, lambda: subscriber.snapshot()["sequence"] == 1)
assert subscriber.read() == b""
subscriber.offer(points(2))
assert drain_until(subscriber, lambda: subscriber.snapshot()["sequence"] == 2)
assert subscriber.snapshot()["points"] == 1
assert not subscriber.closed.is_set()
finally:
subscriber.close()
subscriber.thread.join(timeout=3)
assert not subscriber.thread.is_alive()
def test_subscriber_does_not_publish_queued_stale_points():
subscriber = RrdSubscriber(RerunSceneSettings)
try:
drain_until(subscriber, lambda: subscriber.output.empty())
subscriber.offer(points(age=5))
assert subscriber.read() == b""
assert subscriber.snapshot()["sequence"] == 0
subscriber.offer(points(2))
drain_until(subscriber, lambda: subscriber.snapshot()["sequence"] == 2)
finally:
subscriber.close()
subscriber.thread.join(timeout=3)
def test_reopened_viewer_does_not_replay_cached_points_after_source_pause(monkeypatch):
bridge = NodeRerunBridge()
try:
bridge.process(points())
cached_at, frame = next(iter(bridge.latest.values()))
bridge.latest[type(frame)] = (cached_at - 5, frame)
sub = bridge.subscribe()
drain_until(sub, lambda: sub.output.empty())
assert sub.read() == b""
assert sub.snapshot()["sequence"] == 0
bridge.process(points(2))
drain_until(sub, lambda: sub.snapshot()["sequence"] == 2)
finally:
bridge.close()
sub.thread.join(timeout=3)