from __future__ import annotations import math import statistics import struct import threading import time from collections import deque from dataclasses import dataclass from typing import TypedDict import foxglove from foxglove import Channel from foxglove.channels import ( PointCloudChannel, PoseInFrameChannel, SceneUpdateChannel, ) from foxglove.messages import ( Color, LinePrimitive, LinePrimitiveLineType, PackedElementField, PackedElementFieldNumericType, Point3, PointCloud, Pose, PoseInFrame, Quaternion, SceneEntity, SceneUpdate, Timestamp, Vector3, ) from k1link.protocol.streams import ( LegacyPointCloudFrame, LegacyPoseFrame, LioPointCloudFrame, LioPoseFrame, StreamDecodeError, decode_legacy_pointcloud, decode_legacy_pose, decode_lio_pcl, decode_lio_pose, ) from k1link.viewer.messages import StreamMessage POINT_STRUCT = struct.Struct(" None: self._lock = threading.Lock() self._messages_received = 0 self._payload_bytes = 0 self._pcl_frames = 0 self._pose_frames = 0 self._points_published = 0 self._last_point_count = 0 self._decode_errors = 0 self._preview_dropped = 0 self._trajectory_poses = 0 self._pcl_times: deque[int] = deque() self._pose_times: deque[int] = deque() self._latencies_ms: deque[float] = deque(maxlen=512) self._decode_publish_ms: float | None = None def received(self, payload_bytes: int) -> None: with self._lock: self._messages_received += 1 self._payload_bytes += payload_bytes def published_pcl(self, point_count: int, now_ns: int, decode_publish_ms: float) -> None: with self._lock: self._pcl_frames += 1 self._points_published += point_count self._last_point_count = point_count self._decode_publish_ms = decode_publish_ms self._pcl_times.append(now_ns) _trim_rate_window(self._pcl_times, now_ns) def published_pose(self, now_ns: int, decode_publish_ms: float, path_size: int) -> None: with self._lock: self._pose_frames += 1 self._decode_publish_ms = decode_publish_ms self._trajectory_poses = path_size self._pose_times.append(now_ns) _trim_rate_window(self._pose_times, now_ns) def record_latency(self, milliseconds: float) -> None: if not math.isfinite(milliseconds) or milliseconds < 0: return with self._lock: self._latencies_ms.append(milliseconds) def decode_error(self) -> None: with self._lock: self._decode_errors += 1 def preview_dropped(self) -> None: with self._lock: self._preview_dropped += 1 def snapshot(self) -> MetricsSnapshot: now_ns = time.monotonic_ns() with self._lock: _trim_rate_window(self._pcl_times, now_ns) _trim_rate_window(self._pose_times, now_ns) latencies = list(self._latencies_ms) last_latency = latencies[-1] if latencies else None p50 = statistics.median(latencies) if latencies else None p95 = _percentile(latencies, 0.95) if latencies else None return { "messages_received": self._messages_received, "payload_bytes": self._payload_bytes, "pcl_frames": self._pcl_frames, "pose_frames": self._pose_frames, "points_published": self._points_published, "last_point_count": self._last_point_count, "decode_errors": self._decode_errors, "preview_dropped": self._preview_dropped, "pcl_fps": _window_rate(self._pcl_times), "pose_fps": _window_rate(self._pose_times), "mqtt_to_publish_ms": _rounded(last_latency), "mqtt_to_publish_p50_ms": _rounded(p50), "mqtt_to_publish_p95_ms": _rounded(p95), "decode_publish_ms": _rounded(self._decode_publish_ms), "trajectory_poses": self._trajectory_poses, } class FoxgloveBridge: """Decode verified K1 topics and publish Foxglove-native visualization messages.""" def __init__( self, *, host: str = "127.0.0.1", port: int = 8765, metrics: BridgeMetrics | None = None, ) -> None: self.metrics = metrics or BridgeMetrics() self._server = foxglove.start_server( name="Mission Core K1 legacy bridge", host=host, port=port, message_backlog_size=32, ) self._points = PointCloudChannel("/k1/points") self._pose = PoseInFrameChannel("/k1/pose") self._trajectory = SceneUpdateChannel("/k1/trajectory") self._metrics = Channel( "/k1/metrics", schema={ "type": "object", "properties": { "mqtt_to_publish_ms": {"type": ["number", "null"]}, "mqtt_to_publish_p50_ms": {"type": ["number", "null"]}, "mqtt_to_publish_p95_ms": {"type": ["number", "null"]}, "decode_publish_ms": {"type": ["number", "null"]}, "point_count": {"type": "integer"}, "pcl_fps": {"type": "number"}, "pose_fps": {"type": "number"}, "preview_dropped": {"type": "integer"}, }, }, ) self._path: deque[tuple[float, float, float]] = deque(maxlen=MAX_TRAJECTORY_POSES) self._last_trajectory_publish_ns = 0 self._last_point_count = 0 self._closed = False @property def port(self) -> int: return int(self._server.port) @property def websocket_url(self) -> str: return f"ws://127.0.0.1:{self.port}" @property def viewer_url(self) -> str: return self._server.app_url() or "https://app.foxglove.dev/" def process(self, message: StreamMessage) -> None: started_ns = time.monotonic_ns() self.metrics.received(len(message.payload)) try: if message.topic.endswith("/lio_pcl"): self._publish_lio_pcl(decode_lio_pcl(message.payload), message) elif message.topic == "RealtimePointcloud": self._publish_legacy_pcl(decode_legacy_pointcloud(message.payload), message) elif message.topic.endswith("/lio_pose"): self._publish_lio_pose(decode_lio_pose(message.payload), message) elif message.topic == "RealtimePath": self._publish_legacy_pose(decode_legacy_pose(message.payload), message) else: return except StreamDecodeError: self.metrics.decode_error() return published_ns = time.monotonic_ns() decode_publish_ms = (published_ns - started_ns) / 1_000_000 if message.topic.endswith("/lio_pcl") or message.topic == "RealtimePointcloud": self.metrics.published_pcl(self._last_point_count, published_ns, decode_publish_ms) else: self.metrics.published_pose(published_ns, decode_publish_ms, len(self._path)) if message.received_monotonic_ns is not None: self.metrics.record_latency((published_ns - message.received_monotonic_ns) / 1_000_000) snapshot = self.metrics.snapshot() self._metrics.log( { "mqtt_to_publish_ms": snapshot["mqtt_to_publish_ms"], "mqtt_to_publish_p50_ms": snapshot["mqtt_to_publish_p50_ms"], "mqtt_to_publish_p95_ms": snapshot["mqtt_to_publish_p95_ms"], "decode_publish_ms": snapshot["decode_publish_ms"], "point_count": snapshot["last_point_count"], "pcl_fps": snapshot["pcl_fps"], "pose_fps": snapshot["pose_fps"], "preview_dropped": snapshot["preview_dropped"], }, log_time=message.received_at_epoch_ns, ) def close(self) -> None: if self._closed: return self._closed = True for channel in (self._points, self._pose, self._trajectory, self._metrics): channel.close() self._server.stop() def _publish_lio_pcl(self, frame: LioPointCloudFrame, message: StreamMessage) -> None: packed = pack_lio_point_cloud(frame) self._publish_point_cloud(packed, message) def _publish_legacy_pcl( self, frame: LegacyPointCloudFrame, message: StreamMessage, ) -> None: packed = pack_legacy_point_cloud(frame) self._publish_point_cloud(packed, message) def _publish_point_cloud(self, packed: PackedPointCloud, message: StreamMessage) -> None: timestamp = _timestamp(message.received_at_epoch_ns) self._points.log( PointCloud( timestamp=timestamp, frame_id=FRAME_ID, pose=_identity_pose(), point_stride=POINT_STRIDE, fields=_point_fields(), data=packed.data, ), log_time=message.received_at_epoch_ns, ) self._last_point_count = packed.point_count def _publish_lio_pose(self, frame: LioPoseFrame, message: StreamMessage) -> None: self._publish_pose(frame.position_xyz, frame.orientation_xyzw, message) def _publish_legacy_pose(self, frame: LegacyPoseFrame, message: StreamMessage) -> None: self._publish_pose(frame.position_xyz, frame.orientation_xyzw, message) def _publish_pose( self, position_xyz: tuple[float, float, float], orientation_xyzw: tuple[float, float, float, float], message: StreamMessage, ) -> None: pose = Pose( position=Vector3(x=position_xyz[0], y=position_xyz[1], z=position_xyz[2]), orientation=Quaternion( x=orientation_xyzw[0], y=orientation_xyzw[1], z=orientation_xyzw[2], w=orientation_xyzw[3], ), ) timestamp = _timestamp(message.received_at_epoch_ns) self._pose.log( PoseInFrame(timestamp=timestamp, frame_id=FRAME_ID, pose=pose), log_time=message.received_at_epoch_ns, ) self._path.append(position_xyz) now_ns = time.monotonic_ns() if ( len(self._path) > 2 and len(self._path) % 20 and now_ns - self._last_trajectory_publish_ns < 200_000_000 ): return self._last_trajectory_publish_ns = now_ns line_points = [Point3(x=item[0], y=item[1], z=item[2]) for item in self._path] self._trajectory.log( SceneUpdate( entities=[ SceneEntity( timestamp=timestamp, frame_id=FRAME_ID, id="k1-trajectory", frame_locked=True, lines=[ LinePrimitive( type=LinePrimitiveLineType.LineStrip, thickness=3.0, scale_invariant=True, points=line_points, color=Color(r=0.08, g=0.82, b=1.0, a=1.0), ) ], ) ] ), log_time=message.received_at_epoch_ns, ) def pack_lio_point_cloud(frame: LioPointCloudFrame) -> PackedPointCloud: data = bytearray(len(frame.points) * POINT_STRIDE) scaler = frame.header.scaler for index, point in enumerate(frame.points): x, y, z = point.scaled_xyz(scaler) POINT_STRUCT.pack_into(data, index * POINT_STRIDE, x, y, z, point.intensity) return PackedPointCloud(data=bytes(data), point_count=len(frame.points)) def pack_legacy_point_cloud(frame: LegacyPointCloudFrame) -> PackedPointCloud: data = bytearray(len(frame.points) * POINT_STRIDE) for index, point in enumerate(frame.points): POINT_STRUCT.pack_into( data, index * POINT_STRIDE, point.x, point.y, point.z, point.intensity, ) return PackedPointCloud(data=bytes(data), point_count=len(frame.points)) def _point_fields() -> list[PackedElementField]: return [ PackedElementField(name="x", offset=0, type=PackedElementFieldNumericType.Float32), PackedElementField(name="y", offset=4, type=PackedElementFieldNumericType.Float32), PackedElementField(name="z", offset=8, type=PackedElementFieldNumericType.Float32), PackedElementField( name="intensity", offset=12, type=PackedElementFieldNumericType.Uint8, ), ] def _identity_pose() -> Pose: return Pose(position=Vector3(), orientation=Quaternion(w=1.0)) def _timestamp(epoch_ns: int) -> Timestamp: return Timestamp(epoch_ns // 1_000_000_000, epoch_ns % 1_000_000_000) def _trim_rate_window(values: deque[int], now_ns: int) -> None: cutoff = now_ns - 1_000_000_000 while values and values[0] < cutoff: values.popleft() def _window_rate(values: deque[int]) -> float: if len(values) < 2: return float(len(values)) elapsed = (values[-1] - values[0]) / 1_000_000_000 return len(values) / max(elapsed, 1.0) def _percentile(values: list[float], fraction: float) -> float: if not values: raise ValueError("cannot calculate a percentile of an empty sample") ordered = sorted(values) index = min(len(ordered) - 1, math.ceil(len(ordered) * fraction) - 1) return ordered[index] def _rounded(value: float | None) -> float | None: return None if value is None else round(value, 3)