feat: add live K1 Foxglove console
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from k1link.protocol.streams import (
|
||||
LegacyPoint,
|
||||
LegacyPointCloudFrame,
|
||||
LioPoint,
|
||||
LioPointCloudFrame,
|
||||
MqttHeader,
|
||||
)
|
||||
from k1link.viewer.foxglove_bridge import (
|
||||
POINT_STRIDE,
|
||||
BridgeMetrics,
|
||||
pack_legacy_point_cloud,
|
||||
pack_lio_point_cloud,
|
||||
)
|
||||
|
||||
|
||||
def test_lio_points_are_scaled_and_packed_with_uint8_intensity() -> None:
|
||||
frame = LioPointCloudFrame(
|
||||
header=MqttHeader(
|
||||
seq=1,
|
||||
stamp=2,
|
||||
scaler=1000,
|
||||
device_id="synthetic",
|
||||
session_id="synthetic",
|
||||
openapi_key=None,
|
||||
),
|
||||
compression=0,
|
||||
compressed_bytes=10,
|
||||
decompressed_bytes=20,
|
||||
points=(LioPoint(-1000, 2500, 3000, 0xAABBCC7F),),
|
||||
)
|
||||
|
||||
packed = pack_lio_point_cloud(frame)
|
||||
|
||||
assert packed.point_count == 1
|
||||
assert len(packed.data) == POINT_STRIDE
|
||||
assert struct.unpack("<fffB3x", packed.data) == (-1.0, 2.5, 3.0, 0x7F)
|
||||
|
||||
|
||||
def test_legacy_points_keep_verified_metric_fields() -> None:
|
||||
frame = LegacyPointCloudFrame(
|
||||
envelope=b"\x00" * 12,
|
||||
stride=16,
|
||||
points=(LegacyPoint(1.0, -2.0, 3.5, 1, 2, 3, 240),),
|
||||
)
|
||||
|
||||
packed = pack_legacy_point_cloud(frame)
|
||||
|
||||
assert struct.unpack("<fffB3x", packed.data) == (1.0, -2.0, 3.5, 240)
|
||||
|
||||
|
||||
def test_metrics_distinguish_preview_drops_and_pipeline_latency() -> None:
|
||||
metrics = BridgeMetrics()
|
||||
metrics.received(100)
|
||||
metrics.preview_dropped()
|
||||
metrics.record_latency(12.3456)
|
||||
metrics.published_pcl(42, 2_000_000_000, 1.2345)
|
||||
|
||||
snapshot = metrics.snapshot()
|
||||
|
||||
assert snapshot["messages_received"] == 1
|
||||
assert snapshot["payload_bytes"] == 100
|
||||
assert snapshot["preview_dropped"] == 1
|
||||
assert snapshot["last_point_count"] == 42
|
||||
assert snapshot["mqtt_to_publish_ms"] == 12.346
|
||||
assert snapshot["decode_publish_ms"] == 1.234
|
||||
@@ -95,11 +95,13 @@ def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
|
||||
|
||||
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
|
||||
fake = FakeClient()
|
||||
observed = []
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "capture",
|
||||
duration_seconds=30,
|
||||
on_message_recorded=observed.append,
|
||||
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
|
||||
@@ -110,6 +112,10 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
|
||||
assert summary["message_count"] == 1
|
||||
assert summary["payload_bytes"] == len(fake.payload)
|
||||
assert summary["subscriptions"] == list(REPORT_TOPICS)
|
||||
assert len(observed) == 1
|
||||
assert observed[0].topic == fake.topic
|
||||
assert observed[0].payload == fake.payload
|
||||
assert observed[0].received_at_epoch_ns > 0
|
||||
|
||||
capture_dir = tmp_path / "capture"
|
||||
raw = (capture_dir / "mqtt.raw.k1mqtt").read_bytes()
|
||||
@@ -122,14 +128,14 @@ def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -
|
||||
assert raw[payload_start:] == fake.payload
|
||||
|
||||
records: list[dict[str, object]] = [
|
||||
json.loads(line)
|
||||
for line in (capture_dir / "mqtt.metadata.jsonl").read_text().splitlines()
|
||||
json.loads(line) for line in (capture_dir / "mqtt.metadata.jsonl").read_text().splitlines()
|
||||
]
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record["record_type"] == "message"
|
||||
assert record["topic"] == fake.topic
|
||||
assert record["payload_bytes"] == len(fake.payload)
|
||||
assert isinstance(record["received_at_epoch_ns"], int)
|
||||
assert record["payload_sha256"] == hashlib.sha256(fake.payload).hexdigest()
|
||||
assert record["raw_frame_offset"] == len(RAW_MAGIC)
|
||||
assert record["raw_payload_offset"] == payload_start
|
||||
@@ -187,6 +193,49 @@ def test_capture_refuses_to_overwrite_existing_artifacts(tmp_path: Path) -> None
|
||||
assert raw.read_bytes() == b"existing evidence"
|
||||
|
||||
|
||||
def test_capture_can_be_stopped_by_owner_without_losing_artifacts(tmp_path: Path) -> None:
|
||||
fake = FakeClient()
|
||||
stop_checks = 0
|
||||
|
||||
def should_stop() -> bool:
|
||||
nonlocal stop_checks
|
||||
stop_checks += 1
|
||||
return stop_checks >= 4
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "capture",
|
||||
duration_seconds=30,
|
||||
should_stop=should_stop,
|
||||
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
|
||||
assert summary["stop_reason"] == "external_stop"
|
||||
assert summary["message_count"] == 1
|
||||
assert list(iter_capture_frames(tmp_path / "capture" / "mqtt.raw.k1mqtt"))[0].payload
|
||||
|
||||
|
||||
def test_preview_failure_happens_after_raw_message_is_preserved(tmp_path: Path) -> None:
|
||||
fake = FakeClient()
|
||||
capture_dir = tmp_path / "capture"
|
||||
|
||||
def fail_preview(_message: object) -> None:
|
||||
raise ValueError("synthetic preview failure")
|
||||
|
||||
with pytest.raises(CaptureError, match="preview callback failed") as error:
|
||||
capture_mqtt(
|
||||
"192.168.1.50",
|
||||
capture_dir,
|
||||
on_message_recorded=fail_preview,
|
||||
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
|
||||
assert error.value.summary is not None
|
||||
assert error.value.summary["message_count"] == 1
|
||||
frames = list(iter_capture_frames(capture_dir / "mqtt.raw.k1mqtt"))
|
||||
assert frames[0].payload == fake.payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "message"),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
|
||||
|
||||
|
||||
def _write_native(path: Path, topic: str, payload: bytes) -> None:
|
||||
topic_raw = topic.encode()
|
||||
path.write_bytes(
|
||||
RAW_MAGIC + FRAME_HEADER.pack(len(topic_raw), len(payload)) + topic_raw + payload
|
||||
)
|
||||
|
||||
|
||||
def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
_write_native(capture, "lixel/application/report/lio_pose", b"pose")
|
||||
(tmp_path / "mqtt.metadata.jsonl").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"record_type": "message",
|
||||
"sequence": 1,
|
||||
"received_at_epoch_ns": 123_000_000_000,
|
||||
"received_monotonic_ns": 456,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
assert detect_replay_format(capture) == "k1mqtt"
|
||||
message = list(iter_replay_messages(capture))[0]
|
||||
assert message.topic.endswith("lio_pose")
|
||||
assert message.payload == b"pose"
|
||||
assert message.received_at_epoch_ns == 123_000_000_000
|
||||
assert message.received_monotonic_ns == 456
|
||||
|
||||
|
||||
def test_legacy_tsv_replay_validates_and_decodes_payload(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "payloads.tsv"
|
||||
capture.write_bytes(b"1784124315.186225000\tRealtimePath\t4\t0001aaff\n")
|
||||
|
||||
assert detect_replay_format(capture) == "legacy_tsv"
|
||||
message = list(iter_replay_messages(capture))[0]
|
||||
assert message.received_at_epoch_ns == 1_784_124_315_186_225_000
|
||||
assert message.payload == b"\x00\x01\xaa\xff"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"line",
|
||||
[
|
||||
b"1\tonly\tthree\n",
|
||||
b"bad\ttopic\t1\t00\n",
|
||||
b"1\ttopic\t2\t00\n",
|
||||
b"1\ttopic\t1\tzz\n",
|
||||
],
|
||||
)
|
||||
def test_legacy_tsv_rejects_unreviewed_shapes(tmp_path: Path, line: bytes) -> None:
|
||||
capture = tmp_path / "payloads.tsv"
|
||||
capture.write_bytes(line)
|
||||
with pytest.raises(ReplayFormatError):
|
||||
list(iter_replay_messages(capture))
|
||||
Reference in New Issue
Block a user