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
@@ -151,9 +151,21 @@ class NodeK1Sensor:
await self.peers.close(params.get("peer_id"))
return {"ok": True}
if action == "offer":
if item["snapshot"]["acquisition"] != "streaming":
raise ValueError("Acquisition is not active")
return await self.peers.offer(params)
acquisition_id = item["control"]["acquisition_id"]
if (item["snapshot"]["acquisition"] != "streaming" or not acquisition_id
or params.get("acquisition_id") != acquisition_id):
raise ValueError("Acquisition is not active or changed")
answer = await self.peers.offer(params)
# Signalling may outlive STOP or a replacement acquisition. Retire
# that disposable preview instead of attaching it to another run.
current = project_sensor(await self.raw_state(), node_id)
if (current is None or current["snapshot"]["acquisition"] != "streaming"
or current["snapshot"]["context"]["session_id"]
!= command["session"]["session_id"]
or current["control"]["acquisition_id"] != acquisition_id):
await self.peers.close(answer["peer_id"])
raise ValueError("Acquisition changed during preview signalling")
return answer
async with self.bridge.lock:
if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC):
raise ValueError("Command expired before dispatch")
+5 -1
View File
@@ -13,7 +13,7 @@ from uuid import uuid4
import aioice.ice
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
MEDIA_PROTOCOL = "missioncore.node-preview/v1"
MEDIA_PROTOCOL = "missioncore.node-preview/v2"
MAX_PAYLOAD = 8 * 1024 * 1024
FRAGMENT_BYTES = 16384
logger = logging.getLogger(__name__)
@@ -175,6 +175,10 @@ class NodeMediaPeers:
break
if payload:
await self.send(channel, payload)
if subscriber:
# Ordered after native bytes. Age is source arrival age,
# not time spent replaying an encoded preview backlog.
channel.send(json.dumps(subscriber.snapshot()))
except asyncio.CancelledError:
pass
except Exception as error:
+32 -6
View File
@@ -5,14 +5,19 @@ Latest-value queues discard decoded preview frames before encoding; encoded
RRD bytes are never dropped inside a stream. Slow viewers are closed instead.
"""
import logging
import queue
import threading
import time
from contextlib import suppress
from uuid import uuid4
from k1link.data_plane import DecodedPointCloudView
from k1link.viewer.rerun_bridge import RerunBridge
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
LIVE_FRAME_MAX_AGE_SECONDS = 2.0
logger = logging.getLogger(__name__)
class RrdSubscriber:
@@ -21,6 +26,7 @@ class RrdSubscriber:
self.inputs = queue.Queue(maxsize=2)
self.output = queue.Queue(maxsize=2)
self.settings_provider = settings_provider
self.last_frame = None
self.thread = threading.Thread(target=self.run, name="node-rerun-view", daemon=True)
self.thread.start()
@@ -41,6 +47,12 @@ class RrdSubscriber:
except queue.Empty:
return b""
def snapshot(self):
frame = self.last_frame
return {"type": "lidar-state", "sequence": frame[0] if frame else 0,
"age_ms": max(0, (time.monotonic_ns() - frame[1]) / 1_000_000) if frame else None,
"points": frame[2] if frame else 0}
def close(self):
self.closed.set()
@@ -58,7 +70,9 @@ class RrdSubscriber:
bridge.begin_session()
while not self.closed.is_set():
payload = binary.read()
if len(payload) > MAX_ENCODED_CHUNK:
# The SDK returns None while its live sink has no new bytes.
# An idle read is not EOF and must not retire the media peer.
if payload and len(payload) > MAX_ENCODED_CHUNK:
break
if payload:
# Bound both bytes and waiting time. The archive/producer
@@ -68,9 +82,20 @@ class RrdSubscriber:
envelope = self.inputs.get(timeout=0.1)
except queue.Empty:
continue
received = envelope.context.received_monotonic_ns
if (received is not None
and (time.monotonic_ns() - received) / 1_000_000_000
> LIVE_FRAME_MAX_AGE_SECONDS):
continue
bridge.process(envelope)
except Exception:
pass
if isinstance(envelope, DecodedPointCloudView):
self.last_frame = (
envelope.context.sequence,
envelope.context.received_monotonic_ns or time.monotonic_ns(),
envelope.point_count,
)
except Exception as error:
logger.warning("Node RRD subscriber failed exception=%s", type(error).__name__)
finally:
self.closed.set()
if bridge is not None:
@@ -98,7 +123,7 @@ class NodeRerunBridge(RerunBridge):
# It is continuously drained even when no viewer is attached.
self.binary.read()
with self.lock:
self.latest[type(envelope)] = envelope
self.latest[type(envelope)] = (time.monotonic(), envelope)
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
for subscriber in self.subscribers:
subscriber.offer(envelope)
@@ -113,8 +138,9 @@ class NodeRerunBridge(RerunBridge):
if self._closed or len(self.subscribers) >= 2:
raise RuntimeError("Live viewer unavailable")
subscriber = RrdSubscriber(self._settings_provider)
for envelope in self.latest.values():
subscriber.offer(envelope)
for observed, envelope in self.latest.values():
if time.monotonic() - observed <= LIVE_FRAME_MAX_AGE_SECONDS:
subscriber.offer(envelope)
self.subscribers.append(subscriber)
return subscriber