feat(plugins): isolate device integrations

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 19:29:32 +03:00
parent f9ffb7bd1c
commit 24a47318f2
122 changed files with 3304 additions and 1892 deletions
+7 -11
View File
@@ -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",
]
-288
View File
@@ -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)
-15
View File
@@ -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"
+124
View File
@@ -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
-170
View File
@@ -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",
)
-595
View File
@@ -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()
-569
View File
@@ -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