chore(node): preserve pre-canonicalization experiment snapshot
Historical working copy retained for audit before consolidation into main. The canonicalized plugin architecture and later fixes already live in main; this snapshot is not a release or a request to restore obsolete source layout.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""Paired signalling, private ICE candidates and bounded live data channels."""
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import queue
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from uuid import uuid4
|
||||
|
||||
import aioice.ice
|
||||
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription
|
||||
|
||||
PRIVATE_NETWORKS = tuple(
|
||||
ipaddress.ip_network(v)
|
||||
for v in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")
|
||||
)
|
||||
|
||||
|
||||
def private(address):
|
||||
try:
|
||||
value = ipaddress.ip_address(address)
|
||||
return value.version == 4 and any(value in network for network in PRIVATE_NETWORKS)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def admit_sdp(sdp):
|
||||
if not isinstance(sdp, str) or not 1 <= len(sdp) <= 32768:
|
||||
raise ValueError("Invalid media invitation")
|
||||
media = [line for line in sdp.splitlines() if line.startswith("m=")]
|
||||
if not sdp.startswith("v=0") or len(media) != 1 or not media[0].startswith("m=application "):
|
||||
raise ValueError("A single data-channel media section is required")
|
||||
for line in sdp.splitlines():
|
||||
if line.startswith("a=candidate:"):
|
||||
parts = line.split()
|
||||
if (
|
||||
len(parts) < 8
|
||||
or parts[7] != "host"
|
||||
or not (private(parts[4]) or parts[4].endswith(".local"))
|
||||
):
|
||||
raise ValueError("Only private host ICE candidates are admitted")
|
||||
|
||||
|
||||
class NodeMediaPeers:
|
||||
def __init__(self, hub, camera):
|
||||
self.hub, self.camera, self.items = hub, camera, {}
|
||||
# Process-local adapter in this dedicated worker; no public/STUN/TURN ICE.
|
||||
original = aioice.ice.get_host_addresses
|
||||
aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [
|
||||
value for value in original(use_ipv4=True, use_ipv6=False) if private(value)
|
||||
]
|
||||
|
||||
async def offer(self, parameters):
|
||||
admit_sdp(parameters.get("sdp"))
|
||||
if len(self.items) >= 2:
|
||||
raise ValueError("Close another live viewer")
|
||||
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
|
||||
identifier = "peer_" + uuid4().hex
|
||||
entry = {"pc": pc, "seen": time.monotonic(), "tasks": [], "labels": set()}
|
||||
self.items[identifier] = entry
|
||||
|
||||
@pc.on("datachannel")
|
||||
def datachannel(channel):
|
||||
if channel.label not in {"rrd", "camera"} or channel.label in entry["labels"]:
|
||||
channel.close()
|
||||
return
|
||||
entry["labels"].add(channel.label)
|
||||
|
||||
@channel.on("message")
|
||||
def message(value):
|
||||
if value == "keepalive":
|
||||
entry["seen"] = time.monotonic()
|
||||
|
||||
entry["tasks"].append(asyncio.create_task(self.deliver(identifier, channel)))
|
||||
|
||||
@pc.on("connectionstatechange")
|
||||
async def changed():
|
||||
if pc.connectionState in {"closed", "failed"}:
|
||||
await self.close(identifier)
|
||||
|
||||
try:
|
||||
await pc.setRemoteDescription(
|
||||
RTCSessionDescription(sdp=parameters["sdp"], type="offer")
|
||||
)
|
||||
await pc.setLocalDescription(await pc.createAnswer())
|
||||
|
||||
async def expiry():
|
||||
await asyncio.sleep(25)
|
||||
if pc.connectionState != "connected":
|
||||
await self.close(identifier)
|
||||
|
||||
entry["tasks"].append(asyncio.create_task(expiry()))
|
||||
snapshot = self.camera.snapshot()
|
||||
return {
|
||||
"peer_id": identifier,
|
||||
"sdp": pc.localDescription.sdp,
|
||||
"type": "answer",
|
||||
"camera_mime": (snapshot.get("delivery") or {}).get("media_type"),
|
||||
"profile": "live-acquisition",
|
||||
"transport": "webrtc-rrd-fmp4",
|
||||
}
|
||||
except BaseException:
|
||||
await self.close(identifier)
|
||||
raise
|
||||
|
||||
async def send(self, channel, payload):
|
||||
if len(payload) > 8 * 1024 * 1024:
|
||||
raise RuntimeError("Preview fragment exceeds bound")
|
||||
for offset in range(0, len(payload), 16384):
|
||||
deadline = time.monotonic() + 2
|
||||
while channel.readyState != "open" or channel.bufferedAmount > 1024 * 1024:
|
||||
if channel.readyState in {"closed", "closing"} or time.monotonic() > deadline:
|
||||
raise RuntimeError("Preview consumer unavailable")
|
||||
await asyncio.sleep(0.01)
|
||||
channel.send(payload[offset : offset + 16384])
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def deliver(self, identifier, channel):
|
||||
subscriber = lease = None
|
||||
try:
|
||||
entry = self.items[identifier]
|
||||
if channel.label == "rrd":
|
||||
subscriber = await asyncio.to_thread(self.hub.subscribe)
|
||||
else:
|
||||
generation = self.camera.snapshot().get("generation")
|
||||
if generation is None:
|
||||
channel.close()
|
||||
return
|
||||
lease = await asyncio.to_thread(self.camera.open_delivery, generation)
|
||||
while identifier in self.items and time.monotonic() - entry["seen"] < 30:
|
||||
if subscriber:
|
||||
payload = await asyncio.to_thread(subscriber.read)
|
||||
else:
|
||||
try:
|
||||
segment = await asyncio.to_thread(lease.segments.get, 0.5)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if segment is None:
|
||||
break
|
||||
kind, payload = segment
|
||||
if kind == "media":
|
||||
self.camera.mark_streaming(lease)
|
||||
if payload is None:
|
||||
break
|
||||
if payload:
|
||||
await self.send(channel, payload)
|
||||
except (Exception, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
if subscriber:
|
||||
subscriber.close()
|
||||
if lease:
|
||||
self.camera.release_delivery(lease, client_closed=True)
|
||||
if channel.label == "camera":
|
||||
channel.close()
|
||||
else:
|
||||
await self.close(identifier)
|
||||
|
||||
async def close(self, identifier):
|
||||
entry = self.items.pop(identifier, None)
|
||||
if entry:
|
||||
for task in entry["tasks"]:
|
||||
if task is not asyncio.current_task():
|
||||
task.cancel()
|
||||
with suppress(Exception):
|
||||
await entry["pc"].close()
|
||||
|
||||
async def close_all(self):
|
||||
for identifier in list(self.items):
|
||||
await self.close(identifier)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Bounded live RRD publication for paired Node viewers, without a TCP listener.
|
||||
|
||||
Every viewer receives a fresh native recording including StoreInfo/blueprint.
|
||||
Latest-value queues discard decoded preview frames before encoding; encoded
|
||||
RRD bytes are never dropped inside a stream. Slow viewers are closed instead.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import threading
|
||||
from contextlib import suppress
|
||||
from uuid import uuid4
|
||||
|
||||
from k1link.viewer.rerun_bridge import RerunBridge
|
||||
|
||||
MAX_ENCODED_CHUNK = 8 * 1024 * 1024
|
||||
|
||||
|
||||
class RrdSubscriber:
|
||||
def __init__(self, settings_provider):
|
||||
self.closed = threading.Event()
|
||||
self.inputs = queue.Queue(maxsize=2)
|
||||
self.output = queue.Queue(maxsize=2)
|
||||
self.settings_provider = settings_provider
|
||||
self.thread = threading.Thread(target=self.run, name="node-rerun-view", daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def offer(self, envelope):
|
||||
if self.closed.is_set():
|
||||
return
|
||||
with suppress(queue.Full):
|
||||
if self.inputs.full():
|
||||
with suppress(queue.Empty):
|
||||
self.inputs.get_nowait()
|
||||
self.inputs.put_nowait(envelope)
|
||||
|
||||
def read(self):
|
||||
if self.closed.is_set():
|
||||
return None
|
||||
try:
|
||||
return self.output.get(timeout=0.5)
|
||||
except queue.Empty:
|
||||
return b""
|
||||
|
||||
def close(self):
|
||||
self.closed.set()
|
||||
|
||||
def run(self):
|
||||
binary = None
|
||||
bridge = None
|
||||
|
||||
def output(recording):
|
||||
nonlocal binary
|
||||
binary = recording.binary_stream()
|
||||
return "webrtc+rrd://" + str(uuid4())
|
||||
|
||||
try:
|
||||
bridge = RerunBridge(settings_provider=self.settings_provider, recording_output=output)
|
||||
bridge.begin_session()
|
||||
while not self.closed.is_set():
|
||||
payload = binary.read()
|
||||
if len(payload) > MAX_ENCODED_CHUNK:
|
||||
break
|
||||
if payload:
|
||||
# Bound both bytes and waiting time. The archive/producer
|
||||
# never waits for this disposable preview subscription.
|
||||
self.output.put(payload, timeout=0.5)
|
||||
try:
|
||||
envelope = self.inputs.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
bridge.process(envelope)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self.closed.set()
|
||||
if bridge is not None:
|
||||
with suppress(Exception):
|
||||
bridge.close()
|
||||
binary.read()
|
||||
|
||||
|
||||
class NodeRerunBridge(RerunBridge):
|
||||
def __init__(self, **kwargs):
|
||||
self.lock = threading.Lock()
|
||||
self.subscribers = []
|
||||
self.latest = {}
|
||||
|
||||
def output(recording):
|
||||
self.binary = recording.binary_stream()
|
||||
return "webrtc+rrd://" + str(uuid4())
|
||||
|
||||
super().__init__(**kwargs, recording_output=output)
|
||||
self.binary.read()
|
||||
|
||||
def process(self, envelope):
|
||||
super().process(envelope)
|
||||
# The primary recording proves native publication and owns metrics.
|
||||
# It is continuously drained even when no viewer is attached.
|
||||
self.binary.read()
|
||||
with self.lock:
|
||||
self.latest[type(envelope)] = envelope
|
||||
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
|
||||
for subscriber in self.subscribers:
|
||||
subscriber.offer(envelope)
|
||||
|
||||
def process_perception(self, frame):
|
||||
super().process_perception(frame)
|
||||
self.binary.read()
|
||||
|
||||
def subscribe(self):
|
||||
with self.lock:
|
||||
self.subscribers = [v for v in self.subscribers if not v.closed.is_set()]
|
||||
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)
|
||||
self.subscribers.append(subscriber)
|
||||
return subscriber
|
||||
|
||||
def close(self):
|
||||
with self.lock:
|
||||
for subscriber in self.subscribers:
|
||||
subscriber.close()
|
||||
self.subscribers.clear()
|
||||
self.latest.clear()
|
||||
super().close()
|
||||
self.binary.read()
|
||||
|
||||
|
||||
class NodeRerunHub:
|
||||
def __init__(self):
|
||||
self.bridge = None
|
||||
|
||||
def create(self, **kwargs):
|
||||
bridge = NodeRerunBridge(**kwargs)
|
||||
self.bridge = bridge
|
||||
return bridge
|
||||
|
||||
def subscribe(self):
|
||||
bridge = self.bridge
|
||||
if bridge is None:
|
||||
raise RuntimeError("Live acquisition is not active")
|
||||
return bridge.subscribe()
|
||||
@@ -118,6 +118,7 @@ class RerunBridge:
|
||||
settings_provider: SettingsProvider | None = None,
|
||||
cors_allow_origin: tuple[str, ...] = DEFAULT_CORS_ORIGINS,
|
||||
recording_factory: Callable[[str], rr.RecordingStream] | None = None,
|
||||
recording_output: Callable[[rr.RecordingStream], str] | None = None,
|
||||
) -> None:
|
||||
self.metrics = metrics or BridgeMetrics()
|
||||
self._settings_provider = settings_provider or RerunSceneSettings
|
||||
@@ -130,31 +131,29 @@ class RerunBridge:
|
||||
else:
|
||||
recording = recording_factory("nodedc_mission_core_spatial")
|
||||
try:
|
||||
selected_grpc_port = _select_available_grpc_port(grpc_port)
|
||||
if selected_grpc_port != grpc_port:
|
||||
logger.info(
|
||||
"Mission Core selected a new Rerun port because an earlier "
|
||||
"viewer still owns the preferred listener",
|
||||
extra={
|
||||
"event_code": "rerun_grpc_port_rotated",
|
||||
"preferred_port": grpc_port,
|
||||
"selected_port": selected_grpc_port,
|
||||
},
|
||||
)
|
||||
blueprint = _blueprint(self._settings)
|
||||
url = recording.serve_grpc(
|
||||
grpc_port=selected_grpc_port,
|
||||
default_blueprint=blueprint,
|
||||
# This is a reconnect cushion for the live preview, not the source
|
||||
# of record. Raw MQTT evidence is persisted independently. A large
|
||||
# late-client backlog can block the native SDK and freeze preview.
|
||||
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
|
||||
# Rerun transport can replay ActivateStore before StoreInfo when an
|
||||
# evicted buffer is served newest-first, leaving late viewers on the
|
||||
# welcome screen. Preserve protocol order within the bounded cache.
|
||||
newest_first=False,
|
||||
cors_allow_origin=list(cors_allow_origin),
|
||||
)
|
||||
if recording_output is not None:
|
||||
# Node's paired WebRTC delivery uses an RRD sink and never opens
|
||||
# an unauthenticated gRPC listener on an onboard interface.
|
||||
url = recording_output(recording)
|
||||
else:
|
||||
selected_grpc_port = _select_available_grpc_port(grpc_port)
|
||||
if selected_grpc_port != grpc_port:
|
||||
logger.info(
|
||||
"Mission Core selected a new Rerun port because an earlier viewer "
|
||||
"still owns the preferred listener",
|
||||
extra={"event_code": "rerun_grpc_port_rotated", "preferred_port": grpc_port,
|
||||
"selected_port": selected_grpc_port},
|
||||
)
|
||||
url = recording.serve_grpc(
|
||||
grpc_port=selected_grpc_port,
|
||||
default_blueprint=blueprint,
|
||||
# Bounded reconnect cushion, not the source of record.
|
||||
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
|
||||
# StoreInfo must precede ActivateStore for late viewers.
|
||||
newest_first=False,
|
||||
cors_allow_origin=list(cors_allow_origin),
|
||||
)
|
||||
recording.send_blueprint(
|
||||
blueprint,
|
||||
make_active=True,
|
||||
|
||||
Reference in New Issue
Block a user