feat: add live K1 Foxglove console

This commit is contained in:
DCCONSTRUCTIONS
2026-07-15 21:53:43 +03:00
parent 6b22e5a1d2
commit 6be96f0b85
34 changed files with 6037 additions and 15 deletions
+28 -2
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Annotated, TypedDict
import typer
import uvicorn
from bleak.exc import BleakError
from rich.console import Console
from rich.table import Table
@@ -200,6 +201,32 @@ def doctor(
console.print(f"- {note}")
@app.command("serve")
def serve_console(
port: Annotated[
int,
typer.Option(min=1024, max=65535, help="Loopback HTTP port for the local console."),
] = 8000,
) -> None:
"""Serve the built K1 console and local-only control API on loopback."""
frontend = Path(__file__).resolve().parents[2] / "apps" / "k1-viewer" / "dist"
if not frontend.is_dir():
console.print(
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
"inside apps/k1-viewer."
)
raise typer.Exit(code=2)
console.print(f"K1 Live Console: http://127.0.0.1:{port}")
console.print("The credential endpoint is bound to this Mac only.")
uvicorn.run(
"k1link.web.app:app",
host="127.0.0.1",
port=port,
log_level="info",
access_log=True,
)
@ble_app.command("scan")
def ble_scan(
out: Annotated[
@@ -300,8 +327,7 @@ def ble_wifi_configure(
raise typer.Exit(code=2)
if not confirm_write:
console.print(
"[red]Write not confirmed.[/red] "
"Add --confirm-write after reviewing the profile."
"[red]Write not confirmed.[/red] Add --confirm-write after reviewing the profile."
)
raise typer.Exit(code=2)
+2
View File
@@ -4,6 +4,7 @@ from k1link.mqtt.capture import (
DEFAULT_MAX_MESSAGE_BYTES,
MAX_CONFIGURABLE_MESSAGE_BYTES,
REPORT_TOPICS,
CapturedMqttMessage,
CaptureError,
CaptureFormatError,
CaptureFrame,
@@ -21,6 +22,7 @@ __all__ = [
"CaptureFormatError",
"CaptureFrame",
"CaptureSummary",
"CapturedMqttMessage",
"capture_mqtt",
"iter_capture_frames",
"validate_private_ipv4",
+46 -11
View File
@@ -44,6 +44,7 @@ _PRIVATE_NETWORKS = tuple(
StopReason = Literal[
"duration_elapsed",
"external_stop",
"keyboard_interrupt",
"message_too_large",
"connection_failed",
@@ -127,6 +128,21 @@ class CaptureFrame:
raw_frame_bytes: int
@dataclass(frozen=True, slots=True)
class CapturedMqttMessage:
"""A message made durable by the raw writer and ready for live preview."""
sequence: int
topic: str
payload: bytes
qos: int
retain: bool
dup: bool
received_at_utc: str
received_at_epoch_ns: int
received_monotonic_ns: int
@dataclass
class _CaptureState:
connected: bool = False
@@ -168,19 +184,19 @@ class _CaptureWriter:
self.close()
raise
def record(self, message: mqtt.MQTTMessage) -> None:
def record(self, message: mqtt.MQTTMessage) -> CapturedMqttMessage:
raw = self._require_raw()
metadata = self._require_metadata()
topic = message.topic
topic_bytes = topic.encode("utf-8")
payload = message.payload
received_at_utc = utc_now_iso()
received_at_epoch_ns = time.time_ns()
received_monotonic_ns = time.monotonic_ns()
if not 1 <= len(topic_bytes) <= MAX_TOPIC_BYTES:
raise ValueError(
f"incoming MQTT topic is {len(topic_bytes)} bytes; "
f"expected 1..{MAX_TOPIC_BYTES}"
f"incoming MQTT topic is {len(topic_bytes)} bytes; expected 1..{MAX_TOPIC_BYTES}"
)
if len(payload) > self.max_message_bytes:
@@ -218,6 +234,7 @@ class _CaptureWriter:
"record_type": "message",
"sequence": self.message_count,
"received_at_utc": received_at_utc,
"received_at_epoch_ns": received_at_epoch_ns,
"received_monotonic_ns": received_monotonic_ns,
"topic": topic,
"qos": message.qos,
@@ -230,6 +247,17 @@ class _CaptureWriter:
"raw_frame_bytes": frame_bytes,
}
self._write_metadata(metadata, record)
return CapturedMqttMessage(
sequence=self.message_count,
topic=topic,
payload=payload,
qos=message.qos,
retain=message.retain,
dup=message.dup,
received_at_utc=received_at_utc,
received_at_epoch_ns=received_at_epoch_ns,
received_monotonic_ns=received_monotonic_ns,
)
def close(self) -> None:
first_error: OSError | None = None
@@ -296,8 +324,7 @@ def iter_capture_frames(
"""Yield validated frames from a K1 MQTT raw capture without decoding payloads."""
if not 1 <= max_payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
raise ValueError(
"max_payload_bytes must be between 1 and "
f"{MAX_CONFIGURABLE_MESSAGE_BYTES}"
f"max_payload_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}"
)
if not 1 <= max_topic_bytes <= MAX_TOPIC_BYTES:
raise ValueError(f"max_topic_bytes must be between 1 and {MAX_TOPIC_BYTES}")
@@ -367,6 +394,8 @@ def capture_mqtt(
duration_seconds: float = 60.0,
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
on_ready: Callable[[], None] | None = None,
on_message_recorded: Callable[[CapturedMqttMessage], None] | None = None,
should_stop: Callable[[], bool] | None = None,
_client_factory: Callable[[], mqtt.Client] | None = None,
) -> CaptureSummary:
"""Capture the fixed K1 report subscriptions once, without publishing or reconnecting."""
@@ -377,8 +406,7 @@ def capture_mqtt(
raise ValueError("duration_seconds must be finite and greater than zero")
if not 1 <= max_message_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
raise ValueError(
"max_message_bytes must be between 1 and "
f"{MAX_CONFIGURABLE_MESSAGE_BYTES}"
f"max_message_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}"
)
client = (
@@ -453,11 +481,17 @@ def capture_mqtt(
if state.error is not None:
return
try:
writer.record(message)
recorded = writer.record(message)
except MessageTooLargeError as exc:
fail("message_too_large", str(exc))
except (OSError, RuntimeError, ValueError) as exc:
fail("capture_error", f"artifact write failed: {type(exc).__name__}: {exc}")
return
if on_message_recorded is not None:
try:
on_message_recorded(recorded)
except (OSError, RuntimeError, ValueError) as exc:
fail("capture_error", f"preview callback failed: {type(exc).__name__}: {exc}")
def on_disconnect(
_callback_client: mqtt.Client,
@@ -487,6 +521,9 @@ def capture_mqtt(
while state.error is None:
now = time.monotonic()
if should_stop is not None and should_stop():
state.stop_reason = "external_stop"
break
if state.subscribed and capture_started is None:
capture_started = now
if on_ready is not None:
@@ -523,9 +560,7 @@ def capture_mqtt(
fail("capture_error", f"artifact close failed: {type(exc).__name__}: {exc}")
operation_completed = time.monotonic()
capture_elapsed = (
0.0 if capture_started is None else operation_completed - capture_started
)
capture_elapsed = 0.0 if capture_started is None else operation_completed - capture_started
summary = _build_summary(
writer=writer,
+1
View File
@@ -0,0 +1 @@
+15
View File
@@ -0,0 +1,15 @@
"""Live/replay visualization bridge for verified K1 MQTT streams."""
from k1link.viewer.messages import StreamMessage
from k1link.viewer.replay import (
ReplayFormatError,
detect_replay_format,
iter_replay_messages,
)
__all__ = [
"ReplayFormatError",
"StreamMessage",
"detect_replay_format",
"iter_replay_messages",
]
+417
View File
@@ -0,0 +1,417 @@
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("<fffB3x")
POINT_STRIDE = POINT_STRUCT.size
FRAME_ID = "map"
MAX_TRAJECTORY_POSES = 20_000
class MetricsSnapshot(TypedDict):
messages_received: int
payload_bytes: int
pcl_frames: int
pose_frames: int
points_published: int
last_point_count: int
decode_errors: int
preview_dropped: int
pcl_fps: float
pose_fps: float
mqtt_to_publish_ms: float | None
mqtt_to_publish_p50_ms: float | None
mqtt_to_publish_p95_ms: float | None
decode_publish_ms: float | None
trajectory_poses: int
@dataclass(frozen=True, slots=True)
class PackedPointCloud:
data: bytes
point_count: int
class BridgeMetrics:
"""Thread-safe counters shared with the local control API."""
def __init__(self) -> 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="NODE.DC K1 live 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)
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class StreamMessage:
"""One MQTT message entering the derived visualization pipeline."""
sequence: int
topic: str
payload: bytes
received_at_epoch_ns: int
received_monotonic_ns: int | None = None
source: str = "replay"
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
import json
import time
from collections.abc import Generator, Iterator
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import IO, Literal
from k1link.mqtt import CaptureFormatError, iter_capture_frames
from k1link.mqtt.capture import RAW_MAGIC
from k1link.viewer.messages import StreamMessage
MAX_REPLAY_PAYLOAD_BYTES = 2 * 1024 * 1024
MAX_LEGACY_LINE_BYTES = MAX_REPLAY_PAYLOAD_BYTES * 2 + 64 * 1024
MAX_METADATA_LINE_CHARS = 1024 * 1024
ReplayFormat = Literal["k1mqtt", "legacy_tsv"]
class ReplayFormatError(ValueError):
"""A replay input is corrupt or outside the reviewed bounds."""
def detect_replay_format(path: Path) -> ReplayFormat:
resolved = path.expanduser()
with resolved.open("rb") as stream:
prefix = stream.read(len(RAW_MAGIC))
if prefix == RAW_MAGIC:
return "k1mqtt"
if resolved.suffix.casefold() == ".tsv":
return "legacy_tsv"
raise ReplayFormatError("input is neither a K1MQTT capture nor the reviewed legacy TSV")
def iter_replay_messages(path: Path) -> Generator[StreamMessage, None, None]:
"""Yield bounded messages from native evidence or the one reviewed TSV export."""
resolved = path.expanduser().resolve()
replay_format = detect_replay_format(resolved)
if replay_format == "legacy_tsv":
yield from _iter_legacy_tsv(resolved)
return
yield from _iter_native_capture(resolved)
def _iter_native_capture(path: Path) -> Iterator[StreamMessage]:
metadata_path = path.with_name("mqtt.metadata.jsonl")
metadata_stream: IO[str] | None = None
if metadata_path.is_file():
metadata_stream = metadata_path.open("r", encoding="utf-8")
fallback_epoch_ns = time.time_ns()
try:
for frame in iter_capture_frames(path, max_payload_bytes=MAX_REPLAY_PAYLOAD_BYTES):
epoch_ns = fallback_epoch_ns + frame.sequence - 1
monotonic_ns: int | None = None
if metadata_stream is not None:
epoch_ns, monotonic_ns = _read_native_timing(
metadata_stream,
expected_sequence=frame.sequence,
fallback_epoch_ns=epoch_ns,
)
yield StreamMessage(
sequence=frame.sequence,
topic=frame.topic,
payload=frame.payload,
received_at_epoch_ns=epoch_ns,
received_monotonic_ns=monotonic_ns,
source="k1mqtt",
)
except CaptureFormatError as exc:
raise ReplayFormatError(str(exc)) from exc
finally:
if metadata_stream is not None:
metadata_stream.close()
def _read_native_timing(
stream: IO[str],
*,
expected_sequence: int,
fallback_epoch_ns: int,
) -> tuple[int, int | None]:
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
if not line:
return fallback_epoch_ns, None
if len(line) > MAX_METADATA_LINE_CHARS:
raise ReplayFormatError("native metadata line exceeds the reviewed bound")
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
raise ReplayFormatError(
f"native metadata line {expected_sequence} is not valid JSON"
) from exc
if record.get("record_type") != "message" or record.get("sequence") != expected_sequence:
raise ReplayFormatError(f"native metadata is not aligned at message {expected_sequence}")
epoch_ns = record.get("received_at_epoch_ns", fallback_epoch_ns)
monotonic_ns = record.get("received_monotonic_ns")
if not isinstance(epoch_ns, int) or epoch_ns < 0:
raise ReplayFormatError("native metadata received_at_epoch_ns is invalid")
if monotonic_ns is not None and (not isinstance(monotonic_ns, int) or monotonic_ns < 0):
raise ReplayFormatError("native metadata received_monotonic_ns is invalid")
return epoch_ns, monotonic_ns
def _iter_legacy_tsv(path: Path) -> Iterator[StreamMessage]:
with path.open("rb") as stream:
line_number = 0
while True:
raw_line = stream.readline(MAX_LEGACY_LINE_BYTES + 1)
if not raw_line:
return
line_number += 1
if len(raw_line) > MAX_LEGACY_LINE_BYTES:
raise ReplayFormatError(
f"legacy TSV line {line_number} exceeds {MAX_LEGACY_LINE_BYTES} bytes"
)
stripped = raw_line.rstrip(b"\r\n")
if not stripped:
continue
columns = stripped.split(b"\t")
if len(columns) != 4:
raise ReplayFormatError(
f"legacy TSV line {line_number} must contain exactly four columns"
)
timestamp_raw, topic_raw, length_raw, payload_hex = columns
try:
timestamp = Decimal(timestamp_raw.decode("ascii"))
declared_length = int(length_raw.decode("ascii"), 10)
topic = topic_raw.decode("utf-8")
except (InvalidOperation, UnicodeDecodeError, ValueError) as exc:
raise ReplayFormatError(
f"legacy TSV line {line_number} has invalid timestamp/topic/length"
) from exc
if not timestamp.is_finite() or timestamp < 0:
raise ReplayFormatError(
f"legacy TSV line {line_number} timestamp is outside bounds"
)
if not topic:
raise ReplayFormatError(f"legacy TSV line {line_number} has an empty topic")
if declared_length < 0 or declared_length > MAX_REPLAY_PAYLOAD_BYTES:
raise ReplayFormatError(
f"legacy TSV line {line_number} payload length is outside bounds"
)
if len(payload_hex) != declared_length * 2:
raise ReplayFormatError(
f"legacy TSV line {line_number} declared payload length does not match hex"
)
try:
payload = bytes.fromhex(payload_hex.decode("ascii"))
except (UnicodeDecodeError, ValueError) as exc:
raise ReplayFormatError(
f"legacy TSV line {line_number} payload is not valid hex"
) from exc
epoch_ns = int(timestamp * Decimal(1_000_000_000))
yield StreamMessage(
sequence=line_number,
topic=topic,
payload=payload,
received_at_epoch_ns=epoch_ns,
source="legacy_tsv",
)
+394
View File
@@ -0,0 +1,394 @@
from __future__ import annotations
import math
import queue
import threading
import time
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal, TypedDict
from k1link.artifacts import utc_now_iso, write_json_atomic
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
from k1link.viewer.foxglove_bridge import BridgeMetrics, FoxgloveBridge, MetricsSnapshot
from k1link.viewer.messages import StreamMessage
from k1link.viewer.replay import iter_replay_messages
RuntimePhase = Literal[
"idle",
"starting_live",
"live",
"replay",
"stopping",
"error",
]
SourceMode = Literal["idle", "live", "replay"]
StateCallback = Callable[[], None]
class RuntimeSnapshot(TypedDict):
phase: RuntimePhase
message: str
source_mode: SourceMode
foxglove_ws_url: str | None
foxglove_viewer_url: str | None
metrics: MetricsSnapshot
class VisualizationRuntime:
"""Own one bounded live/replay source and one deterministic publisher thread."""
def __init__(self, *, on_state_change: StateCallback | None = None) -> None:
self._lock = threading.Lock()
self._on_state_change = on_state_change
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._phase: RuntimePhase = "idle"
self._message = "Ready for a live scanner or replay capture."
self._source_mode: SourceMode = "idle"
self._foxglove_ws_url: str | None = None
self._foxglove_viewer_url: str | None = None
self._metrics = BridgeMetrics()
def snapshot(self) -> RuntimeSnapshot:
with self._lock:
return {
"phase": self._phase,
"message": self._message,
"source_mode": self._source_mode,
"foxglove_ws_url": self._foxglove_ws_url,
"foxglove_viewer_url": self._foxglove_viewer_url,
"metrics": self._metrics.snapshot(),
}
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise ValueError("replay path must be an existing backend-local file")
if not math.isfinite(speed) or speed < 0:
raise ValueError("replay speed must be finite and non-negative")
# Validate the reviewed shape before changing runtime state.
iterator = iter_replay_messages(resolved)
try:
next(iterator)
except StopIteration as exc:
raise ValueError("replay capture contains no messages") from exc
finally:
iterator.close()
self._start(
source_mode="replay",
phase="replay",
message=f"Starting replay: {resolved.name}",
target=lambda: self._run_replay(resolved, speed=speed, loop=loop),
)
def start_live(
self,
host: str,
out_dir: Path,
*,
duration_seconds: float = 3600.0,
) -> None:
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
raise ValueError("live duration must be finite and greater than zero")
self._start(
source_mode="live",
phase="starting_live",
message="Starting read-only MQTT capture and Foxglove bridge.",
target=lambda: self._run_live(
host,
out_dir.expanduser().resolve(),
duration_seconds=duration_seconds,
),
)
def stop(self, *, wait_seconds: float = 5.0) -> None:
notify_only = False
with self._lock:
thread = self._thread
if thread is None or not thread.is_alive():
self._phase = "idle"
self._source_mode = "idle"
self._message = "No active visualization session."
notify_only = True
else:
self._phase = "stopping"
self._message = "Stopping source after preserving queued evidence."
self._stop_event.set()
self._notify()
if notify_only:
return
assert thread is not None
thread.join(timeout=wait_seconds)
def _start(
self,
*,
source_mode: SourceMode,
phase: RuntimePhase,
message: str,
target: Callable[[], None],
) -> None:
with self._lock:
if self._thread is not None and self._thread.is_alive():
raise RuntimeError("a visualization session is already active")
self._stop_event = threading.Event()
self._metrics = BridgeMetrics()
self._phase = phase
self._source_mode = source_mode
self._message = message
self._foxglove_ws_url = None
self._foxglove_viewer_url = None
self._thread = threading.Thread(
target=target,
name=f"k1-{source_mode}-session",
daemon=True,
)
self._thread.start()
self._notify()
def _run_replay(self, path: Path, *, speed: float, loop: bool) -> None:
def produce(put: Callable[[StreamMessage], None]) -> str:
while not self._stop_event.is_set():
first_source_ns: int | None = None
replay_started_ns = time.monotonic_ns()
count = 0
for message in iter_replay_messages(path):
if self._stop_event.is_set():
return "Replay stopped by operator."
source_ns = (
message.received_monotonic_ns
if message.received_monotonic_ns is not None
else message.received_at_epoch_ns
)
if first_source_ns is None:
first_source_ns = source_ns
if speed > 0:
target_ns = replay_started_ns + int((source_ns - first_source_ns) / speed)
remaining = (target_ns - time.monotonic_ns()) / 1_000_000_000
if remaining > 0 and self._stop_event.wait(remaining):
return "Replay stopped by operator."
put(message)
count += 1
if not loop:
return f"Replay completed: {count} MQTT messages."
return "Replay stopped by operator."
self._run_pipeline(produce, running_phase="replay")
def _run_live(self, host: str, out_dir: Path, *, duration_seconds: float) -> None:
_write_live_session_preamble(out_dir, host, duration_seconds)
def produce(put: Callable[[StreamMessage], None]) -> str:
def on_message(message: CapturedMqttMessage) -> None:
put(
StreamMessage(
sequence=message.sequence,
topic=message.topic,
payload=message.payload,
received_at_epoch_ns=message.received_at_epoch_ns,
received_monotonic_ns=message.received_monotonic_ns,
source="live_mqtt",
)
)
summary = capture_mqtt(
host,
out_dir / "captures" / "mqtt_live",
duration_seconds=duration_seconds,
on_ready=lambda: self._set_running(
"live", "MQTT subscribed; waiting for K1 scan frames."
),
on_message_recorded=on_message,
should_stop=self._stop_event.is_set,
)
return (
f"Live capture stopped: {summary['stop_reason']}; "
f"{summary['message_count']} messages preserved."
)
try:
self._run_pipeline(produce, running_phase="live")
finally:
_finalize_live_session(out_dir, self.snapshot())
def _run_pipeline(
self,
producer: Callable[[Callable[[StreamMessage], None]], str],
*,
running_phase: RuntimePhase,
) -> None:
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=32)
source_done = threading.Event()
publisher_ready = threading.Event()
publisher_error: list[BaseException] = []
def enqueue(message: StreamMessage) -> None:
try:
messages.put_nowait(message)
return
except queue.Full:
pass
try:
messages.get_nowait()
messages.task_done()
except queue.Empty:
pass
self._metrics.preview_dropped()
try:
messages.put_nowait(message)
except queue.Full:
self._metrics.preview_dropped()
def publish() -> None:
bridge: FoxgloveBridge | None = None
try:
bridge = FoxgloveBridge(metrics=self._metrics)
with self._lock:
self._foxglove_ws_url = bridge.websocket_url
self._foxglove_viewer_url = bridge.viewer_url
if self._phase != "stopping":
self._phase = running_phase
self._message = "Foxglove bridge ready; source is active."
publisher_ready.set()
self._notify()
while not source_done.is_set() or not messages.empty():
if self._stop_event.is_set() and source_done.is_set() and messages.empty():
break
try:
message = messages.get(timeout=0.1)
except queue.Empty:
continue
try:
bridge.process(message)
finally:
messages.task_done()
if self._metrics.snapshot()["messages_received"] % 10 == 0:
self._notify()
except BaseException as exc:
publisher_error.append(exc)
publisher_ready.set()
self._stop_event.set()
finally:
if bridge is not None:
bridge.close()
publisher = threading.Thread(target=publish, name="k1-foxglove-publisher", daemon=True)
publisher.start()
if not publisher_ready.wait(timeout=15.0):
self._finish_error("Foxglove bridge did not start within 15 seconds.")
source_done.set()
self._stop_event.set()
return
if publisher_error:
self._finish_error(
f"Foxglove bridge failed: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
)
source_done.set()
return
final_message = "Session stopped."
try:
final_message = producer(enqueue)
except CaptureError as exc:
self._finish_error(f"Live MQTT capture failed: {exc}")
except (OSError, RuntimeError, ValueError) as exc:
self._finish_error(f"Source failed: {type(exc).__name__}: {exc}")
finally:
source_done.set()
publisher.join(timeout=15.0)
if publisher.is_alive():
self._stop_event.set()
self._finish_error("Publisher did not drain within 15 seconds.")
elif publisher_error:
self._finish_error(
f"Publisher failed: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
)
elif self.snapshot()["phase"] != "error":
self._finish_idle(final_message)
def _set_running(self, phase: RuntimePhase, message: str) -> None:
with self._lock:
if self._phase != "stopping":
self._phase = phase
self._message = message
self._notify()
def _finish_idle(self, message: str) -> None:
with self._lock:
self._phase = "idle"
self._source_mode = "idle"
self._message = message
self._foxglove_ws_url = None
self._foxglove_viewer_url = None
self._notify()
def _finish_error(self, message: str) -> None:
with self._lock:
self._phase = "error"
self._message = message
self._foxglove_ws_url = None
self._foxglove_viewer_url = None
self._notify()
def _notify(self) -> None:
callback = self._on_state_change
if callback is not None:
callback()
def new_live_session_dir(repository_root: Path) -> Path:
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
base = repository_root / "sessions" / f"{stamp}_viewer_live"
candidate = base
suffix = 1
while candidate.exists():
suffix += 1
candidate = base.with_name(f"{base.name}_{suffix:02d}")
return candidate
def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: float) -> None:
out_dir.mkdir(parents=True, exist_ok=False)
started_at_utc = utc_now_iso()
write_json_atomic(
out_dir / "manifest.redacted.json",
{
"schema_version": 1,
"started_at_utc": started_at_utc,
"started_monotonic_ns": time.monotonic_ns(),
"operation": "k1_live_mqtt_to_foxglove",
"target": "owner-controlled K1 at redacted RFC1918 address",
"requested_duration_seconds": duration_seconds,
"raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt",
"credential_storage": "none",
},
)
notes = (
"# K1 live visualization session\n\n"
f"Started UTC: {started_at_utc}\n\n"
"The local control API started a read-only MQTT subscription and Foxglove "
"preview. Raw MQTT evidence is written before preview decoding. The target "
f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n"
)
notes_path = out_dir / "operator-notes.md"
notes_path.write_text(notes, encoding="utf-8")
notes_path.chmod(0o600)
def _finalize_live_session(out_dir: Path, snapshot: RuntimeSnapshot) -> None:
manifest_path = out_dir / "manifest.redacted.json"
try:
import json
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
payload["completed_at_utc"] = utc_now_iso()
payload["completed_monotonic_ns"] = time.monotonic_ns()
payload["final_phase"] = snapshot["phase"]
payload["aggregate_metrics"] = snapshot["metrics"]
write_json_atomic(manifest_path, payload)
except (OSError, ValueError):
# The MQTT capture summary remains authoritative if final annotation fails.
return
+1
View File
@@ -0,0 +1 @@
"""Local-only control API for the K1 live console."""
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
import asyncio
import threading
from collections.abc import Mapping
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from bleak.exc import BleakError
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from k1link import __version__
from k1link.artifacts import write_json_atomic
from k1link.ble.scanner import scan
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
from k1link.mqtt import validate_private_ipv4
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
class BleScanRequest(BaseModel):
duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0)
class ConnectRequest(BaseModel):
device_id: str = Field(min_length=1, max_length=128)
ssid: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=256)
class LiveRequest(BaseModel):
host: str | None = Field(default=None, max_length=15)
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
class ReplayRequest(BaseModel):
path: str = Field(min_length=1, max_length=4096)
speed: float = Field(default=1.0, ge=0.0, le=100.0)
loop: bool = False
class ConsoleService:
def __init__(self, repository_root: Path) -> None:
self.repository_root = repository_root.resolve()
self._lock = threading.Lock()
self._devices: list[dict[str, Any]] = []
self._selected_device_id: str | None = None
self._k1_ip: str | None = None
self._operation_phase: str | None = None
self._operation_message: str | None = None
self.runtime = VisualizationRuntime()
def state(self) -> dict[str, Any]:
runtime = self.runtime.snapshot()
metrics = runtime["metrics"]
with self._lock:
operation_phase = self._operation_phase
operation_message = self._operation_message
devices = list(self._devices)
selected_device_id = self._selected_device_id
k1_ip = self._k1_ip
runtime_active = runtime["source_mode"] != "idle" or runtime["phase"] in {
"starting_live",
"stopping",
"error",
}
if operation_phase is not None:
phase = operation_phase
message = operation_message
elif runtime_active:
phase = runtime["phase"]
message = runtime["message"]
elif k1_ip is not None:
phase = "connected"
message = runtime["message"]
elif selected_device_id is not None:
phase = "device_selected"
message = "K1 selected; Wi-Fi credentials have not been written."
else:
phase = "idle"
message = runtime["message"]
return {
"phase": phase,
"message": message,
"devices": devices,
"selected_device_id": selected_device_id,
"k1_ip": k1_ip,
"foxglove_ws_url": runtime["foxglove_ws_url"],
"foxglove_viewer_url": runtime["foxglove_viewer_url"],
"source_mode": runtime["source_mode"],
"metrics": {
"pipeline_ms": metrics["mqtt_to_publish_ms"],
"end_to_end_ms": metrics["mqtt_to_publish_ms"],
"decode_ms": metrics["decode_publish_ms"],
"frame_rate": metrics["pcl_fps"],
"frame_rate_hz": metrics["pcl_fps"],
"point_count": metrics["last_point_count"],
"dropped_preview_frames": metrics["preview_dropped"],
**metrics,
},
}
async def scan_ble(self, duration_seconds: float) -> dict[str, Any]:
self._set_operation("scanning", "Scanning for nearby K1 BLE advertisements.")
try:
result = await scan(duration_seconds)
devices = [
{
"device_id": item["macos_uuid"],
"name": item["local_name"] or item["name"],
"rssi": item["rssi"],
"address": None,
"connectable": True,
}
for item in result["devices"]
if item["k1_name_candidate"]
]
with self._lock:
self._devices = devices
if len(devices) == 1:
self._selected_device_id = str(devices[0]["device_id"])
self._operation_message = f"BLE scan complete: {len(devices)} K1 candidate(s)."
finally:
with self._lock:
self._operation_phase = None
return self.state()
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids:
raise ValueError("device_id must come from the latest K1 BLE scan")
self._set_operation(
"provisioning",
"Sending one explicitly requested Wi-Fi provisioning write.",
)
session_dir = _new_operation_session_dir(
self.repository_root,
"viewer_wifi_provisioning",
)
session_dir.mkdir(parents=True, exist_ok=False)
password = request.password
try:
result = await provision_wifi_once(
request.device_id,
request.ssid,
password,
timeout_seconds=45.0,
write_mode="auto",
)
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
ipv4 = _provisioned_ipv4(result)
write_json_atomic(
session_dir / "manifest.redacted.json",
{
"schema_version": 1,
"started_at_utc": result["started_at_utc"],
"completed_at_utc": result["completed_at_utc"],
"operation": "single_reviewed_wifi_provisioning_write",
"profile_id": result["profile_id"],
"outcome": result["outcome"],
"k1_lan_address_observed": ipv4 is not None,
"credentials_persisted_by_connector": False,
},
)
if ipv4 is None:
raise RuntimeError(
"K1 did not report a non-AP LAN address; no automatic retry was made"
)
with self._lock:
self._selected_device_id = request.device_id
self._k1_ip = ipv4
self._operation_message = "K1 joined the LAN and reported its private address."
finally:
password = ""
with self._lock:
self._operation_phase = None
return self.state()
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
target = host or self.state()["k1_ip"]
if not isinstance(target, str) or not target:
raise ValueError("live host is required until BLE provisioning reports a K1 address")
target = validate_private_ipv4(target)
out_dir = new_live_session_dir(self.repository_root)
self.runtime.start_live(target, out_dir, duration_seconds=duration_seconds)
return self.state()
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
replay_path = Path(path).expanduser().resolve()
if not replay_path.is_relative_to(self.repository_root):
raise ValueError("replay path must remain inside this repository")
self.runtime.start_replay(replay_path, speed=speed, loop=loop)
return self.state()
def stop(self) -> dict[str, Any]:
self.runtime.stop()
return self.state()
def _set_operation(self, phase: str, message: str) -> None:
with self._lock:
self._operation_phase = phase
self._operation_message = message
service = ConsoleService(REPOSITORY_ROOT)
app = FastAPI(
title="NODE.DC K1 Live Console API",
version=__version__,
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
)
@app.get("/api/health")
def health() -> dict[str, Any]:
return {
"ok": True,
"status": "ok",
"service": "k1-live-console",
"version": __version__,
}
@app.get("/api/state")
def get_state() -> dict[str, Any]:
return service.state()
@app.post("/api/ble/scan")
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
try:
return await service.scan_ble(request.duration_seconds)
except (BleakError, OSError, RuntimeError, ValueError) as exc:
raise HTTPException(status_code=502, detail=f"BLE scan failed: {exc}") from exc
@app.post("/api/connect")
async def connect(request: ConnectRequest) -> dict[str, Any]:
try:
return await service.connect(request)
except (BleakError, OSError, TimeoutError, RuntimeError, ValueError) as exc:
raise HTTPException(status_code=502, detail=f"Wi-Fi provisioning failed: {exc}") from exc
@app.post("/api/session/live")
def start_live(request: LiveRequest) -> dict[str, Any]:
try:
return service.start_live(request.host, request.duration_seconds)
except (OSError, RuntimeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/session/replay")
def start_replay(request: ReplayRequest) -> dict[str, Any]:
try:
return service.start_replay(request.path, request.speed, request.loop)
except (OSError, RuntimeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/session/stop")
def stop_session() -> dict[str, Any]:
return service.stop()
@app.websocket("/api/events")
async def events(websocket: WebSocket) -> None:
await websocket.accept()
try:
while True:
await websocket.send_json({"state": service.state()})
await asyncio.sleep(0.5)
except WebSocketDisconnect:
return
frontend_dist = REPOSITORY_ROOT / "apps" / "k1-viewer" / "dist"
if frontend_dist.is_dir():
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend")
def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path:
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
base = repository_root / "sessions" / f"{stamp}_{suffix}"
candidate = base
serial = 1
while candidate.exists():
serial += 1
candidate = base.with_name(f"{base.name}_{serial:02d}")
return candidate
def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
observations = result.get("observations")
if not isinstance(observations, list):
return None
for observation in reversed(observations):
if not isinstance(observation, dict):
continue
status = observation.get("status")
if not isinstance(status, dict):
continue
address = status.get("ipv4")
if isinstance(address, str) and address != AP_FALLBACK_IPV4:
try:
return validate_private_ipv4(address)
except ValueError:
continue
return None