feat(plugins): isolate device integrations
This commit is contained in:
@@ -1,15 +1,11 @@
|
||||
"""Live/replay visualization bridge for verified K1 MQTT streams."""
|
||||
"""Mission Core viewer consumers and recorded-viewer contracts."""
|
||||
|
||||
from k1link.viewer.messages import StreamMessage
|
||||
from k1link.viewer.replay import (
|
||||
ReplayFormatError,
|
||||
detect_replay_format,
|
||||
iter_replay_messages,
|
||||
)
|
||||
from .recorded import RecordedBlueprintError, recorded_blueprint_rrd
|
||||
from .rerun_bridge import RerunBridge, RerunSceneSettings
|
||||
|
||||
__all__ = [
|
||||
"ReplayFormatError",
|
||||
"StreamMessage",
|
||||
"detect_replay_format",
|
||||
"iter_replay_messages",
|
||||
"RecordedBlueprintError",
|
||||
"RerunBridge",
|
||||
"RerunSceneSettings",
|
||||
"recorded_blueprint_rrd",
|
||||
]
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
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
|
||||
from k1link.viewer.metrics import BridgeMetrics
|
||||
|
||||
POINT_STRUCT = struct.Struct("<fffB3x")
|
||||
POINT_STRIDE = POINT_STRUCT.size
|
||||
FRAME_ID = "map"
|
||||
MAX_TRAJECTORY_POSES = 20_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PackedPointCloud:
|
||||
data: bytes
|
||||
point_count: int
|
||||
|
||||
|
||||
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)
|
||||
@@ -1,15 +0,0 @@
|
||||
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"
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Vendor-neutral Rerun blueprint for prepared observation recordings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from uuid import UUID
|
||||
|
||||
import rerun as rr
|
||||
from rerun import blueprint as rrb
|
||||
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings, _parse_hex_color
|
||||
|
||||
APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
|
||||
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
||||
|
||||
|
||||
class RecordedBlueprintError(RuntimeError):
|
||||
"""A viewer blueprint update could not be serialized safely."""
|
||||
|
||||
|
||||
def recorded_blueprint(
|
||||
settings: RerunSceneSettings,
|
||||
*,
|
||||
include_initial_playback_state: bool = True,
|
||||
) -> rrb.Blueprint:
|
||||
accumulation = max(0.0, settings.accumulation_seconds)
|
||||
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||
if accumulation > 0:
|
||||
time_ranges = [
|
||||
rr.VisibleTimeRange(
|
||||
SESSION_TIMELINE,
|
||||
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
|
||||
end=rr.TimeRangeBoundary.cursor_relative(),
|
||||
)
|
||||
]
|
||||
point_visualizer = rr.Points3D.from_fields(
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
colors=(
|
||||
[_parse_hex_color(settings.custom_color)]
|
||||
if settings.palette == "custom"
|
||||
else None
|
||||
),
|
||||
).visualizer()
|
||||
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
|
||||
spatial_view = rrb.Spatial3DView(
|
||||
origin="/world",
|
||||
name="Пространственная сцена",
|
||||
background=[7, 8, 10, 255],
|
||||
line_grid=rrb.LineGrid3D(
|
||||
visible=settings.show_grid,
|
||||
color=[86, 91, 99, 110],
|
||||
stroke_width=0.75,
|
||||
),
|
||||
overrides={
|
||||
"/world/points": [
|
||||
rrb.EntityBehavior(visible=settings.show_points),
|
||||
point_visualizer,
|
||||
],
|
||||
"/world/trajectory": rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
},
|
||||
time_ranges=time_ranges,
|
||||
)
|
||||
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
||||
root_container = rrb.Tabs(spatial_view)
|
||||
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
||||
|
||||
if include_initial_playback_state:
|
||||
return rrb.Blueprint(
|
||||
root_container,
|
||||
rrb.TimePanel(
|
||||
timeline=SESSION_TIMELINE,
|
||||
play_state="paused",
|
||||
state="hidden",
|
||||
),
|
||||
auto_layout=False,
|
||||
auto_views=False,
|
||||
collapse_panels=True,
|
||||
)
|
||||
return rrb.Blueprint(
|
||||
root_container,
|
||||
auto_layout=False,
|
||||
auto_views=False,
|
||||
collapse_panels=False,
|
||||
)
|
||||
|
||||
|
||||
def recorded_blueprint_rrd(
|
||||
settings: RerunSceneSettings,
|
||||
*,
|
||||
application_id: str = APPLICATION_ID,
|
||||
recording_id: str,
|
||||
) -> bytes:
|
||||
"""Serialize a bounded active blueprint update without recorded data."""
|
||||
|
||||
recording = rr.RecordingStream(
|
||||
application_id,
|
||||
recording_id=recording_id,
|
||||
send_properties=False,
|
||||
)
|
||||
stream = rr.binary_stream(recording)
|
||||
try:
|
||||
recording.send_blueprint(
|
||||
recorded_blueprint(
|
||||
settings,
|
||||
include_initial_playback_state=False,
|
||||
),
|
||||
make_active=True,
|
||||
make_default=False,
|
||||
)
|
||||
payload = stream.read(flush=True, flush_timeout_sec=5.0)
|
||||
except Exception as exc:
|
||||
raise RecordedBlueprintError("failed to serialize recorded blueprint") from exc
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
recording.disconnect()
|
||||
if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
|
||||
raise RecordedBlueprintError("serialized recorded blueprint is invalid")
|
||||
return payload
|
||||
@@ -1,170 +0,0 @@
|
||||
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:
|
||||
timing = _read_native_timing(
|
||||
metadata_stream,
|
||||
expected_sequence=frame.sequence,
|
||||
fallback_epoch_ns=epoch_ns,
|
||||
)
|
||||
if timing is None:
|
||||
# A crash may leave one raw frame ahead of the last fully
|
||||
# committed metadata line. Only the aligned prefix has a
|
||||
# trustworthy source timeline and is safe to replay.
|
||||
return
|
||||
epoch_ns, monotonic_ns = timing
|
||||
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] | None:
|
||||
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
|
||||
if not line:
|
||||
return 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:
|
||||
if not line.endswith(("\n", "\r")):
|
||||
# A non-newline final tail is the only tolerated corruption: the
|
||||
# writer may have crashed between raw and metadata group commits.
|
||||
return None
|
||||
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",
|
||||
)
|
||||
@@ -1,595 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
from rerun import blueprint as rrb
|
||||
|
||||
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
|
||||
from k1link.protocol.normalizer import normalize_k1_message
|
||||
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
|
||||
from k1link.viewer.rerun_bridge import (
|
||||
MAX_TRAJECTORY_POSES,
|
||||
TRAJECTORY_APPEND_INTERVAL_NS,
|
||||
TRAJECTORY_FORCE_APPEND_NS,
|
||||
TRAJECTORY_MIN_DISTANCE_METERS,
|
||||
TRAJECTORY_PUBLISH_INTERVAL_NS,
|
||||
RerunSceneSettings,
|
||||
_parse_hex_color,
|
||||
_point_colors,
|
||||
)
|
||||
|
||||
APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
CAPTURE_TIMELINE = "capture_time"
|
||||
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
|
||||
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
|
||||
# blueprint update makes the update overwrite the existing scene instead of
|
||||
# creating a fresh view/container (which would also reset the operator's eye
|
||||
# position and layout).
|
||||
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
||||
|
||||
|
||||
class RrdExportSummary(TypedDict):
|
||||
schema_version: int
|
||||
input_path: str
|
||||
output_path: str
|
||||
recording_id: str
|
||||
timeline: str
|
||||
capture_timeline: str
|
||||
source_messages: int
|
||||
decoded_messages: int
|
||||
point_frames: int
|
||||
pose_frames: int
|
||||
ignored_messages: int
|
||||
points: int
|
||||
trajectory_poses: int
|
||||
trajectory_updates: int
|
||||
session_origin_monotonic_ns: int
|
||||
timeline_start_ns: int
|
||||
timeline_end_ns: int
|
||||
timeline_span_ns: int
|
||||
first_decoded_time_ns: int
|
||||
last_decoded_time_ns: int
|
||||
source_sha256: str
|
||||
rrd_sha256: str
|
||||
rrd_bytes: int
|
||||
|
||||
|
||||
class RrdExportError(RuntimeError):
|
||||
"""A raw capture could not be converted into a complete durable RRD."""
|
||||
|
||||
|
||||
class RrdExportCancelled(RrdExportError):
|
||||
"""A background RRD export was cooperatively cancelled."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ExportCounters:
|
||||
source_messages: int = 0
|
||||
point_frames: int = 0
|
||||
pose_frames: int = 0
|
||||
ignored_messages: int = 0
|
||||
points: int = 0
|
||||
first_decoded_time_ns: int | None = None
|
||||
last_decoded_time_ns: int | None = None
|
||||
|
||||
@property
|
||||
def decoded_messages(self) -> int:
|
||||
return self.point_frames + self.pose_frames
|
||||
|
||||
def observe_decoded(self, session_time_ns: int) -> None:
|
||||
if self.first_decoded_time_ns is None:
|
||||
self.first_decoded_time_ns = session_time_ns
|
||||
self.last_decoded_time_ns = session_time_ns
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _TrajectoryBuffer:
|
||||
positions: list[tuple[float, float, float]]
|
||||
last_append_time_ns: int | None = None
|
||||
last_publish_time_ns: int | None = None
|
||||
updates: int = 0
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> _TrajectoryBuffer:
|
||||
return cls(positions=[])
|
||||
|
||||
def process(
|
||||
self,
|
||||
recording: rr.RecordingStream,
|
||||
position: tuple[float, float, float],
|
||||
session_time_ns: int,
|
||||
) -> None:
|
||||
if not self._append(position, session_time_ns):
|
||||
return
|
||||
if (
|
||||
self.last_publish_time_ns is not None
|
||||
and session_time_ns - self.last_publish_time_ns < TRAJECTORY_PUBLISH_INTERVAL_NS
|
||||
):
|
||||
return
|
||||
self.last_publish_time_ns = session_time_ns
|
||||
self.updates += 1
|
||||
recording.log(
|
||||
"/world/trajectory",
|
||||
rr.LineStrips3D(
|
||||
[list(self.positions)],
|
||||
colors=[247, 248, 244, 255],
|
||||
radii=rr.Radius.ui_points(2.0),
|
||||
),
|
||||
)
|
||||
|
||||
def _append(self, position: tuple[float, float, float], session_time_ns: int) -> bool:
|
||||
if not self.positions:
|
||||
self.positions.append(position)
|
||||
self.last_append_time_ns = session_time_ns
|
||||
return True
|
||||
|
||||
assert self.last_append_time_ns is not None
|
||||
elapsed_ns = session_time_ns - self.last_append_time_ns
|
||||
if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
|
||||
return False
|
||||
if (
|
||||
math.dist(self.positions[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
|
||||
and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
|
||||
):
|
||||
return False
|
||||
|
||||
self.positions.append(position)
|
||||
self.last_append_time_ns = session_time_ns
|
||||
if len(self.positions) > MAX_TRAJECTORY_POSES:
|
||||
last = self.positions[-1]
|
||||
self.positions = self.positions[::2]
|
||||
if self.positions[-1] != last:
|
||||
self.positions.append(last)
|
||||
return True
|
||||
|
||||
|
||||
def export_k1mqtt_to_rrd(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: Callable[[], None] | None = None,
|
||||
) -> RrdExportSummary:
|
||||
"""Losslessly project every decodable K1 data-plane frame into one RRD.
|
||||
|
||||
The raw capture remains the source of record. The derived RRD uses a
|
||||
recording-local duration timeline whose zero is the first raw message's
|
||||
receive-monotonic timestamp. It never traverses the bounded live-preview
|
||||
queue, so export throughput cannot drop point or pose frames.
|
||||
|
||||
The destination is replaced only after the temporary RRD has been closed,
|
||||
flushed and fsynced. Any decode, timing, sink or rename failure therefore
|
||||
leaves an existing destination artifact untouched.
|
||||
"""
|
||||
|
||||
_raise_if_cancelled(cancel_event)
|
||||
source = input_path.expanduser().resolve()
|
||||
destination = output_path.expanduser().resolve()
|
||||
_validate_paths(source, destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recording_id = str(uuid4())
|
||||
temporary = destination.with_name(f".{destination.name}.{recording_id}.tmp")
|
||||
source_sha256 = _sha256_file(
|
||||
source,
|
||||
cancel_event=cancel_event,
|
||||
activity_callback=activity_callback,
|
||||
)
|
||||
settings = RerunSceneSettings()
|
||||
blueprint = _recorded_blueprint(settings)
|
||||
recording: rr.RecordingStream | None = None
|
||||
recording_closed = False
|
||||
published = False
|
||||
|
||||
counters = _ExportCounters()
|
||||
trajectory = _TrajectoryBuffer.empty()
|
||||
session_origin_ns: int | None = None
|
||||
previous_monotonic_ns: int | None = None
|
||||
|
||||
try:
|
||||
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
|
||||
recording.set_sinks(
|
||||
rr.FileSink(temporary, write_footer=True),
|
||||
default_blueprint=blueprint,
|
||||
)
|
||||
_log_static_scene(recording)
|
||||
_log_session_origin(recording)
|
||||
|
||||
for message in iter_replay_messages(source):
|
||||
_raise_if_cancelled(cancel_event)
|
||||
_notify_activity(activity_callback)
|
||||
counters.source_messages += 1
|
||||
monotonic_ns = message.received_monotonic_ns
|
||||
if monotonic_ns is None:
|
||||
raise RrdExportError(
|
||||
"native capture metadata must provide received_monotonic_ns "
|
||||
f"for message {message.sequence}"
|
||||
)
|
||||
if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
|
||||
raise RrdExportError(
|
||||
f"native capture monotonic time decreases at message {message.sequence}"
|
||||
)
|
||||
if session_origin_ns is None:
|
||||
session_origin_ns = monotonic_ns
|
||||
session_time_ns = monotonic_ns - session_origin_ns
|
||||
if session_time_ns > JS_MAX_SAFE_INTEGER:
|
||||
raise RrdExportError(
|
||||
"session duration exceeds the exact JavaScript nanosecond range"
|
||||
)
|
||||
previous_monotonic_ns = monotonic_ns
|
||||
|
||||
try:
|
||||
decoded = normalize_k1_message(
|
||||
message,
|
||||
processing_started_monotonic_ns=monotonic_ns,
|
||||
)
|
||||
except NormalizationError as exc:
|
||||
raise RrdExportError(
|
||||
f"known K1 frame {message.sequence} failed normalization"
|
||||
) from exc
|
||||
if decoded is None:
|
||||
counters.ignored_messages += 1
|
||||
continue
|
||||
|
||||
_set_frame_time(
|
||||
recording,
|
||||
decoded.context.sequence,
|
||||
session_time_ns,
|
||||
decoded.context.captured_at_epoch_ns,
|
||||
)
|
||||
counters.observe_decoded(session_time_ns)
|
||||
if isinstance(decoded, DecodedPointCloudView):
|
||||
_log_points(recording, decoded, settings)
|
||||
counters.point_frames += 1
|
||||
counters.points += decoded.point_count
|
||||
elif isinstance(decoded, DecodedPoseView):
|
||||
position = (
|
||||
float(decoded.position_xyz[0]),
|
||||
float(decoded.position_xyz[1]),
|
||||
float(decoded.position_xyz[2]),
|
||||
)
|
||||
_log_pose(recording, decoded, position)
|
||||
trajectory.process(recording, position, session_time_ns)
|
||||
counters.pose_frames += 1
|
||||
else:
|
||||
counters.ignored_messages += 1
|
||||
|
||||
if session_origin_ns is None:
|
||||
raise RrdExportError("native capture contains no messages")
|
||||
if counters.decoded_messages == 0:
|
||||
raise RrdExportError("native capture contains no decodable point or pose frames")
|
||||
assert counters.first_decoded_time_ns is not None
|
||||
assert counters.last_decoded_time_ns is not None
|
||||
|
||||
_raise_if_cancelled(cancel_event)
|
||||
recording.flush(timeout_sec=30.0)
|
||||
recording.disconnect()
|
||||
recording_closed = True
|
||||
_raise_if_cancelled(cancel_event)
|
||||
_fsync_file(temporary)
|
||||
rrd_bytes = temporary.stat().st_size
|
||||
if rrd_bytes <= 0:
|
||||
raise RrdExportError("Rerun produced an empty recording")
|
||||
rrd_sha256 = _sha256_file(
|
||||
temporary,
|
||||
cancel_event=cancel_event,
|
||||
activity_callback=activity_callback,
|
||||
)
|
||||
|
||||
summary = RrdExportSummary(
|
||||
schema_version=1,
|
||||
input_path=str(source),
|
||||
output_path=str(destination),
|
||||
recording_id=recording_id,
|
||||
timeline=SESSION_TIMELINE,
|
||||
capture_timeline=CAPTURE_TIMELINE,
|
||||
source_messages=counters.source_messages,
|
||||
decoded_messages=counters.decoded_messages,
|
||||
point_frames=counters.point_frames,
|
||||
pose_frames=counters.pose_frames,
|
||||
ignored_messages=counters.ignored_messages,
|
||||
points=counters.points,
|
||||
trajectory_poses=len(trajectory.positions),
|
||||
trajectory_updates=trajectory.updates,
|
||||
session_origin_monotonic_ns=session_origin_ns,
|
||||
timeline_start_ns=0,
|
||||
# Playback completeness is defined by data actually written to
|
||||
# the RRD. K1 status/heartbeat packets may continue long after the
|
||||
# final point or pose frame; advertising that raw tail as the RRD
|
||||
# end makes a strict browser buffering gate wait forever.
|
||||
timeline_end_ns=counters.last_decoded_time_ns,
|
||||
timeline_span_ns=counters.last_decoded_time_ns,
|
||||
first_decoded_time_ns=counters.first_decoded_time_ns,
|
||||
last_decoded_time_ns=counters.last_decoded_time_ns,
|
||||
source_sha256=source_sha256,
|
||||
rrd_sha256=rrd_sha256,
|
||||
rrd_bytes=rrd_bytes,
|
||||
)
|
||||
_raise_if_cancelled(cancel_event)
|
||||
os.replace(temporary, destination)
|
||||
_fsync_directory(destination.parent)
|
||||
published = True
|
||||
return summary
|
||||
except (ReplayFormatError, OSError) as exc:
|
||||
raise RrdExportError(f"RRD export failed: {exc}") from exc
|
||||
finally:
|
||||
if recording is not None and not recording_closed:
|
||||
with suppress(BaseException):
|
||||
recording.disconnect()
|
||||
if not published:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _validate_paths(source: Path, destination: Path) -> None:
|
||||
if not source.is_file():
|
||||
raise RrdExportError(f"raw capture does not exist: {source}")
|
||||
if source.suffix.casefold() != ".k1mqtt":
|
||||
raise RrdExportError("RRD export accepts only native .k1mqtt captures")
|
||||
if destination.suffix.casefold() != ".rrd":
|
||||
raise RrdExportError("RRD destination must use the .rrd suffix")
|
||||
if source == destination:
|
||||
raise RrdExportError("raw capture and RRD destination must be different files")
|
||||
try:
|
||||
replay_format = detect_replay_format(source)
|
||||
except ReplayFormatError as exc:
|
||||
raise RrdExportError(str(exc)) from exc
|
||||
if replay_format != "k1mqtt":
|
||||
raise RrdExportError("RRD export accepts only native K1MQTT captures")
|
||||
|
||||
|
||||
def _recorded_blueprint(
|
||||
settings: RerunSceneSettings,
|
||||
*,
|
||||
include_initial_playback_state: bool = True,
|
||||
) -> rrb.Blueprint:
|
||||
accumulation = max(0.0, settings.accumulation_seconds)
|
||||
# An omitted range means Rerun's native latest-at query: the most recent
|
||||
# LiDAR frame at the cursor. A zero-width range is *not* equivalent; it
|
||||
# only matches rows stamped at the cursor's exact nanosecond and therefore
|
||||
# makes an ordinary recorded scan appear empty.
|
||||
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||
if accumulation > 0:
|
||||
time_ranges = [
|
||||
rr.VisibleTimeRange(
|
||||
SESSION_TIMELINE,
|
||||
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
|
||||
end=rr.TimeRangeBoundary.cursor_relative(),
|
||||
)
|
||||
]
|
||||
point_visualizer = rr.Points3D.from_fields(
|
||||
# A negative Radius value is Rerun's serialized representation
|
||||
# for UI points. Blueprint overrides broadcast this singleton
|
||||
# value across every recorded point without rewriting the store.
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
# Recorded height/intensity/distance/RGB palettes are baked into
|
||||
# each Points3D row. A uniform custom color is the one color mode
|
||||
# that can be replaced safely by a singleton blueprint override.
|
||||
colors=(
|
||||
[_parse_hex_color(settings.custom_color)]
|
||||
if settings.palette == "custom"
|
||||
else None
|
||||
),
|
||||
).visualizer()
|
||||
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
|
||||
spatial_view = rrb.Spatial3DView(
|
||||
origin="/world",
|
||||
name="Пространственная сцена",
|
||||
background=[7, 8, 10, 255],
|
||||
line_grid=rrb.LineGrid3D(
|
||||
visible=settings.show_grid,
|
||||
color=[86, 91, 99, 110],
|
||||
stroke_width=0.75,
|
||||
),
|
||||
overrides={
|
||||
# EntityBehavior is evaluated from the blueprint store and can
|
||||
# therefore hide/reveal already-recorded entities without
|
||||
# rewriting the data RRD. Keep the point visualizer alongside it
|
||||
# so size/color overrides remain active for the same entity.
|
||||
"/world/points": [
|
||||
rrb.EntityBehavior(visible=settings.show_points),
|
||||
point_visualizer,
|
||||
],
|
||||
"/world/trajectory": rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
},
|
||||
# A positive window accumulates historical frames. With no
|
||||
# window, latest-at deliberately keeps one current LiDAR frame.
|
||||
time_ranges=time_ranges,
|
||||
)
|
||||
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
||||
root_container = rrb.Tabs(spatial_view)
|
||||
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
||||
|
||||
if include_initial_playback_state:
|
||||
# This state is appropriate only while opening a newly exported RRD.
|
||||
# Settings-only blueprint messages must not pause an already playing
|
||||
# recording or mutate the host-owned panel state.
|
||||
return rrb.Blueprint(
|
||||
root_container,
|
||||
rrb.TimePanel(
|
||||
timeline=SESSION_TIMELINE,
|
||||
play_state="paused",
|
||||
state="hidden",
|
||||
),
|
||||
auto_layout=False,
|
||||
auto_views=False,
|
||||
collapse_panels=True,
|
||||
)
|
||||
|
||||
return rrb.Blueprint(
|
||||
root_container,
|
||||
auto_layout=False,
|
||||
auto_views=False,
|
||||
collapse_panels=False,
|
||||
)
|
||||
|
||||
|
||||
def recorded_blueprint_rrd(
|
||||
settings: RerunSceneSettings,
|
||||
*,
|
||||
application_id: str = APPLICATION_ID,
|
||||
recording_id: str,
|
||||
) -> bytes:
|
||||
"""Serialize a small active blueprint update for an already-open recording.
|
||||
|
||||
The returned RRD contains blueprint-store messages only; it never copies the
|
||||
recorded data store and is therefore safe to push through a WebViewer log
|
||||
channel when operator display settings change.
|
||||
"""
|
||||
|
||||
recording = rr.RecordingStream(
|
||||
application_id,
|
||||
recording_id=recording_id,
|
||||
send_properties=False,
|
||||
)
|
||||
stream = rr.binary_stream(recording)
|
||||
try:
|
||||
recording.send_blueprint(
|
||||
_recorded_blueprint(
|
||||
settings,
|
||||
include_initial_playback_state=False,
|
||||
),
|
||||
make_active=True,
|
||||
make_default=False,
|
||||
)
|
||||
payload = stream.read(flush=True, flush_timeout_sec=5.0)
|
||||
except Exception as exc:
|
||||
raise RrdExportError("failed to serialize recorded blueprint") from exc
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
recording.disconnect()
|
||||
if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
|
||||
raise RrdExportError("serialized recorded blueprint is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _log_static_scene(recording: rr.RecordingStream) -> None:
|
||||
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||
recording.log(
|
||||
"/world/sensor_pose",
|
||||
rr.TransformAxes3D(axis_length=0.45, show_frame=False),
|
||||
static=True,
|
||||
)
|
||||
|
||||
|
||||
def _log_session_origin(recording: rr.RecordingStream) -> None:
|
||||
"""Materialize the declared zero of ``session_time`` outside the 3D scene."""
|
||||
|
||||
recording.set_time(
|
||||
SESSION_TIMELINE,
|
||||
duration=np.timedelta64(0, "ns"),
|
||||
)
|
||||
recording.log(
|
||||
"/__mission_core/session_origin",
|
||||
rr.AnyValues(session_origin=True),
|
||||
)
|
||||
|
||||
|
||||
def _set_frame_time(
|
||||
recording: rr.RecordingStream,
|
||||
sequence: int,
|
||||
session_time_ns: int,
|
||||
capture_time_ns: int,
|
||||
) -> None:
|
||||
recording.set_time(
|
||||
SESSION_TIMELINE,
|
||||
duration=np.timedelta64(session_time_ns, "ns"),
|
||||
)
|
||||
recording.set_time(
|
||||
CAPTURE_TIMELINE,
|
||||
timestamp=np.datetime64(capture_time_ns, "ns"),
|
||||
)
|
||||
recording.set_time("message_sequence", sequence=sequence)
|
||||
|
||||
|
||||
def _log_points(
|
||||
recording: rr.RecordingStream,
|
||||
frame: DecodedPointCloudView,
|
||||
settings: RerunSceneSettings,
|
||||
) -> None:
|
||||
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
|
||||
if frame.intensities is None:
|
||||
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
|
||||
else:
|
||||
intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
|
||||
rgb = (
|
||||
None
|
||||
if frame.colors_rgb is None
|
||||
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
||||
)
|
||||
recording.log(
|
||||
"/world/points",
|
||||
rr.Points3D(
|
||||
positions,
|
||||
colors=_point_colors(positions, intensities, rgb, settings),
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _log_pose(
|
||||
recording: rr.RecordingStream,
|
||||
frame: DecodedPoseView,
|
||||
position: tuple[float, float, float],
|
||||
) -> None:
|
||||
recording.log(
|
||||
"/world/sensor_pose",
|
||||
rr.Transform3D(
|
||||
translation=position,
|
||||
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _fsync_file(path: Path) -> None:
|
||||
with path.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _sha256_file(
|
||||
path: Path,
|
||||
*,
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: Callable[[], None] | None = None,
|
||||
) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
_raise_if_cancelled(cancel_event)
|
||||
_notify_activity(activity_callback)
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise RrdExportCancelled("RRD export was cancelled")
|
||||
|
||||
|
||||
def _notify_activity(callback: Callable[[], None] | None) -> None:
|
||||
if callback is not None:
|
||||
callback()
|
||||
@@ -1,569 +0,0 @@
|
||||
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, Protocol, TypedDict
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
from k1link.data_plane import DecodedDataPlaneView, NormalizationError
|
||||
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
|
||||
from k1link.viewer.messages import StreamMessage
|
||||
from k1link.viewer.metrics import BridgeMetrics, MetricsSnapshot
|
||||
from k1link.viewer.replay import iter_replay_messages
|
||||
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
|
||||
|
||||
RuntimePhase = Literal[
|
||||
"idle",
|
||||
"starting_live",
|
||||
"live",
|
||||
"replay",
|
||||
"stopping",
|
||||
"error",
|
||||
]
|
||||
SourceMode = Literal["idle", "live", "replay"]
|
||||
StateCallback = Callable[[], None]
|
||||
BridgeFactory = Callable[..., RerunBridge]
|
||||
# TODO: replace the mixed-modality FIFO with a latest point-cloud slot and a
|
||||
# bounded pose queue. The compact queue protects acquisition from a slow
|
||||
# visualizer, but under sustained pressure it can still evict pose messages.
|
||||
PREVIEW_QUEUE_SIZE = 4
|
||||
|
||||
|
||||
class CanonicalNormalizer(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
message: StreamMessage,
|
||||
*,
|
||||
processing_started_monotonic_ns: int,
|
||||
) -> DecodedDataPlaneView | None: ...
|
||||
|
||||
|
||||
class RuntimeSnapshot(TypedDict):
|
||||
phase: RuntimePhase
|
||||
message: str
|
||||
source_mode: SourceMode
|
||||
source_ready: bool
|
||||
foxglove_ws_url: str | None
|
||||
foxglove_viewer_url: str | None
|
||||
rerun_grpc_url: str | None
|
||||
viewer_settings: dict[str, object]
|
||||
metrics: MetricsSnapshot
|
||||
|
||||
|
||||
class VisualizationRuntime:
|
||||
"""Own one bounded live/replay source and one deterministic publisher thread."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
on_state_change: StateCallback | None = None,
|
||||
grpc_port: int = DEFAULT_GRPC_PORT,
|
||||
bridge_factory: BridgeFactory | None = None,
|
||||
normalizer: CanonicalNormalizer,
|
||||
) -> 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 = "Готово. Включите устройство и начните с поиска по Bluetooth."
|
||||
self._source_mode: SourceMode = "idle"
|
||||
self._source_ready = False
|
||||
self._foxglove_ws_url: str | None = None
|
||||
self._foxglove_viewer_url: str | None = None
|
||||
self._rerun_grpc_url: str | None = None
|
||||
self._grpc_port = grpc_port
|
||||
self._bridge_factory = bridge_factory or RerunBridge
|
||||
self._normalizer = normalizer
|
||||
self._bridge: RerunBridge | None = None
|
||||
self._closed = False
|
||||
self._scene_settings = RerunSceneSettings()
|
||||
self._metrics = BridgeMetrics()
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot:
|
||||
with self._lock:
|
||||
return {
|
||||
"phase": self._phase,
|
||||
"message": self._message,
|
||||
"source_mode": self._source_mode,
|
||||
"source_ready": self._source_ready,
|
||||
"foxglove_ws_url": self._foxglove_ws_url,
|
||||
"foxglove_viewer_url": self._foxglove_viewer_url,
|
||||
"rerun_grpc_url": self._rerun_grpc_url,
|
||||
"viewer_settings": self._scene_settings.as_dict(),
|
||||
"metrics": self._metrics.snapshot(),
|
||||
}
|
||||
|
||||
def update_scene_settings(self, settings: RerunSceneSettings) -> RuntimeSnapshot:
|
||||
with self._lock:
|
||||
self._scene_settings = settings
|
||||
self._notify()
|
||||
return self.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("файл записи не найден на этом компьютере")
|
||||
if not math.isfinite(speed) or speed < 0:
|
||||
raise ValueError("скорость повтора должна быть неотрицательным числом")
|
||||
# Validate the reviewed shape before changing runtime state.
|
||||
iterator = iter_replay_messages(resolved)
|
||||
try:
|
||||
next(iterator)
|
||||
except StopIteration as exc:
|
||||
raise ValueError("в записи нет сообщений") from exc
|
||||
finally:
|
||||
iterator.close()
|
||||
|
||||
self._start(
|
||||
source_mode="replay",
|
||||
phase="replay",
|
||||
message=f"Запускаем повтор записи: {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("длительность приёма должна быть больше нуля")
|
||||
self._start(
|
||||
source_mode="live",
|
||||
phase="starting_live",
|
||||
message="Запускаем приём MQTT и локальный мост визуализации.",
|
||||
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._source_ready = False
|
||||
self._message = "Активного потока нет."
|
||||
notify_only = True
|
||||
else:
|
||||
self._phase = "stopping"
|
||||
self._message = "Останавливаем поток и сохраняем полученные данные."
|
||||
self._stop_event.set()
|
||||
self._notify()
|
||||
if notify_only:
|
||||
return
|
||||
assert thread is not None
|
||||
thread.join(timeout=wait_seconds)
|
||||
if thread.is_alive():
|
||||
raise RuntimeError("поток не завершился за отведённое время; повторите остановку")
|
||||
|
||||
def close(self, *, wait_seconds: float = 5.0) -> None:
|
||||
"""Stop the active source and release the process-wide visual bridge."""
|
||||
with self._lock:
|
||||
self._closed = True
|
||||
thread = self._thread
|
||||
if thread is not None and thread.is_alive():
|
||||
self._phase = "stopping"
|
||||
self._message = "Завершаем локальный поток и визуальный мост."
|
||||
self._stop_event.set()
|
||||
else:
|
||||
self._phase = "idle"
|
||||
self._source_mode = "idle"
|
||||
self._source_ready = False
|
||||
self._message = "Локальный поток завершён."
|
||||
self._notify()
|
||||
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=wait_seconds)
|
||||
|
||||
with self._lock:
|
||||
thread_alive = self._thread is not None and self._thread.is_alive()
|
||||
bridge = None if thread_alive else self._bridge
|
||||
if not thread_alive:
|
||||
self._bridge = None
|
||||
self._rerun_grpc_url = None
|
||||
if bridge is not None:
|
||||
try:
|
||||
bridge.close()
|
||||
except BaseException as exc:
|
||||
self._finish_error(
|
||||
f"Ошибка завершения визуального моста: {type(exc).__name__}: {exc}"
|
||||
)
|
||||
raise
|
||||
self._notify()
|
||||
|
||||
def _start(
|
||||
self,
|
||||
*,
|
||||
source_mode: SourceMode,
|
||||
phase: RuntimePhase,
|
||||
message: str,
|
||||
target: Callable[[], None],
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("runtime завершён; перезапустите локальный сервер")
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
raise RuntimeError("поток уже запущен; сначала остановите текущую сессию")
|
||||
self._stop_event = threading.Event()
|
||||
self._metrics = BridgeMetrics()
|
||||
self._phase = phase
|
||||
self._source_mode = source_mode
|
||||
self._source_ready = False
|
||||
self._message = message
|
||||
self._foxglove_ws_url = None
|
||||
self._foxglove_viewer_url = None
|
||||
self._thread = threading.Thread(
|
||||
target=lambda: self._run_target_safely(target, source_mode),
|
||||
name=f"k1-{source_mode}-session",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
self._notify()
|
||||
|
||||
def _run_target_safely(
|
||||
self,
|
||||
target: Callable[[], None],
|
||||
source_mode: SourceMode,
|
||||
) -> None:
|
||||
"""Convert setup failures before the pipeline into observable runtime state."""
|
||||
|
||||
try:
|
||||
target()
|
||||
except BaseException as exc:
|
||||
with self._lock:
|
||||
closed = self._closed
|
||||
if closed:
|
||||
return
|
||||
self._finish_error(f"Ошибка {source_mode}-источника: {type(exc).__name__}: {exc}")
|
||||
|
||||
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 "Повтор записи остановлен."
|
||||
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 "Повтор записи остановлен."
|
||||
put(message)
|
||||
count += 1
|
||||
if not loop:
|
||||
return f"Повтор завершён: обработано сообщений MQTT — {count}."
|
||||
return "Повтор записи остановлен."
|
||||
|
||||
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",
|
||||
"Приём запущен. Теперь дважды нажмите физическую кнопку устройства.",
|
||||
),
|
||||
on_message_recorded=on_message,
|
||||
should_stop=self._stop_event.is_set,
|
||||
)
|
||||
return f"Приём остановлен. Сохранено сообщений: {summary['message_count']}."
|
||||
|
||||
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=PREVIEW_QUEUE_SIZE)
|
||||
source_done = threading.Event()
|
||||
publisher_ready = threading.Event()
|
||||
publisher_aborted = 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: RerunBridge | None = None
|
||||
try:
|
||||
with self._lock:
|
||||
bridge = self._bridge
|
||||
if self._closed:
|
||||
publisher_aborted.set()
|
||||
if publisher_aborted.is_set():
|
||||
publisher_ready.set()
|
||||
return
|
||||
if bridge is None:
|
||||
candidate = self._bridge_factory(
|
||||
grpc_port=self._grpc_port,
|
||||
metrics=self._metrics,
|
||||
settings_provider=self._current_scene_settings,
|
||||
)
|
||||
bridge = candidate
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
publisher_aborted.set()
|
||||
else:
|
||||
self._bridge = candidate
|
||||
if publisher_aborted.is_set():
|
||||
publisher_ready.set()
|
||||
return
|
||||
assert bridge is not None
|
||||
bridge.begin_session(self._metrics)
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
publisher_aborted.set()
|
||||
else:
|
||||
self._rerun_grpc_url = bridge.grpc_url
|
||||
if running_phase == "replay" and not self._closed and self._phase != "stopping":
|
||||
self._phase = running_phase
|
||||
self._source_ready = True
|
||||
self._message = "Локальный Rerun-мост готов; источник данных запущен."
|
||||
publisher_ready.set()
|
||||
self._notify()
|
||||
if publisher_aborted.is_set():
|
||||
return
|
||||
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:
|
||||
processing_started_ns = time.monotonic_ns()
|
||||
self._metrics.received(len(message.payload))
|
||||
try:
|
||||
envelope = self._normalizer(
|
||||
message,
|
||||
processing_started_monotonic_ns=processing_started_ns,
|
||||
)
|
||||
except NormalizationError:
|
||||
self._metrics.decode_error()
|
||||
else:
|
||||
if envelope is not None:
|
||||
bridge.process(envelope)
|
||||
finally:
|
||||
messages.task_done()
|
||||
if self._metrics.snapshot()["messages_received"] % 10 == 0:
|
||||
self._notify()
|
||||
except BaseException as exc:
|
||||
with self._lock:
|
||||
closed = self._closed
|
||||
if not closed:
|
||||
publisher_error.append(exc)
|
||||
else:
|
||||
publisher_aborted.set()
|
||||
publisher_ready.set()
|
||||
self._stop_event.set()
|
||||
finally:
|
||||
if bridge is not None:
|
||||
with self._lock:
|
||||
close_bridge = self._closed and (
|
||||
self._bridge is bridge or publisher_aborted.is_set()
|
||||
)
|
||||
if close_bridge and self._bridge is bridge:
|
||||
self._bridge = None
|
||||
self._rerun_grpc_url = None
|
||||
if close_bridge:
|
||||
try:
|
||||
bridge.close()
|
||||
except BaseException as exc:
|
||||
# Process shutdown must not become a false successful
|
||||
# idle state when the native bridge failed to close.
|
||||
publisher_error.append(exc)
|
||||
|
||||
def join_publisher() -> None:
|
||||
# Never orphan a publisher: the session thread remains its owner.
|
||||
# On process shutdown both are daemon threads, so an irrecoverably
|
||||
# blocked native call cannot prevent the operating system from exit.
|
||||
while publisher.is_alive():
|
||||
publisher.join(timeout=0.25)
|
||||
|
||||
publisher = threading.Thread(target=publish, name="k1-rerun-publisher", daemon=True)
|
||||
publisher.start()
|
||||
if not publisher_ready.wait(timeout=15.0):
|
||||
self._finish_error("Локальный мост визуализации не запустился за 15 секунд.")
|
||||
source_done.set()
|
||||
self._stop_event.set()
|
||||
join_publisher()
|
||||
return
|
||||
if publisher_aborted.is_set():
|
||||
source_done.set()
|
||||
self._stop_event.set()
|
||||
join_publisher()
|
||||
return
|
||||
if publisher_error:
|
||||
self._finish_error(
|
||||
"Ошибка локального моста визуализации: "
|
||||
f"{type(publisher_error[0]).__name__}: {publisher_error[0]}"
|
||||
)
|
||||
source_done.set()
|
||||
join_publisher()
|
||||
return
|
||||
|
||||
final_message = "Поток остановлен."
|
||||
try:
|
||||
final_message = producer(enqueue)
|
||||
except CaptureError as exc:
|
||||
self._finish_error(f"Ошибка приёма MQTT: {exc}")
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
source_done.set()
|
||||
join_publisher()
|
||||
if publisher_error:
|
||||
self._finish_error(
|
||||
f"Ошибка публикации: {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._source_ready = True
|
||||
self._message = message
|
||||
self._notify()
|
||||
|
||||
def _finish_idle(self, message: str) -> None:
|
||||
with self._lock:
|
||||
self._phase = "idle"
|
||||
self._source_mode = "idle"
|
||||
self._source_ready = False
|
||||
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._source_ready = False
|
||||
self._message = message
|
||||
self._foxglove_ws_url = None
|
||||
self._foxglove_viewer_url = None
|
||||
self._notify()
|
||||
|
||||
def _current_scene_settings(self) -> RerunSceneSettings:
|
||||
with self._lock:
|
||||
return self._scene_settings
|
||||
|
||||
def _notify(self) -> None:
|
||||
callback = self._on_state_change
|
||||
if callback is not None:
|
||||
callback()
|
||||
|
||||
|
||||
def new_live_session_dir(sessions_root: Path) -> Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
base = sessions_root / 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_rerun",
|
||||
"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 Rerun "
|
||||
"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
|
||||
Reference in New Issue
Block a user