423 lines
14 KiB
Python
423 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from collections import deque
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import paho.mqtt.client as mqtt
|
|
import pytest
|
|
|
|
from k1link.device_plugins.xgrids_k1.calibration_snapshot import (
|
|
seal_factory_calibration_snapshot,
|
|
)
|
|
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
|
ApplicationControlAuthority,
|
|
LiveDeviceControlBinding,
|
|
)
|
|
from k1link.device_plugins.xgrids_k1.protocol.calibration_file import (
|
|
CALIBRATION_FILE_READ_COMMAND,
|
|
CALIBRATION_FILE_REQUEST_TOPIC,
|
|
CALIBRATION_FILE_RESPONSE_TOPIC,
|
|
FACTORY_CALIBRATION_PATHS,
|
|
FACTORY_CAMERA_CALIBRATION_PATH,
|
|
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
|
CalibrationFileProtocolError,
|
|
CalibrationFileRejected,
|
|
build_factory_calibration_file_read,
|
|
decode_factory_calibration_file_response,
|
|
)
|
|
from k1link.device_plugins.xgrids_k1.protocol.calibration_mqtt import (
|
|
CALIBRATION_READ_SUBSCRIPTIONS,
|
|
FactoryCalibrationReadResult,
|
|
ReviewedCalibrationMqttReader,
|
|
)
|
|
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS
|
|
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
|
|
|
|
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
|
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
DEVICE_SERIAL = "K1SERIAL01"
|
|
PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
|
|
CALIBRATION_FIXTURE_ROOT = Path(__file__).parent / "fixtures" / "k1" / "calibration"
|
|
|
|
|
|
def _authority() -> ApplicationControlAuthority:
|
|
return ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
|
|
|
|
|
def _binding(**changes: object) -> LiveDeviceControlBinding:
|
|
values = {
|
|
"vendor_device_id": VENDOR_DEVICE_ID,
|
|
"device_serial": DEVICE_SERIAL,
|
|
"software_version": "V3.0.2",
|
|
"system_version": "V3.0.2",
|
|
"device_model": "LixelKity K1",
|
|
"device_type": "A4",
|
|
"is_activated": True,
|
|
}
|
|
values.update(changes)
|
|
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
|
|
|
|
|
|
def _varint(value: int) -> bytes:
|
|
encoded = bytearray()
|
|
while value > 0x7F:
|
|
encoded.append((value & 0x7F) | 0x80)
|
|
value >>= 7
|
|
encoded.append(value)
|
|
return bytes(encoded)
|
|
|
|
|
|
def _uint(number: int, value: int) -> bytes:
|
|
return _varint(number << 3) + _varint(value)
|
|
|
|
|
|
def _bytes(number: int, value: bytes) -> bytes:
|
|
return _varint((number << 3) | 2) + _varint(len(value)) + value
|
|
|
|
|
|
def _text(number: int, value: str) -> bytes:
|
|
return _bytes(number, value.encode("utf-8"))
|
|
|
|
|
|
def _header(session_id: str) -> bytes:
|
|
return b"".join(
|
|
(
|
|
_text(4, VENDOR_DEVICE_ID),
|
|
_text(5, session_id),
|
|
_text(6, APPLICATION_KEY),
|
|
)
|
|
)
|
|
|
|
|
|
def _device_info_response() -> bytes:
|
|
base_info = b"".join(
|
|
(
|
|
_text(2, "V3.0.2"),
|
|
_text(3, "V3.0.2"),
|
|
_text(6, "LixelKity K1"),
|
|
_text(7, DEVICE_SERIAL),
|
|
_text(8, "A4"),
|
|
)
|
|
)
|
|
working_status = _uint(1, 1)
|
|
device_info = _bytes(2, base_info) + _bytes(7, working_status)
|
|
return b"".join(
|
|
(
|
|
_bytes(1, _header(":DeviceInfoRequest")),
|
|
_bytes(2, device_info),
|
|
_bytes(15, _uint(1, OPENAPI_SUCCESS)),
|
|
)
|
|
)
|
|
|
|
|
|
def _calibration_response(
|
|
path: str,
|
|
content: bytes,
|
|
*,
|
|
command: int = CALIBRATION_FILE_READ_COMMAND,
|
|
result_code: int = OPENAPI_SUCCESS,
|
|
session_id: str | None = None,
|
|
) -> bytes:
|
|
return b"".join(
|
|
(
|
|
_bytes(
|
|
1,
|
|
_header(
|
|
session_id or f"{VENDOR_DEVICE_ID}:CalibFileRequest"
|
|
),
|
|
),
|
|
_uint(2, command),
|
|
_text(3, path),
|
|
_bytes(4, content),
|
|
_bytes(15, _uint(1, result_code)),
|
|
)
|
|
)
|
|
|
|
|
|
def _content_for(path: str) -> bytes:
|
|
if path == FACTORY_CAMERA_CALIBRATION_PATH:
|
|
return (CALIBRATION_FIXTURE_ROOT / "camera.yaml").read_bytes()
|
|
return (CALIBRATION_FIXTURE_ROOT / "extrinsic_camera_lidar.yaml").read_bytes()
|
|
|
|
|
|
def test_factory_file_request_is_command_five_and_has_no_content_field() -> None:
|
|
request = build_factory_calibration_file_read(
|
|
_authority(),
|
|
_binding(),
|
|
FACTORY_CAMERA_CALIBRATION_PATH,
|
|
)
|
|
fields = {field.number: field.value for field in iter_fields(request.payload)}
|
|
header = fields[1]
|
|
assert isinstance(header, bytes)
|
|
header_fields = {field.number: field.value for field in iter_fields(header)}
|
|
|
|
assert request.topic == CALIBRATION_FILE_REQUEST_TOPIC
|
|
assert request.response_topic == CALIBRATION_FILE_RESPONSE_TOPIC
|
|
assert request.mutates_device is False
|
|
assert request.automatic_retry is False
|
|
assert fields[2] == 5
|
|
assert fields[3] == FACTORY_CAMERA_CALIBRATION_PATH.encode()
|
|
assert 4 not in fields
|
|
assert header_fields[4] == VENDOR_DEVICE_ID.encode()
|
|
assert header_fields[5] == f"{VENDOR_DEVICE_ID}:CalibFileRequest".encode()
|
|
assert header_fields[6] == APPLICATION_KEY.encode()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
(
|
|
"/mnt/system/factory-data/config/../Lixel.yaml",
|
|
"/mnt/system/factory-data/config/extrinsic_camera_motor.yaml",
|
|
"/tmp/camera.yaml",
|
|
"",
|
|
),
|
|
)
|
|
def test_factory_file_request_rejects_every_path_outside_two_file_allowlist(
|
|
path: str,
|
|
) -> None:
|
|
with pytest.raises(CalibrationFileProtocolError, match="two-file allowlist"):
|
|
build_factory_calibration_file_read(_authority(), _binding(), path)
|
|
|
|
|
|
def test_factory_file_request_rejects_a_nonreviewed_live_binding() -> None:
|
|
with pytest.raises(CalibrationFileProtocolError, match="reviewed activated"):
|
|
build_factory_calibration_file_read(
|
|
_authority(),
|
|
_binding(system_version="V3.0.3"),
|
|
FACTORY_CAMERA_CALIBRATION_PATH,
|
|
)
|
|
|
|
|
|
def test_correlated_factory_file_response_returns_exact_utf8_bytes() -> None:
|
|
request = build_factory_calibration_file_read(
|
|
_authority(),
|
|
_binding(),
|
|
FACTORY_CAMERA_CALIBRATION_PATH,
|
|
)
|
|
content = _content_for(request.path)
|
|
|
|
result = decode_factory_calibration_file_response(
|
|
_calibration_response(request.path, content),
|
|
request,
|
|
_authority(),
|
|
_binding(),
|
|
)
|
|
|
|
assert result.path == request.path
|
|
assert result.content == content
|
|
assert result.content_bytes == len(content)
|
|
assert len(result.content_sha256) == 64
|
|
assert result.result_code == OPENAPI_SUCCESS
|
|
|
|
|
|
def test_factory_file_response_rejects_write_command_path_drift_and_duplicates() -> None:
|
|
request = build_factory_calibration_file_read(
|
|
_authority(),
|
|
_binding(),
|
|
FACTORY_CAMERA_CALIBRATION_PATH,
|
|
)
|
|
with pytest.raises(CalibrationFileProtocolError, match="not a file-read"):
|
|
decode_factory_calibration_file_response(
|
|
_calibration_response(request.path, b"yaml", command=6),
|
|
request,
|
|
_authority(),
|
|
_binding(),
|
|
)
|
|
with pytest.raises(CalibrationFileProtocolError, match="path mismatch"):
|
|
decode_factory_calibration_file_response(
|
|
_calibration_response(FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH, b"yaml"),
|
|
request,
|
|
_authority(),
|
|
_binding(),
|
|
)
|
|
duplicate_path = _calibration_response(request.path, b"yaml") + _text(3, request.path)
|
|
with pytest.raises(CalibrationFileProtocolError, match="duplicated"):
|
|
decode_factory_calibration_file_response(
|
|
duplicate_path,
|
|
request,
|
|
_authority(),
|
|
_binding(),
|
|
)
|
|
|
|
|
|
def test_factory_file_response_rejects_device_error_and_non_utf8_content() -> None:
|
|
request = build_factory_calibration_file_read(
|
|
_authority(),
|
|
_binding(),
|
|
FACTORY_CAMERA_CALIBRATION_PATH,
|
|
)
|
|
with pytest.raises(CalibrationFileRejected) as rejected:
|
|
decode_factory_calibration_file_response(
|
|
_calibration_response(request.path, b"yaml", result_code=123),
|
|
request,
|
|
_authority(),
|
|
_binding(),
|
|
)
|
|
assert rejected.value.result_code == 123
|
|
with pytest.raises(CalibrationFileProtocolError, match="valid UTF-8"):
|
|
decode_factory_calibration_file_response(
|
|
_calibration_response(request.path, b"\xff"),
|
|
request,
|
|
_authority(),
|
|
_binding(),
|
|
)
|
|
|
|
|
|
class FakeReasonCode:
|
|
def __init__(self, *, failure: bool = False) -> None:
|
|
self.is_failure = failure
|
|
|
|
|
|
class FakeCalibrationClient:
|
|
def __init__(self) -> None:
|
|
self.on_connect: Any = None
|
|
self.on_subscribe: Any = None
|
|
self.on_publish: Any = None
|
|
self.on_message: Any = None
|
|
self.on_disconnect: Any = None
|
|
self.connect_timeout = 0.0
|
|
self.events: deque[tuple[str, object]] = deque()
|
|
self.connect_calls: list[tuple[str, int, int]] = []
|
|
self.subscribe_calls: list[list[tuple[str, int]]] = []
|
|
self.publish_calls: list[tuple[str, bytes, int, bool]] = []
|
|
self.unsubscribe_calls: list[list[str]] = []
|
|
self.next_mid = 20
|
|
|
|
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
|
|
self.connect_calls.append((host, port, keepalive))
|
|
self.events.append(("connect", FakeReasonCode()))
|
|
return mqtt.MQTT_ERR_SUCCESS
|
|
|
|
def subscribe(self, topics: list[tuple[str, int]]) -> tuple[mqtt.MQTTErrorCode, int]:
|
|
self.subscribe_calls.append(topics)
|
|
self.events.append(("subscribe", 7))
|
|
return mqtt.MQTT_ERR_SUCCESS, 7
|
|
|
|
def publish(
|
|
self,
|
|
topic: str,
|
|
payload: bytes,
|
|
qos: int,
|
|
retain: bool,
|
|
) -> SimpleNamespace:
|
|
self.publish_calls.append((topic, payload, qos, retain))
|
|
mid = self.next_mid
|
|
self.next_mid += 1
|
|
self.events.append(("publish", mid))
|
|
if topic == "lixel/application/request/device_info":
|
|
response_topic = "lixel/application/response/device_info"
|
|
response_payload = _device_info_response()
|
|
else:
|
|
fields = {field.number: field.value for field in iter_fields(payload)}
|
|
raw_path = fields[3]
|
|
assert isinstance(raw_path, bytes)
|
|
path = raw_path.decode()
|
|
response_topic = CALIBRATION_FILE_RESPONSE_TOPIC
|
|
response_payload = _calibration_response(path, _content_for(path))
|
|
self.events.append(("message", (response_topic, response_payload)))
|
|
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
|
|
|
|
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
|
del timeout
|
|
if not self.events:
|
|
return mqtt.MQTT_ERR_SUCCESS
|
|
kind, value = self.events.popleft()
|
|
if kind == "connect":
|
|
self.on_connect(self, None, SimpleNamespace(), value, None)
|
|
elif kind == "subscribe":
|
|
self.on_subscribe(
|
|
self,
|
|
None,
|
|
value,
|
|
[FakeReasonCode() for _topic in CALIBRATION_READ_SUBSCRIPTIONS],
|
|
None,
|
|
)
|
|
elif kind == "publish":
|
|
self.on_publish(self, None, value, FakeReasonCode(), None)
|
|
elif kind == "message":
|
|
topic, payload = value
|
|
self.on_message(self, None, SimpleNamespace(topic=topic, payload=payload))
|
|
return mqtt.MQTT_ERR_SUCCESS
|
|
|
|
def unsubscribe(self, topics: list[str]) -> tuple[mqtt.MQTTErrorCode, int]:
|
|
self.unsubscribe_calls.append(topics)
|
|
return mqtt.MQTT_ERR_SUCCESS, 50
|
|
|
|
def disconnect(self) -> mqtt.MQTTErrorCode:
|
|
return mqtt.MQTT_ERR_SUCCESS
|
|
|
|
|
|
def test_reviewed_mqtt_reader_performs_one_identity_read_then_two_exact_file_reads() -> None:
|
|
client = FakeCalibrationClient()
|
|
reader = ReviewedCalibrationMqttReader(
|
|
"10.255.254.54",
|
|
client_factory=lambda: client, # type: ignore[arg-type]
|
|
)
|
|
|
|
result = reader.read_factory_calibration(_authority())
|
|
|
|
assert result.binding == _binding()
|
|
assert [item.path for item in result.files] == list(FACTORY_CALIBRATION_PATHS)
|
|
assert client.subscribe_calls == [list(CALIBRATION_READ_SUBSCRIPTIONS)]
|
|
assert [topic for topic, _payload, _qos, _retain in client.publish_calls] == [
|
|
"lixel/application/request/device_info",
|
|
CALIBRATION_FILE_REQUEST_TOPIC,
|
|
CALIBRATION_FILE_REQUEST_TOPIC,
|
|
]
|
|
assert all(qos == 2 and not retain for _topic, _payload, qos, retain in client.publish_calls)
|
|
assert result.transport["publish_attempts"] == 3
|
|
assert result.transport["correlated_responses"] == 3
|
|
assert result.transport["write_command_available"] is False
|
|
|
|
|
|
def test_private_snapshot_is_new_only_and_hashes_both_exact_artifacts(tmp_path: Path) -> None:
|
|
files = tuple(
|
|
decode_factory_calibration_file_response(
|
|
_calibration_response(path, _content_for(path)),
|
|
build_factory_calibration_file_read(_authority(), _binding(), path),
|
|
_authority(),
|
|
_binding(),
|
|
)
|
|
for path in FACTORY_CALIBRATION_PATHS
|
|
)
|
|
result = FactoryCalibrationReadResult(
|
|
binding=_binding(),
|
|
files=(files[0], files[1]),
|
|
transport={"automatic_retry": False, "publish_attempts": 3},
|
|
)
|
|
|
|
snapshot = seal_factory_calibration_snapshot(
|
|
result,
|
|
evidence_root=tmp_path,
|
|
compatibility_profile_id=PROFILE_ID,
|
|
captured_at=datetime(2026, 7, 20, 10, 30, tzinfo=UTC),
|
|
)
|
|
|
|
assert snapshot["status"] == "available"
|
|
internal = snapshot["device_internal_calibration"]
|
|
assert isinstance(internal, dict)
|
|
snapshot_path = Path(str(internal["private_snapshot_path"]))
|
|
assert snapshot_path.is_dir()
|
|
assert (snapshot_path / "camera.yaml").read_bytes() == _content_for(
|
|
FACTORY_CAMERA_CALIBRATION_PATH
|
|
)
|
|
assert (snapshot_path / "extrinsic_camera_lidar.yaml").read_bytes() == _content_for(
|
|
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH
|
|
)
|
|
manifest = json.loads((snapshot_path / "manifest.json").read_text(encoding="utf-8"))
|
|
assert manifest["source"]["request_command"] == 5
|
|
assert manifest["source"]["write_command_available"] is False
|
|
assert manifest["normalized_calibration"]["transform_notation"] == (
|
|
"T_destination_from_source"
|
|
)
|
|
assert [item["source_path"] for item in manifest["artifacts"]] == list(
|
|
FACTORY_CALIBRATION_PATHS
|
|
)
|
|
assert snapshot_path.stat().st_mode & 0o777 == 0o700
|
|
assert all(path.stat().st_mode & 0o777 == 0o600 for path in snapshot_path.iterdir())
|
|
assert internal["camera_stream_mapping"]["status"] == "firmware-profile-verified"
|