Share the K1 live scene template and preserve idle media channels
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user