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