feat: prove and decode K1 realtime MQTT streams
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -21,3 +23,68 @@ def test_doctor_json() -> None:
|
||||
assert isinstance(payload["tools"], list)
|
||||
assert isinstance(payload["network"], dict)
|
||||
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
||||
|
||||
|
||||
def test_mqtt_capture_requires_owned_device_confirmation(tmp_path: Path) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"net",
|
||||
"mqtt-capture",
|
||||
"--host",
|
||||
"192.168.1.20",
|
||||
"--out",
|
||||
str(tmp_path / "capture"),
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 2
|
||||
assert "ownership not confirmed" in result.stdout
|
||||
assert not (tmp_path / "capture").exists()
|
||||
|
||||
|
||||
def test_mqtt_capture_cli_uses_bounded_read_only_capture(
|
||||
monkeypatch: Any,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_capture(host: str, out: Path, **kwargs: object) -> dict[str, object]:
|
||||
on_ready = kwargs.pop("on_ready")
|
||||
assert callable(on_ready)
|
||||
on_ready()
|
||||
captured.update({"host": host, "out": out, **kwargs})
|
||||
return {
|
||||
"stop_reason": "duration_elapsed",
|
||||
"message_count": 2,
|
||||
"payload_bytes": 128,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("k1link.cli.capture_mqtt", fake_capture)
|
||||
out = tmp_path / "capture"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"net",
|
||||
"mqtt-capture",
|
||||
"--host",
|
||||
"10.0.0.42",
|
||||
"--out",
|
||||
str(out),
|
||||
"--duration",
|
||||
"5",
|
||||
"--max-message-bytes",
|
||||
"1024",
|
||||
"--confirm-owned-device",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured == {
|
||||
"host": "10.0.0.42",
|
||||
"out": out,
|
||||
"port": 1883,
|
||||
"duration_seconds": 5.0,
|
||||
"max_message_bytes": 1024,
|
||||
}
|
||||
assert "messages: 2" in result.stdout
|
||||
assert "subscriptions active" in result.stdout
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.macos_credentials import CredentialDialogError, _dialog_text
|
||||
|
||||
|
||||
@patch("k1link.macos_credentials.platform.system", return_value="Darwin")
|
||||
@patch("k1link.macos_credentials.shutil.which", return_value="/usr/bin/osascript")
|
||||
@patch("k1link.macos_credentials.subprocess.run")
|
||||
def test_dialog_returns_value_without_printing(
|
||||
run: Mock,
|
||||
_which: Mock,
|
||||
_system: Mock,
|
||||
) -> None:
|
||||
run.return_value = Mock(returncode=0, stdout="local-value\n", stderr="")
|
||||
|
||||
assert _dialog_text("Prompt", hidden=True) == "local-value"
|
||||
command = run.call_args.args[0]
|
||||
assert "local-value" not in command
|
||||
assert "with hidden answer" in command[-1]
|
||||
|
||||
|
||||
@patch("k1link.macos_credentials.platform.system", return_value="Darwin")
|
||||
@patch("k1link.macos_credentials.shutil.which", return_value="/usr/bin/osascript")
|
||||
@patch("k1link.macos_credentials.subprocess.run")
|
||||
def test_dialog_cancel_is_not_echoed(
|
||||
run: Mock,
|
||||
_which: Mock,
|
||||
_system: Mock,
|
||||
) -> None:
|
||||
run.return_value = Mock(returncode=1, stdout="", stderr="User canceled.")
|
||||
|
||||
with pytest.raises(CredentialDialogError, match="cancelled"):
|
||||
_dialog_text("Prompt", hidden=False)
|
||||
@@ -0,0 +1,209 @@
|
||||
import hashlib
|
||||
import json
|
||||
import stat
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import pytest
|
||||
from paho.mqtt.packettypes import PacketTypes
|
||||
from paho.mqtt.reasoncodes import ReasonCode
|
||||
|
||||
from k1link.mqtt.capture import (
|
||||
FRAME_HEADER,
|
||||
RAW_MAGIC,
|
||||
REPORT_TOPICS,
|
||||
CaptureError,
|
||||
CaptureFormatError,
|
||||
capture_mqtt,
|
||||
iter_capture_frames,
|
||||
validate_private_ipv4,
|
||||
)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, *, topic: str = "RealtimePath", payload: bytes = b"pose-data") -> None:
|
||||
self.on_connect: Callable[..., None] | None = None
|
||||
self.on_subscribe: Callable[..., None] | None = None
|
||||
self.on_message: Callable[..., None] | None = None
|
||||
self.on_disconnect: Callable[..., None] | None = None
|
||||
self.topic = topic
|
||||
self.payload = payload
|
||||
self.connect_calls: list[tuple[str, int, int]] = []
|
||||
self.subscribe_calls: list[Any] = []
|
||||
self.disconnect_count = 0
|
||||
self._step = 0
|
||||
|
||||
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
|
||||
self.connect_calls.append((host, port, keepalive))
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
def subscribe(self, topics: Any) -> tuple[mqtt.MQTTErrorCode, int]:
|
||||
self.subscribe_calls.append(topics)
|
||||
return mqtt.MQTT_ERR_SUCCESS, 7
|
||||
|
||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||
assert timeout > 0
|
||||
self._step += 1
|
||||
if self._step == 1:
|
||||
assert self.on_connect is not None
|
||||
self.on_connect(
|
||||
self,
|
||||
None,
|
||||
mqtt.ConnectFlags(session_present=False),
|
||||
ReasonCode(PacketTypes.CONNACK, "Success"),
|
||||
None,
|
||||
)
|
||||
elif self._step == 2:
|
||||
assert self.on_subscribe is not None
|
||||
self.on_subscribe(
|
||||
self,
|
||||
None,
|
||||
7,
|
||||
[ReasonCode(PacketTypes.SUBACK, identifier=0) for _ in REPORT_TOPICS],
|
||||
None,
|
||||
)
|
||||
elif self._step == 3:
|
||||
assert self.on_message is not None
|
||||
message = mqtt.MQTTMessage(topic=self.topic.encode())
|
||||
message.payload = self.payload
|
||||
message.qos = 0
|
||||
self.on_message(self, None, message)
|
||||
else:
|
||||
raise KeyboardInterrupt
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
def disconnect(self) -> mqtt.MQTTErrorCode:
|
||||
self.disconnect_count += 1
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("address", ["10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.4.2"])
|
||||
def test_validate_private_ipv4_accepts_only_rfc1918(address: str) -> None:
|
||||
assert validate_private_ipv4(address) == address
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
["8.8.8.8", "127.0.0.1", "169.254.1.2", "::1", "k1.local", " 192.168.1.2"],
|
||||
)
|
||||
def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
|
||||
with pytest.raises(ValueError, match="private IPv4|RFC1918"):
|
||||
validate_private_ipv4(address)
|
||||
|
||||
|
||||
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
|
||||
fake = FakeClient()
|
||||
|
||||
summary = capture_mqtt(
|
||||
"192.168.1.50",
|
||||
tmp_path / "capture",
|
||||
duration_seconds=30,
|
||||
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
|
||||
assert fake.connect_calls == [("192.168.1.50", 1883, 30)]
|
||||
assert fake.subscribe_calls == [[(topic, 0) for topic in REPORT_TOPICS]]
|
||||
assert fake.disconnect_count == 1
|
||||
assert summary["stop_reason"] == "keyboard_interrupt"
|
||||
assert summary["message_count"] == 1
|
||||
assert summary["payload_bytes"] == len(fake.payload)
|
||||
assert summary["subscriptions"] == list(REPORT_TOPICS)
|
||||
|
||||
capture_dir = tmp_path / "capture"
|
||||
raw = (capture_dir / "mqtt.raw.k1mqtt").read_bytes()
|
||||
assert raw.startswith(RAW_MAGIC)
|
||||
topic_length, payload_length = FRAME_HEADER.unpack_from(raw, len(RAW_MAGIC))
|
||||
topic_start = len(RAW_MAGIC) + FRAME_HEADER.size
|
||||
payload_start = topic_start + topic_length
|
||||
assert raw[topic_start:payload_start].decode() == fake.topic
|
||||
assert payload_length == len(fake.payload)
|
||||
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()
|
||||
]
|
||||
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 record["payload_sha256"] == hashlib.sha256(fake.payload).hexdigest()
|
||||
assert record["raw_frame_offset"] == len(RAW_MAGIC)
|
||||
assert record["raw_payload_offset"] == payload_start
|
||||
|
||||
frames = list(iter_capture_frames(capture_dir / "mqtt.raw.k1mqtt"))
|
||||
assert len(frames) == 1
|
||||
assert frames[0].sequence == 1
|
||||
assert frames[0].topic == fake.topic
|
||||
assert frames[0].payload == fake.payload
|
||||
assert frames[0].raw_frame_offset == len(RAW_MAGIC)
|
||||
assert frames[0].raw_payload_offset == payload_start
|
||||
|
||||
saved_summary = json.loads((capture_dir / "mqtt.summary.json").read_text())
|
||||
assert saved_summary == summary
|
||||
assert saved_summary["artifact_hashes"]["raw_sha256"] == hashlib.sha256(raw).hexdigest()
|
||||
for artifact_name in ("mqtt.raw.k1mqtt", "mqtt.metadata.jsonl", "mqtt.summary.json"):
|
||||
assert stat.S_IMODE((capture_dir / artifact_name).stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_oversize_payload_is_not_written_to_raw_capture(tmp_path: Path) -> None:
|
||||
fake = FakeClient(payload=b"oversize")
|
||||
capture_dir = tmp_path / "capture"
|
||||
|
||||
with pytest.raises(CaptureError, match="limit is 4 bytes") as error:
|
||||
capture_mqtt(
|
||||
"10.1.2.3",
|
||||
capture_dir,
|
||||
max_message_bytes=4,
|
||||
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
|
||||
assert (capture_dir / "mqtt.raw.k1mqtt").read_bytes() == RAW_MAGIC
|
||||
record = json.loads((capture_dir / "mqtt.metadata.jsonl").read_text())
|
||||
assert record["record_type"] == "rejected_message"
|
||||
assert record["payload_bytes"] == len(fake.payload)
|
||||
assert error.value.summary is not None
|
||||
assert error.value.summary["stop_reason"] == "message_too_large"
|
||||
assert error.value.summary["message_count"] == 0
|
||||
assert error.value.summary["rejected_message_count"] == 1
|
||||
|
||||
|
||||
def test_capture_refuses_to_overwrite_existing_artifacts(tmp_path: Path) -> None:
|
||||
capture_dir = tmp_path / "capture"
|
||||
capture_dir.mkdir()
|
||||
raw = capture_dir / "mqtt.raw.k1mqtt"
|
||||
raw.write_bytes(b"existing evidence")
|
||||
|
||||
with pytest.raises(FileExistsError, match="refusing to overwrite"):
|
||||
capture_mqtt(
|
||||
"192.168.1.2",
|
||||
capture_dir,
|
||||
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
|
||||
)
|
||||
|
||||
assert raw.read_bytes() == b"existing evidence"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "message"),
|
||||
[
|
||||
(b"not-mqtt", "magic/version"),
|
||||
(RAW_MAGIC + b"\x00", "truncated length header"),
|
||||
(RAW_MAGIC + FRAME_HEADER.pack(4, 1) + b"ab", "topic .* is truncated"),
|
||||
(RAW_MAGIC + FRAME_HEADER.pack(1, 4) + b"t" + b"ab", "payload .* is truncated"),
|
||||
(RAW_MAGIC + FRAME_HEADER.pack(1, 5) + b"t" + b"abcde", "exceeds 4"),
|
||||
],
|
||||
)
|
||||
def test_capture_reader_rejects_invalid_or_unbounded_frames(
|
||||
tmp_path: Path,
|
||||
raw: bytes,
|
||||
message: str,
|
||||
) -> None:
|
||||
path = tmp_path / "capture.raw"
|
||||
path.write_bytes(raw)
|
||||
|
||||
with pytest.raises(CaptureFormatError, match=message):
|
||||
list(iter_capture_frames(path, max_payload_bytes=4))
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import struct
|
||||
|
||||
import lz4.block
|
||||
import pytest
|
||||
|
||||
from k1link.protocol.protobuf_wire import ProtobufWireError, decode_zigzag64, iter_fields
|
||||
from k1link.protocol.streams import (
|
||||
DecodeLimits,
|
||||
StreamDecodeError,
|
||||
UnsupportedCompressionError,
|
||||
decode_legacy_pointcloud,
|
||||
decode_legacy_pose,
|
||||
decode_lio_pcl,
|
||||
decode_lio_pose,
|
||||
decode_pre_path_array,
|
||||
)
|
||||
|
||||
|
||||
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 _header(*, scaler: int = 1000) -> bytes:
|
||||
return b"".join(
|
||||
(
|
||||
_uint(1, 7),
|
||||
_sint(2, 123456),
|
||||
_sint(3, scaler),
|
||||
_bytes(4, b"device-redacted"),
|
||||
_bytes(5, b"session-redacted"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _pcl_payload(*, compression: int = 0, scaler: int = 1000) -> bytes:
|
||||
point_1 = _sint(1, 1000) + _sint(2, -2000) + _sint(3, 500) + _uint(4, 0x11223344)
|
||||
point_2 = _sint(1, -250) + _sint(2, 0) + _sint(3, 4000) + _uint(4, 0xAABBCC09)
|
||||
report = _bytes(1, _header(scaler=scaler)) + _bytes(2, point_1) + _bytes(2, point_2)
|
||||
compressed = lz4.block.compress(report, store_size=False)
|
||||
fields = []
|
||||
if compression:
|
||||
fields.append(_uint(2, compression))
|
||||
fields.extend((_uint(3, len(report)), _bytes(4, compressed)))
|
||||
return b"".join(fields)
|
||||
|
||||
|
||||
def test_protobuf_wire_zigzag_and_bounds() -> None:
|
||||
assert decode_zigzag64(0) == 0
|
||||
assert decode_zigzag64(1) == -1
|
||||
assert decode_zigzag64(2) == 1
|
||||
with pytest.raises(ProtobufWireError, match="truncated"):
|
||||
list(iter_fields(b"\x0a\x02\x01"))
|
||||
with pytest.raises(ProtobufWireError, match="unsupported"):
|
||||
list(iter_fields(b"\x0b"))
|
||||
|
||||
|
||||
def test_decode_lio_pcl_raw_lz4() -> None:
|
||||
frame = decode_lio_pcl(_pcl_payload())
|
||||
assert frame.header.seq == 7
|
||||
assert frame.header.stamp == 123456
|
||||
assert frame.header.scaler == 1000
|
||||
assert len(frame.points) == 2
|
||||
assert frame.points[0].scaled_xyz(frame.header.scaler) == (1.0, -2.0, 0.5)
|
||||
assert frame.points[0].rgbi == 0x11223344
|
||||
assert frame.points[0].intensity == 0x44
|
||||
assert frame.points[1].scaled_xyz(frame.header.scaler) == (-0.25, 0.0, 4.0)
|
||||
assert frame.points[1].intensity == 9
|
||||
|
||||
|
||||
def test_decode_lio_pcl_rejects_unverified_or_unsafe_frames() -> None:
|
||||
with pytest.raises(UnsupportedCompressionError, match="enum 1"):
|
||||
decode_lio_pcl(_pcl_payload(compression=1))
|
||||
with pytest.raises(StreamDecodeError, match="scaler is zero"):
|
||||
decode_lio_pcl(_pcl_payload(scaler=0))
|
||||
with pytest.raises(StreamDecodeError, match="exceeds 1 points"):
|
||||
decode_lio_pcl(_pcl_payload(), DecodeLimits(max_points_per_frame=1))
|
||||
with pytest.raises(StreamDecodeError, match="MQTT payload exceeds"):
|
||||
decode_lio_pcl(_pcl_payload(), DecodeLimits(max_mqtt_payload_bytes=4))
|
||||
|
||||
|
||||
def test_decode_lio_pose() -> None:
|
||||
position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75)
|
||||
orientation = (
|
||||
_fixed64(1, 0.1)
|
||||
+ _fixed64(2, 0.2)
|
||||
+ _fixed64(3, 0.3)
|
||||
+ _fixed64(4, 0.9)
|
||||
)
|
||||
pose = _bytes(1, position) + _bytes(2, orientation)
|
||||
stamped = _sint(1, 987654321) + _bytes(2, pose)
|
||||
payload = (
|
||||
_bytes(1, _header())
|
||||
+ _bytes(2, stamped)
|
||||
+ _fixed32(3, 12.5)
|
||||
+ _fixed32(4, 0.001)
|
||||
)
|
||||
|
||||
frame = decode_lio_pose(payload)
|
||||
assert frame.pose_stamp == 987654321
|
||||
assert frame.position_xyz == (1.25, -2.5, 3.75)
|
||||
assert frame.orientation_xyzw == pytest.approx((0.1, 0.2, 0.3, 0.9))
|
||||
assert frame.distance == 12.5
|
||||
assert frame.pose_accuracy == pytest.approx(0.001)
|
||||
|
||||
|
||||
def test_decode_lio_pose_rejects_nonfinite_float() -> None:
|
||||
payload = _bytes(1, _header()) + _fixed32(3, math.nan)
|
||||
with pytest.raises(StreamDecodeError, match="not finite"):
|
||||
decode_lio_pose(payload)
|
||||
|
||||
|
||||
def test_decode_legacy_pointcloud() -> None:
|
||||
envelope = struct.pack("<III", 16, 123, 456)
|
||||
body = struct.pack("<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40)
|
||||
frame = decode_legacy_pointcloud(envelope + body)
|
||||
assert frame.stride == 16
|
||||
assert frame.envelope == envelope
|
||||
assert frame.points[0] == (1.0, -2.0, 3.0, 10, 20, 30, 40)
|
||||
|
||||
|
||||
def test_decode_legacy_pose_and_matrix() -> None:
|
||||
payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
|
||||
frame = decode_legacy_pose(payload + b"tail")
|
||||
assert frame.position_xyz == (1.0, 2.0, 3.0)
|
||||
assert frame.orientation_xyzw == pytest.approx((0.1, 0.2, 0.3, 0.9))
|
||||
assert frame.skipped_offset_12 == struct.pack("<f", 99.0)
|
||||
assert frame.unknown_tail == b"tail"
|
||||
|
||||
matrix = tuple(float(index) for index in range(16))
|
||||
assert decode_pre_path_array(struct.pack("<16d", *matrix)) == matrix
|
||||
with pytest.raises(StreamDecodeError, match="exactly 128"):
|
||||
decode_pre_path_array(b"short")
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import stat
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import lz4.block
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from k1link.analyze.stream_summary import summarize_mqtt_streams
|
||||
from k1link.cli import app
|
||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC, CaptureFormatError
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
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 _fixed64(number: int, value: float) -> bytes:
|
||||
return _key(number, 1) + struct.pack("<d", value)
|
||||
|
||||
|
||||
def _header(*, scaler: int) -> bytes:
|
||||
return b"".join(
|
||||
(
|
||||
_uint(1, 7),
|
||||
_sint(2, 123456),
|
||||
_sint(3, scaler),
|
||||
_bytes(4, b"device-super-secret"),
|
||||
_bytes(5, b"session-super-secret"),
|
||||
_bytes(6, b"openapi-super-secret"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _pcl_payload(*, scaler: int, point_count: int) -> bytes:
|
||||
points = []
|
||||
for index in range(point_count):
|
||||
point = (
|
||||
_sint(1, 1000 + index)
|
||||
+ _sint(2, -2000 - index)
|
||||
+ _sint(3, 500 + index)
|
||||
+ _uint(4, index)
|
||||
)
|
||||
points.append(_bytes(2, point))
|
||||
report = _bytes(1, _header(scaler=scaler)) + b"".join(points)
|
||||
compressed = lz4.block.compress(report, store_size=False)
|
||||
return _uint(3, len(report)) + _bytes(4, compressed)
|
||||
|
||||
|
||||
def _pose_payload(position_xyz: tuple[float, float, float]) -> bytes:
|
||||
position = b"".join(
|
||||
_fixed64(field_number, value)
|
||||
for field_number, value in enumerate(position_xyz, start=1)
|
||||
)
|
||||
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, _header(scaler=1000)) + _bytes(2, stamped)
|
||||
|
||||
|
||||
def _write_capture(path: Path, frames: list[tuple[str, bytes]]) -> bytes:
|
||||
raw = bytearray(RAW_MAGIC)
|
||||
for topic, payload in frames:
|
||||
topic_bytes = topic.encode()
|
||||
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
|
||||
raw.extend(topic_bytes)
|
||||
raw.extend(payload)
|
||||
value = bytes(raw)
|
||||
path.write_bytes(value)
|
||||
return value
|
||||
|
||||
|
||||
def test_summary_is_bounded_aggregate_only_and_hashes_capture(tmp_path: Path) -> None:
|
||||
frames = [
|
||||
("lixel/application/report/lio_pcl", _pcl_payload(scaler=1000, point_count=2)),
|
||||
("lixel/application/report/lio_pcl", _pcl_payload(scaler=2000, point_count=1)),
|
||||
("lixel/application/report/lio_pcl", b"bad-protobuf"),
|
||||
("lixel/application/report/lio_pose", _pose_payload((1.0, 2.0, 3.0))),
|
||||
("lixel/application/report/lio_pose", _pose_payload((4.0, 6.0, 3.0))),
|
||||
("lixel/application/report/lio_pose", b"bad-protobuf"),
|
||||
("device-super-secret/session-super-secret", b"openapi-super-secret"),
|
||||
]
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
raw = _write_capture(capture, frames)
|
||||
|
||||
summary = summarize_mqtt_streams(capture)
|
||||
|
||||
assert summary["source"] == {
|
||||
"bytes": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||
}
|
||||
assert summary["frames"] == {
|
||||
"count": 7,
|
||||
"payload_bytes": sum(len(payload) for _, payload in frames),
|
||||
"encoded_frame_bytes": len(raw) - len(RAW_MAGIC),
|
||||
"other_count": 1,
|
||||
"other_payload_bytes": len(b"openapi-super-secret"),
|
||||
}
|
||||
assert summary["decoding"] == {"attempted": 6, "successes": 4, "errors": 2}
|
||||
assert summary["point_cloud"]["frame_count"] == 3
|
||||
assert summary["point_cloud"]["decode_successes"] == 2
|
||||
assert summary["point_cloud"]["decode_errors"] == 1
|
||||
assert summary["point_cloud"]["points"] == {
|
||||
"total": 3,
|
||||
"per_frame": {"min": 1, "max": 2},
|
||||
}
|
||||
assert summary["point_cloud"]["scalers"] == {
|
||||
"min": 1000,
|
||||
"max": 2000,
|
||||
"constant": False,
|
||||
}
|
||||
assert summary["pose"]["frame_count"] == 3
|
||||
assert summary["pose"]["decode_successes"] == 2
|
||||
assert summary["pose"]["decode_errors"] == 1
|
||||
assert summary["pose"]["first_to_last_displacement_meters"] == 5.0
|
||||
|
||||
serialized = json.dumps(summary)
|
||||
for secret in (
|
||||
"device-super-secret",
|
||||
"session-super-secret",
|
||||
"openapi-super-secret",
|
||||
"bad-protobuf",
|
||||
):
|
||||
assert secret not in serialized
|
||||
assert "position_xyz" not in serialized
|
||||
assert "points" in summary["point_cloud"]
|
||||
|
||||
|
||||
def test_summary_rejects_a_frame_above_the_operator_limit(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
_write_capture(capture, [("other", b"12345")])
|
||||
|
||||
with pytest.raises(CaptureFormatError, match="exceeds 4"):
|
||||
summarize_mqtt_streams(capture, max_payload_bytes=4)
|
||||
|
||||
|
||||
def test_mqtt_streams_cli_writes_atomic_private_summary(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
raw = _write_capture(capture, [])
|
||||
out = tmp_path / "captures" / "summary.json"
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"analyze",
|
||||
"mqtt-streams",
|
||||
"--capture",
|
||||
str(capture),
|
||||
"--out",
|
||||
str(out),
|
||||
"--max-payload-bytes",
|
||||
"1024",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
saved = json.loads(out.read_text())
|
||||
assert saved["source"]["sha256"] == hashlib.sha256(raw).hexdigest()
|
||||
assert saved["limits"]["max_payload_bytes"] == 1024
|
||||
assert saved["frames"]["count"] == 0
|
||||
assert "aggregate-only" in result.stdout
|
||||
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
||||
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import plistlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from k1link.cli import app
|
||||
from k1link.usb.snapshot import (
|
||||
DISKUTIL_COMMAND,
|
||||
SERIAL_IOREG_COMMAND,
|
||||
USB_IOREG_COMMAND,
|
||||
CommandOutput,
|
||||
snapshot,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _plist_output(argv: tuple[str, ...], payload: object) -> CommandOutput:
|
||||
return CommandOutput(
|
||||
argv=argv,
|
||||
returncode=0,
|
||||
stdout=plistlib.dumps(payload),
|
||||
stderr=b"",
|
||||
error=None,
|
||||
)
|
||||
|
||||
|
||||
def _interface(
|
||||
name: str,
|
||||
number: int,
|
||||
interface_class: int,
|
||||
subclass: int,
|
||||
protocol: int,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"IOObjectClass": "IOUSBHostInterface",
|
||||
"IORegistryEntryName": name,
|
||||
"kUSBString": name,
|
||||
"bInterfaceNumber": number,
|
||||
"bInterfaceClass": interface_class,
|
||||
"bInterfaceSubClass": subclass,
|
||||
"bInterfaceProtocol": protocol,
|
||||
"bAlternateSetting": 0,
|
||||
"bConfigurationValue": 1,
|
||||
"bNumEndpoints": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_snapshot_parses_xgrids_interfaces_storage_and_usbmodem() -> None:
|
||||
usb_plist = [
|
||||
{
|
||||
"IOObjectClass": "IOUSBHostDevice",
|
||||
"IORegistryEntryName": "XGRIDS Device",
|
||||
"USB Product Name": "XGRIDS Device",
|
||||
"USB Vendor Name": "rockchip",
|
||||
"USB Serial Number": "synthetic-serial",
|
||||
"idVendor": 0x2207,
|
||||
"idProduct": 0x0019,
|
||||
"bDeviceClass": 0xEF,
|
||||
"bDeviceSubClass": 0x02,
|
||||
"bDeviceProtocol": 0x01,
|
||||
"bcdUSB": 0x0210,
|
||||
"bcdDevice": 0x0310,
|
||||
"USBSpeed": 3,
|
||||
"UsbLinkSpeed": 480_000_000,
|
||||
"USB Address": 1,
|
||||
"locationID": 0x01100000,
|
||||
"IORegistryEntryID": 12345,
|
||||
"IORegistryEntryChildren": [
|
||||
_interface("RNDIS Communications Control", 0, 0xE0, 0x01, 0x03),
|
||||
_interface("RNDIS Ethernet Data", 1, 0x0A, 0x00, 0x00),
|
||||
_interface("Mass Storage", 2, 0x08, 0x06, 0x50),
|
||||
_interface("CDC NCM Control", 3, 0x02, 0x0D, 0x00),
|
||||
_interface("CDC ACM Serial", 4, 0x02, 0x02, 0x01),
|
||||
{
|
||||
"IOObjectClass": "IOMedia",
|
||||
"IORegistryEntryName": "Synthetic Media",
|
||||
"BSD Name": "disk4",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"IOObjectClass": "IOUSBHostDevice",
|
||||
"IORegistryEntryName": "Unrelated Camera",
|
||||
"USB Product Name": "Unrelated Camera",
|
||||
"idVendor": 999,
|
||||
},
|
||||
]
|
||||
serial_plist = [
|
||||
{
|
||||
"IOCalloutDevice": "/dev/cu.usbmodemK1TEST",
|
||||
"IODialinDevice": "/dev/tty.usbmodemK1TEST",
|
||||
"IOTTYBaseName": "usbmodemK1TEST",
|
||||
"IOSerialBSDClientType": "IOSerialStream",
|
||||
},
|
||||
{
|
||||
"IOCalloutDevice": "/dev/cu.debug-console",
|
||||
"IODialinDevice": "/dev/tty.debug-console",
|
||||
},
|
||||
]
|
||||
storage_plist = {
|
||||
"AllDisks": ["disk4", "disk4s1"],
|
||||
"WholeDisks": ["disk4"],
|
||||
"VolumesFromDisks": ["K1_DATA"],
|
||||
"AllDisksAndPartitions": [
|
||||
{
|
||||
"DeviceIdentifier": "disk4",
|
||||
"Content": "GUID_partition_scheme",
|
||||
"Size": 64_000_000,
|
||||
"OSInternal": False,
|
||||
"Partitions": [
|
||||
{
|
||||
"DeviceIdentifier": "disk4s1",
|
||||
"Content": "Microsoft Basic Data",
|
||||
"Size": 63_000_000,
|
||||
"VolumeName": "K1_DATA",
|
||||
"MountPoint": "/Volumes/K1_DATA",
|
||||
"OSInternal": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
outputs = {
|
||||
USB_IOREG_COMMAND: _plist_output(USB_IOREG_COMMAND, usb_plist),
|
||||
SERIAL_IOREG_COMMAND: _plist_output(SERIAL_IOREG_COMMAND, serial_plist),
|
||||
DISKUTIL_COMMAND: _plist_output(DISKUTIL_COMMAND, storage_plist),
|
||||
}
|
||||
|
||||
result = snapshot(lambda argv: outputs[tuple(argv)])
|
||||
|
||||
assert result["xgrids_device_count"] == 1
|
||||
device = result["xgrids_devices"][0]
|
||||
assert device["vendor_id_hex"] == "0x2207"
|
||||
assert device["product_id_hex"] == "0x0019"
|
||||
assert device["bsd_names"] == ["disk4"]
|
||||
assert device["interface_capabilities"] == [
|
||||
"cdc_data",
|
||||
"mass_storage",
|
||||
"ncm",
|
||||
"rndis",
|
||||
"serial",
|
||||
]
|
||||
assert [interface["interface_number"] for interface in device["interfaces"]] == [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
]
|
||||
assert all(entry["xgrids_related"] for entry in result["external_storage"]["entries"])
|
||||
assert result["usbmodem_device_names"] == [
|
||||
"/dev/cu.usbmodemK1TEST",
|
||||
"/dev/tty.usbmodemK1TEST",
|
||||
]
|
||||
assert len(result["usbmodem_devices"]) == 1
|
||||
assert all(source["ok"] for source in result["sources"])
|
||||
assert all("sudo" not in source["argv"] for source in result["sources"])
|
||||
assert result["safety"] == {
|
||||
"metadata_only": True,
|
||||
"sudo_used": False,
|
||||
"device_file_contents_read": False,
|
||||
"device_writes_performed": False,
|
||||
}
|
||||
|
||||
|
||||
def test_snapshot_reports_command_and_plist_errors_without_raising() -> None:
|
||||
outputs = {
|
||||
USB_IOREG_COMMAND: CommandOutput(
|
||||
argv=USB_IOREG_COMMAND,
|
||||
returncode=0,
|
||||
stdout=b"not a plist",
|
||||
stderr=b"",
|
||||
error=None,
|
||||
),
|
||||
SERIAL_IOREG_COMMAND: CommandOutput(
|
||||
argv=SERIAL_IOREG_COMMAND,
|
||||
returncode=1,
|
||||
stdout=b"",
|
||||
stderr=b"serial registry unavailable",
|
||||
error=None,
|
||||
),
|
||||
DISKUTIL_COMMAND: _plist_output(
|
||||
DISKUTIL_COMMAND,
|
||||
{
|
||||
"AllDisks": [],
|
||||
"WholeDisks": [],
|
||||
"VolumesFromDisks": [],
|
||||
"AllDisksAndPartitions": [],
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
result = snapshot(lambda argv: outputs[tuple(argv)])
|
||||
|
||||
assert result["xgrids_devices"] == []
|
||||
statuses = {source["name"]: source for source in result["sources"]}
|
||||
assert statuses["usb_ioreg"]["ok"] is False
|
||||
assert statuses["usb_ioreg"]["error"].startswith("invalid plist:")
|
||||
assert statuses["serial_ioreg"]["ok"] is False
|
||||
assert statuses["serial_ioreg"]["error"] == "serial registry unavailable"
|
||||
assert statuses["external_disks"]["ok"] is True
|
||||
|
||||
|
||||
def test_snapshot_treats_empty_ioreg_output_as_no_devices() -> None:
|
||||
outputs = {
|
||||
USB_IOREG_COMMAND: CommandOutput(USB_IOREG_COMMAND, 0, b"", b"", None),
|
||||
SERIAL_IOREG_COMMAND: CommandOutput(SERIAL_IOREG_COMMAND, 0, b"", b"", None),
|
||||
DISKUTIL_COMMAND: _plist_output(
|
||||
DISKUTIL_COMMAND,
|
||||
{
|
||||
"AllDisks": [],
|
||||
"WholeDisks": [],
|
||||
"VolumesFromDisks": [],
|
||||
"AllDisksAndPartitions": [],
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
result = snapshot(lambda argv: outputs[tuple(argv)])
|
||||
|
||||
assert result["xgrids_device_count"] == 0
|
||||
assert all(source["ok"] for source in result["sources"])
|
||||
|
||||
|
||||
def test_usb_snapshot_cli_writes_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
output = tmp_path / "nested" / "usb-snapshot.json"
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"xgrids_device_count": 0,
|
||||
"safety": {
|
||||
"metadata_only": True,
|
||||
"sudo_used": False,
|
||||
"device_file_contents_read": False,
|
||||
"device_writes_performed": False,
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr("k1link.cli.usb_snapshot", lambda: payload)
|
||||
|
||||
result = runner.invoke(app, ["usb", "snapshot", "--out", str(output)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(output.read_text(encoding="utf-8")) == payload
|
||||
assert "no sudo" in result.stdout
|
||||
@@ -0,0 +1,69 @@
|
||||
import pytest
|
||||
|
||||
from k1link.ble.wifi_provisioning import (
|
||||
FRAME_LENGTH,
|
||||
build_wifi_provisioning_frame,
|
||||
parse_wifi_status,
|
||||
)
|
||||
|
||||
|
||||
def test_build_wifi_provisioning_frame_layout() -> None:
|
||||
frame = build_wifi_provisioning_frame("LabNet", "correct horse")
|
||||
|
||||
assert len(frame) == FRAME_LENGTH
|
||||
assert frame[0] == 6
|
||||
assert frame[1:7] == b"LabNet"
|
||||
assert frame[7:33] == bytes(26)
|
||||
assert frame[33] == 13
|
||||
assert frame[34:47] == b"correct horse"
|
||||
assert frame[47:98] == bytes(51)
|
||||
assert frame[98] == 0
|
||||
|
||||
|
||||
def test_build_wifi_provisioning_frame_uses_utf8_byte_lengths() -> None:
|
||||
frame = build_wifi_provisioning_frame("Сеть", "пароль")
|
||||
|
||||
assert frame[0] == len("Сеть".encode())
|
||||
assert frame[33] == len("пароль".encode())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ssid", "password", "message"),
|
||||
[
|
||||
("", "password", "SSID must not be empty"),
|
||||
("network", "", "password must not be empty"),
|
||||
("x" * 33, "password", "at most 32 UTF-8 bytes"),
|
||||
("network", "x" * 65, "at most 64 UTF-8 bytes"),
|
||||
],
|
||||
)
|
||||
def test_build_wifi_provisioning_frame_rejects_invalid_lengths(
|
||||
ssid: str,
|
||||
password: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match=message):
|
||||
build_wifi_provisioning_frame(ssid, password)
|
||||
|
||||
|
||||
def test_parse_wifi_status_ap_baseline() -> None:
|
||||
value = bytearray(54)
|
||||
value[0] = 7
|
||||
value[1:8] = b"WIFI_AP"
|
||||
value[33] = 4
|
||||
value[34:38] = bytes((192, 168, 56, 1))
|
||||
value[50] = 1
|
||||
value[52:54] = b"XX"
|
||||
|
||||
assert parse_wifi_status(bytes(value)) == {
|
||||
"value_length": 54,
|
||||
"mode": "WIFI_AP",
|
||||
"ipv4": "192.168.56.1",
|
||||
"status_code": 1,
|
||||
"reserved": 0,
|
||||
"trailer_hex": "5858",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_wifi_status_rejects_short_frame() -> None:
|
||||
with pytest.raises(ValueError, match="at least 51 bytes"):
|
||||
parse_wifi_status(bytes(50))
|
||||
Reference in New Issue
Block a user