315 lines
10 KiB
Python
315 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import inspect
|
|
import struct
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import lz4.block
|
|
import pytest
|
|
|
|
import k1link.viewer.runtime as runtime_module
|
|
from k1link.data_plane import (
|
|
ConsumerFrameContext,
|
|
DecodedDeviceStatusView,
|
|
DecodedPointCloudView,
|
|
DecodedPoseView,
|
|
NormalizationError,
|
|
)
|
|
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
|
from k1link.protocol.normalizer import normalize_k1_message
|
|
from k1link.viewer.messages import StreamMessage
|
|
from k1link.viewer.rerun_bridge import RerunBridge
|
|
from k1link.viewer.runtime import VisualizationRuntime
|
|
|
|
|
|
class FakeRecording:
|
|
def __init__(self) -> None:
|
|
self.logs: list[tuple[str, object, bool]] = []
|
|
|
|
def serve_grpc(self, **_: object) -> str:
|
|
return "rerun+http://127.0.0.1:9876/proxy"
|
|
|
|
def log(self, path: str, entity: object, *, static: bool = False) -> None:
|
|
self.logs.append((path, entity, static))
|
|
|
|
def set_time(self, _timeline: str, **_value: object) -> None:
|
|
return
|
|
|
|
def send_blueprint(self, _blueprint: object, **_: object) -> None:
|
|
return
|
|
|
|
def disconnect(self) -> None:
|
|
return
|
|
|
|
def flush(self, **_: object) -> None:
|
|
return
|
|
|
|
|
|
def _message(
|
|
topic: str,
|
|
payload: bytes,
|
|
*,
|
|
source: str = "live_mqtt",
|
|
) -> StreamMessage:
|
|
return StreamMessage(
|
|
sequence=9,
|
|
topic=topic,
|
|
payload=payload,
|
|
received_at_epoch_ns=1_784_124_315_186_225_000,
|
|
received_monotonic_ns=100,
|
|
source=source,
|
|
)
|
|
|
|
|
|
def _varint(value: int) -> bytes:
|
|
encoded = bytearray()
|
|
while value > 0x7F:
|
|
encoded.append((value & 0x7F) | 0x80)
|
|
value >>= 7
|
|
encoded.append(value)
|
|
return bytes(encoded)
|
|
|
|
|
|
def _key(number: int, wire_type: int) -> bytes:
|
|
return _varint((number << 3) | wire_type)
|
|
|
|
|
|
def _uint(number: int, value: int) -> bytes:
|
|
return _key(number, 0) + _varint(value)
|
|
|
|
|
|
def _sint(number: int, value: int) -> bytes:
|
|
zigzag = (value << 1) ^ (value >> 63)
|
|
return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF)
|
|
|
|
|
|
def _bytes(number: int, value: bytes) -> bytes:
|
|
return _key(number, 2) + _varint(len(value)) + value
|
|
|
|
|
|
def _fixed32(number: int, value: float) -> bytes:
|
|
return _key(number, 5) + struct.pack("<f", value)
|
|
|
|
|
|
def _fixed64(number: int, value: float) -> bytes:
|
|
return _key(number, 1) + struct.pack("<d", value)
|
|
|
|
|
|
def _lio_header() -> bytes:
|
|
return b"".join(
|
|
(
|
|
_uint(1, 7),
|
|
_sint(2, 123456),
|
|
_sint(3, 1000),
|
|
_bytes(4, b"device-redacted"),
|
|
_bytes(5, b"session-redacted"),
|
|
)
|
|
)
|
|
|
|
|
|
def _lio_point_payload() -> bytes:
|
|
point = _sint(1, 1000) + _sint(2, -2000) + _sint(3, 500) + _uint(4, 0x44)
|
|
report = _bytes(1, _lio_header()) + _bytes(2, point)
|
|
compressed = lz4.block.compress(report, store_size=False)
|
|
return _uint(3, len(report)) + _bytes(4, compressed)
|
|
|
|
|
|
def _lio_pose_payload() -> bytes:
|
|
position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75)
|
|
orientation = _fixed64(1, 0.0) + _fixed64(2, 0.0) + _fixed64(3, 0.0) + _fixed64(4, 1.0)
|
|
pose = _bytes(1, position) + _bytes(2, orientation)
|
|
stamped = _sint(1, 987654321) + _bytes(2, pose)
|
|
return _bytes(1, _lio_header()) + _bytes(2, stamped) + _fixed32(3, 12.5) + _fixed32(4, 0.001)
|
|
|
|
|
|
def _context() -> ConsumerFrameContext:
|
|
return ConsumerFrameContext(
|
|
sequence=1,
|
|
captured_at_epoch_ns=1,
|
|
received_monotonic_ns=None,
|
|
processing_started_monotonic_ns=1,
|
|
encoded_size_bytes=1,
|
|
live=False,
|
|
)
|
|
|
|
|
|
def test_k1_normalizer_emits_transport_neutral_point_clouds() -> None:
|
|
modern = normalize_k1_message(
|
|
_message("lixel/application/report/lio_pcl", _lio_point_payload()),
|
|
processing_started_monotonic_ns=50,
|
|
)
|
|
legacy_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
|
|
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
|
|
)
|
|
legacy = normalize_k1_message(
|
|
_message("RealtimePointcloud", legacy_payload, source="legacy_tsv"),
|
|
processing_started_monotonic_ns=60,
|
|
)
|
|
|
|
assert isinstance(modern, DecodedPointCloudView)
|
|
assert modern.positions_xyz == ((1.0, -2.0, 0.5),)
|
|
assert modern.intensities == bytes((0x44,))
|
|
assert modern.colors_rgb is None
|
|
assert modern.context.source_device_alias == "device-redacted"
|
|
assert modern.context.source_session_alias == "session-redacted"
|
|
assert modern.context.live is True
|
|
assert not hasattr(modern, "topic")
|
|
assert not hasattr(modern, "payload")
|
|
|
|
assert isinstance(legacy, DecodedPointCloudView)
|
|
assert legacy.positions_xyz == ((1.0, -2.0, 3.0),)
|
|
assert legacy.intensities == bytes((40,))
|
|
assert legacy.colors_rgb == bytes((10, 20, 30))
|
|
assert legacy.context.source_device_alias is None
|
|
assert legacy.context.live is False
|
|
|
|
|
|
def test_k1_normalizer_emits_transport_neutral_poses() -> None:
|
|
modern = normalize_k1_message(
|
|
_message("lixel/application/report/lio_pose", _lio_pose_payload()),
|
|
processing_started_monotonic_ns=50,
|
|
)
|
|
legacy_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
|
|
legacy = normalize_k1_message(
|
|
_message("RealtimePath", legacy_payload, source="legacy_tsv"),
|
|
processing_started_monotonic_ns=60,
|
|
)
|
|
|
|
assert isinstance(modern, DecodedPoseView)
|
|
assert modern.frame_id == "map"
|
|
assert modern.child_frame_id == "sensor"
|
|
assert modern.position_xyz == (1.25, -2.5, 3.75)
|
|
assert modern.orientation_xyzw == (0.0, 0.0, 0.0, 1.0)
|
|
assert modern.context.source_device_alias == "device-redacted"
|
|
assert modern.context.source_session_alias == "session-redacted"
|
|
|
|
assert isinstance(legacy, DecodedPoseView)
|
|
assert legacy.position_xyz == (1.0, 2.0, 3.0)
|
|
assert legacy.orientation_xyzw == (0.0, 0.0, 0.0, 1.0)
|
|
assert legacy.context.source_device_alias is None
|
|
|
|
|
|
def test_known_invalid_and_unknown_channels_have_distinct_outcomes() -> None:
|
|
with pytest.raises(NormalizationError) as caught:
|
|
normalize_k1_message(
|
|
_message("RealtimePointcloud", b"short"),
|
|
processing_started_monotonic_ns=1,
|
|
)
|
|
assert "RealtimePointcloud" not in str(caught.value)
|
|
|
|
assert (
|
|
normalize_k1_message(
|
|
_message("lixel/application/report/unverified", b"opaque"),
|
|
processing_started_monotonic_ns=1,
|
|
)
|
|
is None
|
|
)
|
|
# The status channel is observed but its payload schema is not verified;
|
|
# fail closed instead of inventing normalized status fields.
|
|
assert (
|
|
normalize_k1_message(
|
|
_message("lixel/application/report/device_status", b"opaque"),
|
|
processing_started_monotonic_ns=1,
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_canonical_contracts_reject_misaligned_data_and_support_status() -> None:
|
|
with pytest.raises(ValueError, match="RGB byte count"):
|
|
DecodedPointCloudView(
|
|
context=_context(),
|
|
frame_id="map",
|
|
positions_xyz=((0.0, 0.0, 0.0),),
|
|
colors_rgb=b"\x00\x01",
|
|
)
|
|
|
|
status = DecodedDeviceStatusView(context=_context(), state="ready")
|
|
assert status.state == "ready"
|
|
|
|
|
|
def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge() -> None:
|
|
source = inspect.getsource(__import__("k1link.viewer.rerun_bridge", fromlist=["*"]))
|
|
for forbidden in (
|
|
"k1link.protocol",
|
|
"StreamMessage",
|
|
"RealtimePointcloud",
|
|
"RealtimePath",
|
|
"lio_pcl",
|
|
"lio_pose",
|
|
".topic",
|
|
".payload",
|
|
):
|
|
assert forbidden not in source
|
|
|
|
|
|
def test_visual_runtime_has_no_implicit_vendor_normalizer() -> None:
|
|
source = inspect.getsource(__import__("k1link.viewer.runtime", fromlist=["*"]))
|
|
|
|
assert "k1link.protocol" not in source
|
|
assert "normalize_k1_message" not in source
|
|
assert "normalizer: CanonicalNormalizer" in source
|
|
|
|
|
|
def test_runtime_counts_transport_and_normalization_failures_before_rerun(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
topic = b"RealtimePointcloud"
|
|
payload = b"short"
|
|
capture = tmp_path / "mqtt.raw.k1mqtt"
|
|
capture.write_bytes(RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload)
|
|
recording = FakeRecording()
|
|
|
|
def bridge_factory(**kwargs: object) -> RerunBridge:
|
|
return RerunBridge(
|
|
recording_factory=lambda _: recording, # type: ignore[arg-type]
|
|
**kwargs, # type: ignore[arg-type]
|
|
)
|
|
|
|
runtime = VisualizationRuntime(
|
|
bridge_factory=bridge_factory,
|
|
normalizer=normalize_k1_message,
|
|
)
|
|
runtime.start_replay(capture, speed=0.0)
|
|
deadline = time.monotonic() + 5.0
|
|
while runtime.snapshot()["phase"] != "idle" and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
|
|
snapshot = runtime.snapshot()
|
|
assert snapshot["phase"] == "idle"
|
|
assert snapshot["metrics"]["messages_received"] == 1
|
|
assert snapshot["metrics"]["payload_bytes"] == len(payload)
|
|
assert snapshot["metrics"]["decode_errors"] == 1
|
|
assert snapshot["metrics"]["pcl_frames"] == 0
|
|
assert not any(path == "/world/points" for path, _, _ in recording.logs)
|
|
runtime.close()
|
|
|
|
|
|
def test_live_runtime_surfaces_preamble_failure_as_terminal_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
def fail_preamble(*_: object, **__: object) -> None:
|
|
raise OSError("synthetic evidence directory failure")
|
|
|
|
monkeypatch.setattr(runtime_module, "_write_live_session_preamble", fail_preamble)
|
|
runtime = VisualizationRuntime(normalizer=normalize_k1_message)
|
|
|
|
runtime.start_live(
|
|
"192.168.1.20",
|
|
tmp_path / "unwritable-evidence",
|
|
duration_seconds=1.0,
|
|
)
|
|
deadline = time.monotonic() + 2.0
|
|
snapshot = runtime.snapshot()
|
|
while snapshot["phase"] == "starting_live" and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
snapshot = runtime.snapshot()
|
|
|
|
assert snapshot["phase"] == "error"
|
|
assert snapshot["source_mode"] == "live"
|
|
assert snapshot["source_ready"] is False
|
|
assert "synthetic evidence directory failure" in snapshot["message"]
|
|
runtime.close()
|