feat(device-plugins): add profiled K1 lifecycle and canonical data plane
This commit is contained in:
@@ -23,10 +23,10 @@ def test_advertisement_record_marks_k1_candidate() -> None:
|
||||
assert record["service_uuids"] == ["B", "a"]
|
||||
|
||||
|
||||
def test_advertisement_record_marks_observed_xgr_serial_name_as_k1_candidate() -> None:
|
||||
device = BLEDevice("F89438FA-55ED-85AD-EED7-734AC84746D8", "XGR-A46BE7", None)
|
||||
def test_advertisement_record_marks_synthetic_xgr_name_as_k1_candidate() -> None:
|
||||
device = BLEDevice("00000000-0000-0000-0000-000000000001", "XGR-TEST01", None)
|
||||
advertisement = AdvertisementData(
|
||||
local_name="XGR-A46BE7",
|
||||
local_name="XGR-TEST01",
|
||||
manufacturer_data={},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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()
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.web.device_lifecycle import (
|
||||
AcquisitionRecord,
|
||||
OperationJournal,
|
||||
new_acquisition_id,
|
||||
)
|
||||
|
||||
|
||||
def test_operation_journal_reuses_an_idempotent_request_without_storing_input() -> None:
|
||||
journal = OperationJournal(clock=lambda: datetime(2026, 7, 16, 12, 0, tzinfo=UTC))
|
||||
|
||||
first, created = journal.begin(
|
||||
"network.provision",
|
||||
idempotency_key="provision-owned-k1-once",
|
||||
device_id="device-test",
|
||||
device_session_id="device-session-test",
|
||||
deadline_seconds=45,
|
||||
)
|
||||
repeated, repeated_created = journal.begin(
|
||||
"network.provision",
|
||||
idempotency_key="provision-owned-k1-once",
|
||||
device_id="device-test",
|
||||
device_session_id="device-session-test",
|
||||
deadline_seconds=45,
|
||||
)
|
||||
|
||||
assert created is True
|
||||
assert repeated_created is False
|
||||
assert repeated is first
|
||||
assert "password" not in str(journal.snapshot()).lower()
|
||||
|
||||
|
||||
def test_operation_journal_records_ack_progress_and_terminal_result() -> None:
|
||||
journal = OperationJournal()
|
||||
operation, _ = journal.begin("acquisition.prepare", cancellable=True)
|
||||
|
||||
journal.transition(
|
||||
operation.operation_id,
|
||||
"running",
|
||||
stage_code="compatibility-check",
|
||||
message_code="acquisition.compatibility_check",
|
||||
)
|
||||
journal.transition(
|
||||
operation.operation_id,
|
||||
"succeeded",
|
||||
stage_code="prepared",
|
||||
message_code="acquisition.prepared",
|
||||
result={"acquisition_id": "acq-test"},
|
||||
evidence_refs=("evidence-manifest-test",),
|
||||
)
|
||||
|
||||
document = journal.get(operation.operation_id).as_dict()
|
||||
assert document["schema_version"] == "missioncore.operation-snapshot/v1alpha2"
|
||||
assert document["status"] == "succeeded"
|
||||
assert document["sequence"] == 3
|
||||
assert document["result"] == {"acquisition_id": "acq-test"}
|
||||
assert document["evidence_refs"] == ["evidence-manifest-test"]
|
||||
assert document["completed_at"] is not None
|
||||
|
||||
with pytest.raises(ValueError, match="already terminal"):
|
||||
journal.transition(
|
||||
operation.operation_id,
|
||||
"failed",
|
||||
stage_code="late-failure",
|
||||
message_code="operation.failed",
|
||||
)
|
||||
|
||||
|
||||
def test_operation_id_and_idempotency_key_cannot_be_rebound() -> None:
|
||||
journal = OperationJournal()
|
||||
operation, _ = journal.begin("device.inspect", idempotency_key="same-key")
|
||||
|
||||
with pytest.raises(ValueError, match="another action"):
|
||||
journal.begin("network.provision", idempotency_key="same-key")
|
||||
with pytest.raises(ValueError, match="another action"):
|
||||
journal.begin("sensor.catalog.read", operation_id=operation.operation_id)
|
||||
|
||||
|
||||
def test_idempotency_identity_cannot_be_reused_for_different_request_fingerprint() -> None:
|
||||
journal = OperationJournal()
|
||||
operation, _ = journal.begin(
|
||||
"network.provision",
|
||||
idempotency_key="same-request-only",
|
||||
request_fingerprint="fingerprint-a",
|
||||
)
|
||||
|
||||
repeated, created = journal.begin(
|
||||
"network.provision",
|
||||
idempotency_key="same-request-only",
|
||||
request_fingerprint="fingerprint-a",
|
||||
)
|
||||
assert created is False
|
||||
assert repeated is operation
|
||||
|
||||
with pytest.raises(ValueError, match="different request"):
|
||||
journal.begin(
|
||||
"network.provision",
|
||||
idempotency_key="same-request-only",
|
||||
request_fingerprint="fingerprint-b",
|
||||
)
|
||||
with pytest.raises(ValueError, match="different request"):
|
||||
journal.begin(
|
||||
"network.provision",
|
||||
operation_id=operation.operation_id,
|
||||
request_fingerprint="fingerprint-b",
|
||||
)
|
||||
|
||||
assert "fingerprint-a" not in str(journal.snapshot())
|
||||
|
||||
|
||||
def test_bounded_journal_never_evicts_an_operation_that_is_still_active() -> None:
|
||||
journal = OperationJournal(max_records=1)
|
||||
active, _ = journal.begin("acquisition.start", request_fingerprint="start")
|
||||
completed, _ = journal.begin("device.inspect", request_fingerprint="inspect")
|
||||
journal.transition(
|
||||
completed.operation_id,
|
||||
"succeeded",
|
||||
stage_code="completed",
|
||||
message_code="device.inspect.completed",
|
||||
)
|
||||
|
||||
assert journal.get(active.operation_id).status == "accepted"
|
||||
assert [item["operation_id"] for item in journal.snapshot(limit=10)] == [active.operation_id]
|
||||
|
||||
|
||||
def test_non_cancellable_operation_rejects_cancel_request() -> None:
|
||||
journal = OperationJournal()
|
||||
operation, _ = journal.begin("network.provision", cancellable=False)
|
||||
|
||||
with pytest.raises(ValueError, match="not cancellable"):
|
||||
journal.request_cancel(operation.operation_id)
|
||||
|
||||
|
||||
def test_acquisition_record_keeps_device_session_profile_and_manual_control_separate() -> None:
|
||||
acquisition = AcquisitionRecord(
|
||||
acquisition_id=new_acquisition_id(),
|
||||
device_id="device-test",
|
||||
device_session_id="device-session-test",
|
||||
compatibility_profile_id="xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
||||
control_mode="operator-manual",
|
||||
requested_streams=("spatial.point-cloud.live", "spatial.pose.live"),
|
||||
target_host="192.168.1.20",
|
||||
duration_seconds=60,
|
||||
evidence_policy="required",
|
||||
)
|
||||
|
||||
acquisition.transition("prepared", message_code="acquisition.prepared")
|
||||
acquisition.transition(
|
||||
"awaiting_external_start",
|
||||
message_code="acquisition.operator_start_required",
|
||||
operator_instructions=("Дважды нажмите физическую кнопку устройства.",),
|
||||
)
|
||||
|
||||
document = acquisition.as_dict()
|
||||
assert document["schema_version"] == "missioncore.acquisition-snapshot/v1alpha2"
|
||||
assert document["control_mode"] == "operator-manual"
|
||||
assert document["state"] == "awaiting_external_start"
|
||||
assert document["device_id"] != document["device_session_id"]
|
||||
assert document["operator_instructions"]
|
||||
@@ -90,3 +90,46 @@ def test_model_switch_is_guarded_by_plugin_deactivation() -> None:
|
||||
assert "catch" in host_source
|
||||
assert "selectionTransitionError" in host_source
|
||||
assert "return controller.stop();" in xgrids_runtime
|
||||
|
||||
|
||||
def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
plugin_root = (
|
||||
repository_root / "apps" / "control-station" / "src" / "device-plugins" / "xgrids-k1"
|
||||
)
|
||||
manifest_source = (plugin_root / "manifest.ts").read_text("utf-8")
|
||||
api_source = (plugin_root / "api.ts").read_text("utf-8")
|
||||
hook_source = (plugin_root / "useXgridsK1Runtime.ts").read_text("utf-8")
|
||||
runtime_source = (plugin_root / "runtimeContext.tsx").read_text("utf-8")
|
||||
|
||||
for action in ("acquisition.prepare", "acquisition.start", "acquisition.stop"):
|
||||
assert action in manifest_source
|
||||
assert "prepareAcquisition" in api_source
|
||||
assert "startAcquisition" in api_source
|
||||
assert hook_source.index("xgridsK1Api.prepareAcquisition") < hook_source.index(
|
||||
"xgridsK1Api.startAcquisition"
|
||||
)
|
||||
assert 'mode: "capture-only"' in hook_source
|
||||
assert "state.device_ref" in runtime_source
|
||||
assert "instanceId: deviceRef.device_id" in runtime_source
|
||||
assert "acquisition?.acquisition_id" in runtime_source
|
||||
assert "instanceId: state.selected_device_id" not in runtime_source
|
||||
assert 'id: "xgrids-k1-rerun-live"' not in runtime_source
|
||||
|
||||
|
||||
def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
connection_source = (
|
||||
repository_root
|
||||
/ "apps"
|
||||
/ "control-station"
|
||||
/ "src"
|
||||
/ "device-plugins"
|
||||
/ "xgrids-k1"
|
||||
/ "XgridsK1Connection.tsx"
|
||||
).read_text("utf-8")
|
||||
|
||||
assert "Подготовить приём данных" in connection_source
|
||||
assert "Программная команда запуска на K1 пока не отправляется" in connection_source
|
||||
assert "Остановить локальный приём" in connection_source
|
||||
assert "Физическое состояние сканера остаётся неизвестным" in connection_source
|
||||
|
||||
@@ -45,12 +45,38 @@ def _manifest(plugin_id: str, model_id: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _v1alpha2_manifest(plugin_id: str, model_id: str) -> dict[str, Any]:
|
||||
document = _manifest(plugin_id, model_id)
|
||||
document["apiVersion"] = "missioncore.nodedc/v1alpha2"
|
||||
document["metadata"]["version"] = "0.2.0"
|
||||
document["spec"]["hostApiRange"] = "v1alpha2"
|
||||
document["spec"]["compatibilityProfiles"] = [
|
||||
{
|
||||
"profileId": f"{model_id}.fw-1.direct-lan.v1",
|
||||
"path": "profiles/fw-1/direct-lan.v1.json",
|
||||
"modelId": model_id,
|
||||
}
|
||||
]
|
||||
return document
|
||||
|
||||
|
||||
def _write_manifest(root: Path, directory: str, document: dict[str, Any]) -> None:
|
||||
target = root / "plugins" / directory / "plugin.manifest.json"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(json.dumps(document), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_profile(
|
||||
root: Path,
|
||||
directory: str,
|
||||
profile_id: str,
|
||||
relative_path: str = "profiles/fw-1/direct-lan.v1.json",
|
||||
) -> None:
|
||||
target = root / "plugins" / directory / relative_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps({"profile_id": profile_id}), encoding="utf-8")
|
||||
|
||||
|
||||
def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
catalog = DevicePluginCatalog(repository_root)
|
||||
@@ -58,10 +84,40 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||
plugins = catalog.plugin_documents()
|
||||
models = catalog.model_documents()
|
||||
|
||||
assert any(item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1" for item in plugins)
|
||||
plugin = next(
|
||||
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
|
||||
)
|
||||
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
|
||||
assert plugin["metadata"]["version"] == "0.2.0"
|
||||
assert plugin["spec"]["hostApiRange"] == "v1alpha2"
|
||||
assert plugin["spec"]["compatibilityProfiles"] == [
|
||||
{
|
||||
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
|
||||
"path": "profiles/fw-3.0.2/direct-lan.v1.json",
|
||||
"modelId": "xgrids.lixelkity-k1",
|
||||
}
|
||||
]
|
||||
action_ids = {action["id"] for action in plugin["spec"]["actions"]}
|
||||
assert {
|
||||
"device.inspect",
|
||||
"sensor.catalog.read",
|
||||
"calibration.device-snapshot.read",
|
||||
"connection.verify",
|
||||
"acquisition.prepare",
|
||||
"acquisition.start",
|
||||
"acquisition.stop",
|
||||
"acquisition.abort",
|
||||
"acquisition.state.read",
|
||||
} <= action_ids
|
||||
assert {
|
||||
"stream.start-live",
|
||||
"stream.start-replay",
|
||||
"stream.stop",
|
||||
"viewer.settings.update",
|
||||
} <= action_ids
|
||||
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
|
||||
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
||||
"pluginVersion": "0.1.0",
|
||||
"pluginVersion": "0.2.0",
|
||||
"id": "xgrids.lixelkity-k1",
|
||||
"vendor": "XGRIDS",
|
||||
"displayName": "XGRIDS LixelKity K1",
|
||||
@@ -105,6 +161,152 @@ def test_catalog_accepts_multiple_distinct_plugins_and_models(tmp_path: Path) ->
|
||||
}
|
||||
|
||||
|
||||
def test_catalog_accepts_v1alpha2_profile_link(tmp_path: Path) -> None:
|
||||
document = _v1alpha2_manifest("example.next", "example.next-model")
|
||||
profile_id = document["spec"]["compatibilityProfiles"][0]["profileId"]
|
||||
_write_manifest(tmp_path, "next", document)
|
||||
_write_profile(tmp_path, "next", profile_id)
|
||||
|
||||
plugin = DevicePluginCatalog(tmp_path).plugin_documents()[0]
|
||||
|
||||
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
|
||||
assert plugin["spec"]["compatibilityProfiles"][0]["profileId"] == profile_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutate",
|
||||
[
|
||||
lambda document: document["metadata"].update({"id": "invalid plugin"}),
|
||||
lambda document: document["spec"]["permissions"].append("invalid permission"),
|
||||
lambda document: document["spec"]["actions"][0].update({"id": "invalid action"}),
|
||||
lambda document: document["spec"]["models"][0].update({"id": "invalid model"}),
|
||||
lambda document: document["spec"]["compatibilityProfiles"][0].update(
|
||||
{"profileId": "invalid profile"}
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_v1alpha2_rejects_non_identifier_contract_fields(
|
||||
tmp_path: Path,
|
||||
mutate: Any,
|
||||
) -> None:
|
||||
document = _v1alpha2_manifest("example.strict", "example.strict-model")
|
||||
mutate(document)
|
||||
_write_manifest(tmp_path, "strict", document)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="v1alpha2 identifier"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
|
||||
def test_v1alpha1_keeps_legacy_nonblank_identifier_compatibility(tmp_path: Path) -> None:
|
||||
document = _manifest("legacy plugin", "legacy model")
|
||||
_write_manifest(tmp_path, "legacy", document)
|
||||
|
||||
plugin = DevicePluginCatalog(tmp_path).plugin_documents()[0]
|
||||
|
||||
assert plugin["metadata"]["id"] == "legacy plugin"
|
||||
assert plugin["spec"]["models"][0]["id"] == "legacy model"
|
||||
|
||||
|
||||
def test_catalog_accepts_multiple_profiled_models_in_v1alpha2(tmp_path: Path) -> None:
|
||||
document = _v1alpha2_manifest("example.family", "example.model-a")
|
||||
model_b = {
|
||||
**document["spec"]["models"][0],
|
||||
"id": "example.model-b",
|
||||
"displayName": "Example model B",
|
||||
}
|
||||
document["spec"]["models"].append(model_b)
|
||||
profile_b = "example.model-b.fw-1.direct-lan.v1"
|
||||
document["spec"]["compatibilityProfiles"].append(
|
||||
{
|
||||
"profileId": profile_b,
|
||||
"path": "profiles/fw-1/model-b.v1.json",
|
||||
"modelId": "example.model-b",
|
||||
}
|
||||
)
|
||||
_write_manifest(tmp_path, "family", document)
|
||||
profile_a = document["spec"]["compatibilityProfiles"][0]["profileId"]
|
||||
_write_profile(tmp_path, "family", profile_a)
|
||||
_write_profile(
|
||||
tmp_path,
|
||||
"family",
|
||||
profile_b,
|
||||
relative_path="profiles/fw-1/model-b.v1.json",
|
||||
)
|
||||
|
||||
plugin = DevicePluginCatalog(tmp_path).plugin_documents()[0]
|
||||
|
||||
assert [model["id"] for model in plugin["spec"]["models"]] == [
|
||||
"example.model-a",
|
||||
"example.model-b",
|
||||
]
|
||||
|
||||
|
||||
def test_catalog_rejects_api_and_host_contract_mismatch(tmp_path: Path) -> None:
|
||||
document = _manifest("example.mismatch", "example.mismatch-model")
|
||||
document["spec"]["hostApiRange"] = "v1alpha2"
|
||||
_write_manifest(tmp_path, "mismatch", document)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
|
||||
def test_v1alpha2_catalog_rejects_missing_profile_file(tmp_path: Path) -> None:
|
||||
document = _v1alpha2_manifest("example.missing", "example.missing-model")
|
||||
_write_manifest(tmp_path, "missing", document)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="Compatibility profile does not exist"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
|
||||
def test_v1alpha2_catalog_rejects_profile_path_outside_plugin(tmp_path: Path) -> None:
|
||||
document = _v1alpha2_manifest("example.escape", "example.escape-model")
|
||||
profile_id = document["spec"]["compatibilityProfiles"][0]["profileId"]
|
||||
document["spec"]["compatibilityProfiles"][0]["path"] = "../outside.json"
|
||||
outside = tmp_path / "plugins" / "outside.json"
|
||||
outside.parent.mkdir(parents=True, exist_ok=True)
|
||||
outside.write_text(json.dumps({"profile_id": profile_id}), encoding="utf-8")
|
||||
_write_manifest(tmp_path, "escape", document)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="escapes plugin directory"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
|
||||
def test_v1alpha2_catalog_rejects_profile_id_mismatch(tmp_path: Path) -> None:
|
||||
document = _v1alpha2_manifest("example.mismatch", "example.mismatch-model")
|
||||
_write_manifest(tmp_path, "mismatch", document)
|
||||
_write_profile(tmp_path, "mismatch", "different.profile.id")
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="Compatibility profile id mismatch"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
|
||||
def test_v1alpha2_catalog_rejects_duplicate_profile_json_keys(tmp_path: Path) -> None:
|
||||
document = _v1alpha2_manifest("example.duplicate", "example.duplicate-model")
|
||||
profile_id = document["spec"]["compatibilityProfiles"][0]["profileId"]
|
||||
_write_manifest(tmp_path, "duplicate", document)
|
||||
target = tmp_path / "plugins" / "duplicate" / "profiles" / "fw-1" / "direct-lan.v1.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(
|
||||
f'{{"profile_id": "{profile_id}", "profile_id": "{profile_id}"}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="Duplicate JSON key"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
|
||||
def test_v1alpha2_catalog_rejects_profile_for_unknown_model(tmp_path: Path) -> None:
|
||||
document = _v1alpha2_manifest("example.unknown", "example.known-model")
|
||||
link = document["spec"]["compatibilityProfiles"][0]
|
||||
profile_id = link["profileId"]
|
||||
link["modelId"] = "example.unknown-model"
|
||||
_write_manifest(tmp_path, "unknown", document)
|
||||
_write_profile(tmp_path, "unknown", profile_id)
|
||||
|
||||
with pytest.raises(PluginCatalogError, match="references unknown model"):
|
||||
DevicePluginCatalog(tmp_path).manifests()
|
||||
|
||||
|
||||
def test_catalog_rejects_duplicate_model_ids(tmp_path: Path) -> None:
|
||||
_write_manifest(tmp_path, "first", _manifest("example.first", "example.model"))
|
||||
_write_manifest(tmp_path, "second", _manifest("example.second", "example.model"))
|
||||
|
||||
@@ -31,6 +31,7 @@ from k1link.web.xgrids_k1_facade import (
|
||||
ACTION_STREAM_STOP,
|
||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
CompatibilityAttestationRequest,
|
||||
ConnectRequest,
|
||||
ViewerSettingsRequest,
|
||||
XgridsK1PluginFacade,
|
||||
@@ -53,8 +54,13 @@ class FakeXgridsService:
|
||||
self.calls.append(("connect", request))
|
||||
return {"phase": "connected", "k1_ip": "192.168.1.20"}
|
||||
|
||||
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
|
||||
self.calls.append(("live", (host, duration_seconds)))
|
||||
def start_live(
|
||||
self,
|
||||
host: str | None,
|
||||
duration_seconds: float,
|
||||
compatibility_attestation: CompatibilityAttestationRequest,
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append(("live", (host, duration_seconds, compatibility_attestation)))
|
||||
return {"phase": "live"}
|
||||
|
||||
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
|
||||
@@ -271,7 +277,12 @@ def test_facade_validates_payload_before_calling_service() -> None:
|
||||
dispatcher.invoke(
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
ACTION_NETWORK_PROVISION,
|
||||
{"device_id": "id", "ssid": "network", "password": "secret", "extra": True},
|
||||
{
|
||||
"device_id": "id",
|
||||
"ssid": "network",
|
||||
"password": "x" * 24,
|
||||
"extra": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -281,7 +292,18 @@ def test_facade_validates_payload_before_calling_service() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("action_id", "payload", "expected_call"),
|
||||
[
|
||||
(ACTION_STREAM_START_LIVE, {"host": "192.168.1.20"}, "live"),
|
||||
(
|
||||
ACTION_STREAM_START_LIVE,
|
||||
{
|
||||
"host": "192.168.1.20",
|
||||
"compatibility_attestation": {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"operator_confirmed": True,
|
||||
},
|
||||
},
|
||||
"live",
|
||||
),
|
||||
(
|
||||
ACTION_STREAM_START_REPLAY,
|
||||
{"path": "sessions/capture.k1mqtt", "speed": 1, "loop": False},
|
||||
|
||||
@@ -10,7 +10,9 @@ from pathlib import Path
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.data_plane import DecodedDataPlaneView, 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, RerunSceneSettings, _point_colors
|
||||
from k1link.viewer.runtime import VisualizationRuntime
|
||||
@@ -53,6 +55,15 @@ def _message(topic: str, payload: bytes, *, sequence: int = 7) -> StreamMessage:
|
||||
)
|
||||
|
||||
|
||||
def _envelope(topic: str, payload: bytes, *, sequence: int = 7) -> DecodedDataPlaneView:
|
||||
envelope = normalize_k1_message(
|
||||
_message(topic, payload, sequence=sequence),
|
||||
processing_started_monotonic_ns=time.monotonic_ns(),
|
||||
)
|
||||
assert envelope is not None
|
||||
return envelope
|
||||
|
||||
|
||||
def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
|
||||
recording = FakeRecording()
|
||||
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
|
||||
@@ -61,8 +72,8 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
|
||||
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
|
||||
)
|
||||
pose_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
|
||||
bridge.process(_message("RealtimePointcloud", point_payload))
|
||||
bridge.process(_message("RealtimePath", pose_payload, sequence=8))
|
||||
bridge.process(_envelope("RealtimePointcloud", point_payload))
|
||||
bridge.process(_envelope("RealtimePath", pose_payload, sequence=8))
|
||||
|
||||
paths = [path for path, _, _ in recording.logs]
|
||||
snapshot = bridge.metrics.snapshot()
|
||||
@@ -83,15 +94,19 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
|
||||
assert recording.disconnected is True
|
||||
|
||||
|
||||
def test_bad_frame_is_counted_without_publishing() -> None:
|
||||
def test_bad_frame_is_rejected_before_rerun_without_publishing() -> None:
|
||||
recording = FakeRecording()
|
||||
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
|
||||
|
||||
bridge.process(_message("RealtimePointcloud", b"short"))
|
||||
with pytest.raises(NormalizationError):
|
||||
normalize_k1_message(
|
||||
_message("RealtimePointcloud", b"short"),
|
||||
processing_started_monotonic_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
snapshot = bridge.metrics.snapshot()
|
||||
assert snapshot["messages_received"] == 1
|
||||
assert snapshot["decode_errors"] == 1
|
||||
assert snapshot["messages_received"] == 0
|
||||
assert snapshot["decode_errors"] == 0
|
||||
assert not any(path == "/world/points" for path, _, _ in recording.logs)
|
||||
|
||||
|
||||
@@ -193,6 +208,7 @@ def test_runtime_exposes_rerun_url_and_stops_cleanly(tmp_path: Path) -> None:
|
||||
|
||||
runtime = VisualizationRuntime(
|
||||
bridge_factory=bridge_factory,
|
||||
normalizer=normalize_k1_message,
|
||||
)
|
||||
runtime.start_replay(capture, speed=1.0)
|
||||
deadline = time.monotonic() + 5.0
|
||||
@@ -258,7 +274,10 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) ->
|
||||
**kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
runtime = VisualizationRuntime(bridge_factory=blocked_factory)
|
||||
runtime = VisualizationRuntime(
|
||||
bridge_factory=blocked_factory,
|
||||
normalizer=normalize_k1_message,
|
||||
)
|
||||
runtime.start_replay(capture, speed=0.0)
|
||||
assert factory_entered.wait(timeout=2.0)
|
||||
|
||||
@@ -306,7 +325,10 @@ def test_stop_fails_closed_when_runtime_thread_misses_deadline(tmp_path: Path) -
|
||||
**kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
runtime = VisualizationRuntime(bridge_factory=blocked_factory)
|
||||
runtime = VisualizationRuntime(
|
||||
bridge_factory=blocked_factory,
|
||||
normalizer=normalize_k1_message,
|
||||
)
|
||||
runtime.start_replay(capture, speed=0.0)
|
||||
assert factory_entered.wait(timeout=2.0)
|
||||
|
||||
|
||||
@@ -113,20 +113,10 @@ def test_decode_lio_pcl_rejects_unverified_or_unsafe_frames() -> None:
|
||||
|
||||
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)
|
||||
)
|
||||
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)
|
||||
)
|
||||
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
|
||||
|
||||
@@ -77,15 +77,9 @@ def _pcl_payload(*, scaler: int, point_count: int) -> bytes:
|
||||
|
||||
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)
|
||||
_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)
|
||||
|
||||
@@ -19,8 +19,8 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
|
||||
"devices": [
|
||||
{
|
||||
"macos_uuid": "K1-UUID",
|
||||
"name": "XGR-A46BE7",
|
||||
"local_name": "XGR-A46BE7",
|
||||
"name": "XGR-TEST01",
|
||||
"local_name": "XGR-TEST01",
|
||||
"rssi": -51,
|
||||
"k1_name_candidate": True,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.web.app import INVALID_REQUEST_DETAIL, app
|
||||
from k1link.web.xgrids_k1_facade import XGRIDS_K1_PLUGIN_ID
|
||||
|
||||
|
||||
async def _post_json(path: str, payload: dict[str, Any]) -> tuple[int, str]:
|
||||
body = json.dumps(payload).encode()
|
||||
request_sent = False
|
||||
response_messages: list[dict[str, Any]] = []
|
||||
|
||||
async def receive() -> dict[str, Any]:
|
||||
nonlocal request_sent
|
||||
if request_sent:
|
||||
return {"type": "http.disconnect"}
|
||||
request_sent = True
|
||||
return {"type": "http.request", "body": body, "more_body": False}
|
||||
|
||||
async def send(message: dict[str, Any]) -> None:
|
||||
response_messages.append(message)
|
||||
|
||||
scope: dict[str, Any] = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0", "spec_version": "2.3"},
|
||||
"http_version": "1.1",
|
||||
"method": "POST",
|
||||
"scheme": "http",
|
||||
"path": path,
|
||||
"raw_path": path.encode(),
|
||||
"query_string": b"",
|
||||
"root_path": "",
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(body)).encode()),
|
||||
],
|
||||
"client": ("127.0.0.1", 41000),
|
||||
"server": ("127.0.0.1", 8765),
|
||||
}
|
||||
await app(scope, receive, send)
|
||||
|
||||
start = next(
|
||||
message for message in response_messages if message["type"] == "http.response.start"
|
||||
)
|
||||
response_body = b"".join(
|
||||
message.get("body", b"")
|
||||
for message in response_messages
|
||||
if message["type"] == "http.response.body"
|
||||
)
|
||||
return int(start["status"]), response_body.decode()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "wrap_input"),
|
||||
[
|
||||
(
|
||||
f"/api/v1/device-plugins/{XGRIDS_K1_PLUGIN_ID}/actions/network.provision",
|
||||
True,
|
||||
),
|
||||
("/api/connect", False),
|
||||
],
|
||||
)
|
||||
def test_validation_errors_do_not_echo_sensitive_request_values(
|
||||
path: str,
|
||||
wrap_input: bool,
|
||||
) -> None:
|
||||
sensitive_value = "x" * 300
|
||||
action_input = {
|
||||
"device_id": "synthetic-device",
|
||||
"ssid": "synthetic-network",
|
||||
"password": sensitive_value,
|
||||
"compatibility_attestation": {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"operator_confirmed": True,
|
||||
},
|
||||
}
|
||||
payload = {"input": action_input} if wrap_input else action_input
|
||||
|
||||
status_code, response_text = asyncio.run(_post_json(path, payload))
|
||||
|
||||
assert status_code == 422
|
||||
assert json.loads(response_text) == {"detail": INVALID_REQUEST_DETAIL}
|
||||
assert sensitive_value not in response_text
|
||||
assert sensitive_value[:32] not in response_text
|
||||
assert "input_value" not in response_text
|
||||
@@ -8,31 +8,34 @@ from k1link.ble.wifi_provisioning import (
|
||||
|
||||
|
||||
def test_build_wifi_provisioning_frame_layout() -> None:
|
||||
frame = build_wifi_provisioning_frame("LabNet", "correct horse")
|
||||
credential = "x" * 13
|
||||
frame = build_wifi_provisioning_frame("LabNet", credential)
|
||||
|
||||
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[34:47] == b"x" * 13
|
||||
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("Сеть", "пароль")
|
||||
ssid = "Ж" * 4
|
||||
credential = "я" * 6
|
||||
frame = build_wifi_provisioning_frame(ssid, credential)
|
||||
|
||||
assert frame[0] == len("Сеть".encode())
|
||||
assert frame[33] == len("пароль".encode())
|
||||
assert frame[0] == len(ssid.encode())
|
||||
assert frame[33] == len(credential.encode())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ssid", "password", "message"),
|
||||
[
|
||||
("", "password", "SSID must not be empty"),
|
||||
("", "x" * 8, "SSID must not be empty"),
|
||||
("network", "", "password must not be empty"),
|
||||
("x" * 33, "password", "at most 32 UTF-8 bytes"),
|
||||
("x" * 33, "y" * 8, "at most 32 UTF-8 bytes"),
|
||||
("network", "x" * 65, "at most 64 UTF-8 bytes"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
import k1link.web.xgrids_k1_facade as facade_module
|
||||
from k1link.web.xgrids_k1_facade import (
|
||||
DEFAULT_LIVE_STREAMS,
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
|
||||
AbortAcquisitionRequest,
|
||||
CompatibilityAttestationRequest,
|
||||
ConnectRequest,
|
||||
PrepareAcquisitionRequest,
|
||||
StartAcquisitionRequest,
|
||||
StopAcquisitionRequest,
|
||||
XgridsK1CompatibilityService,
|
||||
)
|
||||
|
||||
ATTESTATION = CompatibilityAttestationRequest(
|
||||
firmware_version="3.0.2",
|
||||
topology="direct-lan",
|
||||
operator_confirmed=True,
|
||||
)
|
||||
PRIMARY_TEST_CREDENTIAL = "x" * 24
|
||||
SECONDARY_TEST_CREDENTIAL = "y" * 24
|
||||
|
||||
|
||||
class FakeVisualizationRuntime:
|
||||
def __init__(self) -> None:
|
||||
self.phase = "idle"
|
||||
self.source_mode = "idle"
|
||||
self.source_ready = False
|
||||
self.pcl_frames = 0
|
||||
self.start_calls: list[tuple[str, Path, float]] = []
|
||||
self.stop_calls = 0
|
||||
self.stop_error: Exception | None = None
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"message": "test runtime",
|
||||
"source_mode": self.source_mode,
|
||||
"source_ready": self.source_ready,
|
||||
"foxglove_ws_url": None,
|
||||
"foxglove_viewer_url": None,
|
||||
"rerun_grpc_url": None,
|
||||
"viewer_settings": {},
|
||||
"metrics": {
|
||||
"messages_received": self.pcl_frames,
|
||||
"payload_bytes": 0,
|
||||
"pcl_frames": self.pcl_frames,
|
||||
"pose_frames": 0,
|
||||
"points_published": 0,
|
||||
"last_point_count": 0,
|
||||
"decode_errors": 0,
|
||||
"preview_dropped": 0,
|
||||
"pcl_fps": 0.0,
|
||||
"pose_fps": 0.0,
|
||||
"mqtt_to_publish_ms": None,
|
||||
"mqtt_to_publish_p50_ms": None,
|
||||
"mqtt_to_publish_p95_ms": None,
|
||||
"decode_publish_ms": None,
|
||||
"trajectory_poses": 0,
|
||||
},
|
||||
}
|
||||
|
||||
def start_live(self, host: str, out_dir: Path, *, duration_seconds: float) -> None:
|
||||
self.start_calls.append((host, out_dir, duration_seconds))
|
||||
self.phase = "starting_live"
|
||||
self.source_mode = "live"
|
||||
|
||||
def mark_ready(self) -> None:
|
||||
self.phase = "live"
|
||||
self.source_ready = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_calls += 1
|
||||
if self.stop_error is not None:
|
||||
raise self.stop_error
|
||||
self.phase = "idle"
|
||||
self.source_mode = "idle"
|
||||
self.source_ready = False
|
||||
|
||||
|
||||
def service_with_fake_runtime(
|
||||
tmp_path: Path,
|
||||
) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]:
|
||||
service = XgridsK1CompatibilityService(tmp_path)
|
||||
runtime = FakeVisualizationRuntime()
|
||||
service.runtime = runtime # type: ignore[assignment]
|
||||
return service, runtime
|
||||
|
||||
|
||||
def test_prepare_creates_provisional_device_session_and_profiled_acquisition(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
|
||||
state = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
duration_seconds=60,
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
assert runtime.start_calls == []
|
||||
assert state["device_ref"]["identity_stability"] == "provisional"
|
||||
assert state["device_ref"]["device_id"] != state["device_session"]["device_session_id"]
|
||||
assert state["acquisition"]["state"] == "prepared"
|
||||
assert state["acquisition"]["compatibility_profile_id"] == (XGRIDS_K1_COMPATIBILITY_PROFILE_ID)
|
||||
assert state["compatibility"]["vendor_writes_enabled"] is False
|
||||
|
||||
|
||||
def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Path) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
duration_seconds=60,
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
|
||||
starting = service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
|
||||
assert starting["acquisition"]["state"] == "starting"
|
||||
assert starting["last_operation"]["status"] == "running"
|
||||
runtime.mark_ready()
|
||||
awaiting = service.state()
|
||||
assert awaiting["acquisition"]["state"] == "awaiting_external_start"
|
||||
assert awaiting["last_operation"]["status"] == "operator_action_required"
|
||||
assert len(runtime.start_calls) == 1
|
||||
|
||||
runtime.pcl_frames = 1
|
||||
acquiring = service.state()
|
||||
|
||||
assert acquiring["acquisition"]["state"] == "acquiring"
|
||||
assert acquiring["last_operation"]["status"] == "succeeded"
|
||||
assert acquiring["last_operation"]["result"]["confirmation"] == "point-frame"
|
||||
|
||||
|
||||
def test_receiver_completion_without_point_data_fails_start_operation(tmp_path: Path) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
duration_seconds=60,
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
|
||||
runtime.phase = "idle"
|
||||
runtime.source_mode = "idle"
|
||||
failed = service.state()
|
||||
|
||||
assert failed["acquisition"]["state"] == "failed"
|
||||
assert failed["last_operation"]["status"] == "failed"
|
||||
assert failed["acquisition"]["result"]["device_state"] == "unknown"
|
||||
|
||||
|
||||
def test_capture_only_stop_never_claims_that_physical_k1_stopped(tmp_path: Path) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
duration_seconds=60,
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
|
||||
stopped = service.stop_acquisition(
|
||||
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="capture-only")
|
||||
)
|
||||
|
||||
assert runtime.stop_calls == 1
|
||||
assert stopped["acquisition"]["state"] == "completed"
|
||||
assert stopped["acquisition"]["result"] == {
|
||||
"receiver_stopped": True,
|
||||
"device_stop": "unknown",
|
||||
}
|
||||
operations = {item["action"]: item for item in stopped["operations"]}
|
||||
assert operations["acquisition.start"]["status"] == "cancelled"
|
||||
assert operations["acquisition.stop"]["status"] == "succeeded"
|
||||
|
||||
|
||||
def test_graceful_stop_waits_for_explicit_operator_confirmation(tmp_path: Path) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
duration_seconds=60,
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
runtime.mark_ready()
|
||||
service.state()
|
||||
runtime.pcl_frames = 1
|
||||
acquiring = service.state()
|
||||
assert acquiring["acquisition"]["state"] == "acquiring"
|
||||
|
||||
awaiting = service.stop_acquisition(
|
||||
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful")
|
||||
)
|
||||
operation_id = awaiting["last_operation"]["operation_id"]
|
||||
|
||||
assert runtime.stop_calls == 0
|
||||
assert awaiting["acquisition"]["state"] == "awaiting_external_stop"
|
||||
assert awaiting["last_operation"]["status"] == "operator_action_required"
|
||||
|
||||
retried = service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
operation_id=operation_id,
|
||||
mode="graceful",
|
||||
)
|
||||
)
|
||||
assert retried["acquisition"]["state"] == "awaiting_external_stop"
|
||||
assert retried["last_operation"]["operation_id"] == operation_id
|
||||
assert retried["last_operation"]["status"] == "operator_action_required"
|
||||
|
||||
with pytest.raises(ValueError, match="исходную stop-operation"):
|
||||
service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
mode="graceful",
|
||||
operator_confirmed=True,
|
||||
)
|
||||
)
|
||||
|
||||
completed = service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
operation_id=operation_id,
|
||||
mode="graceful",
|
||||
operator_confirmed=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert runtime.stop_calls == 1
|
||||
assert completed["acquisition"]["result"]["device_stop"] == "operator-confirmed"
|
||||
stop_operations = [
|
||||
item for item in completed["operations"] if item["action"] == "acquisition.stop"
|
||||
]
|
||||
assert len(stop_operations) == 1
|
||||
assert stop_operations[0]["operation_id"] == operation_id
|
||||
assert stop_operations[0]["status"] == "succeeded"
|
||||
|
||||
|
||||
def test_graceful_stop_retry_by_idempotency_key_reuses_original_operation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
runtime.mark_ready()
|
||||
service.state()
|
||||
runtime.pcl_frames = 1
|
||||
service.state()
|
||||
|
||||
first = service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
idempotency_key="graceful-stop-once",
|
||||
mode="graceful",
|
||||
)
|
||||
)
|
||||
first_operation_id = first["last_operation"]["operation_id"]
|
||||
retried = service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
idempotency_key="graceful-stop-once",
|
||||
mode="graceful",
|
||||
)
|
||||
)
|
||||
|
||||
assert retried["last_operation"]["operation_id"] == first_operation_id
|
||||
assert retried["last_operation"]["status"] == "operator_action_required"
|
||||
assert (
|
||||
len([item for item in retried["operations"] if item["action"] == "acquisition.stop"]) == 1
|
||||
)
|
||||
|
||||
|
||||
def test_unrelated_graceful_stop_is_rejected_while_confirmation_is_pending(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
runtime.mark_ready()
|
||||
service.state()
|
||||
runtime.pcl_frames = 1
|
||||
service.state()
|
||||
first = service.stop_acquisition(
|
||||
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful")
|
||||
)
|
||||
expected_operation_id = first["last_operation"]["operation_id"]
|
||||
|
||||
with pytest.raises(ValueError, match="уже ожидает"):
|
||||
service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
idempotency_key="unrelated-stop",
|
||||
mode="graceful",
|
||||
)
|
||||
)
|
||||
|
||||
original = next(
|
||||
item
|
||||
for item in service.state()["operations"]
|
||||
if item["operation_id"] == expected_operation_id
|
||||
)
|
||||
assert original["status"] == "operator_action_required"
|
||||
|
||||
|
||||
def test_graceful_stop_is_rejected_until_point_data_confirms_acquisition(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
|
||||
with pytest.raises(ValueError, match="подтверждённого потока point cloud"):
|
||||
service.stop_acquisition(
|
||||
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful")
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
assert runtime.stop_calls == 0
|
||||
assert state["acquisition"]["state"] == "starting"
|
||||
assert state["last_operation"]["action"] == "acquisition.start"
|
||||
assert state["last_operation"]["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("evidence_policy", ["best-effort", "disabled"])
|
||||
def test_prepare_rejects_unsupported_evidence_policies(
|
||||
tmp_path: Path,
|
||||
evidence_policy: str,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="evidence_policy=required"):
|
||||
service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
evidence_policy=evidence_policy, # type: ignore[arg-type]
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
assert service.state()["compatibility"]["profile_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"requested_streams",
|
||||
[
|
||||
DEFAULT_LIVE_STREAMS[:-1],
|
||||
(*DEFAULT_LIVE_STREAMS, DEFAULT_LIVE_STREAMS[0]),
|
||||
],
|
||||
)
|
||||
def test_prepare_rejects_stream_subsets_and_duplicates(
|
||||
tmp_path: Path,
|
||||
requested_streams: tuple[str, ...],
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="полный проверенный набор"):
|
||||
service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
requested_streams=requested_streams, # type: ignore[arg-type]
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_exact_profile_is_inactive_until_explicit_operator_attestation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
|
||||
initial = service.state()
|
||||
assert initial["compatibility"] == {
|
||||
"profile_id": None,
|
||||
"decision": "unknown",
|
||||
"permitted_mode": "evidence-only",
|
||||
"firmware_claim": "exact-3.0.2-profile-not-attested",
|
||||
"attestation": None,
|
||||
"vendor_writes_enabled": False,
|
||||
"camera_preview": "unverified",
|
||||
}
|
||||
assert initial["device_calibration"]["compatibility_profile_id"] is None
|
||||
|
||||
attested = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
assert attested["compatibility"]["profile_id"] == XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
assert attested["compatibility"]["decision"] == "limited"
|
||||
assert attested["compatibility"]["attestation"]["basis"] == "operator-attested"
|
||||
assert attested["device_session"]["compatibility_profile_id"] == (
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_rejects_device_ap_fallback_as_direct_lan_target(tmp_path: Path) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="точки доступа"):
|
||||
service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.56.1",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
assert state["device_ref"] is None
|
||||
assert state["compatibility"]["profile_id"] is None
|
||||
|
||||
|
||||
def test_runtime_error_terminalizes_pending_graceful_stop(tmp_path: Path) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
runtime.mark_ready()
|
||||
service.state()
|
||||
runtime.pcl_frames = 1
|
||||
service.state()
|
||||
awaiting = service.stop_acquisition(
|
||||
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful")
|
||||
)
|
||||
stop_operation_id = awaiting["last_operation"]["operation_id"]
|
||||
|
||||
runtime.phase = "error"
|
||||
failed = service.state()
|
||||
|
||||
stop_operation = next(
|
||||
item for item in failed["operations"] if item["operation_id"] == stop_operation_id
|
||||
)
|
||||
assert failed["acquisition"]["state"] == "failed"
|
||||
assert stop_operation["status"] == "failed"
|
||||
assert stop_operation["error"]["side_effect_status"] == "unknown"
|
||||
|
||||
|
||||
def test_receiver_completion_terminalizes_unconfirmed_graceful_stop(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
runtime.mark_ready()
|
||||
service.state()
|
||||
runtime.pcl_frames = 1
|
||||
service.state()
|
||||
awaiting = service.stop_acquisition(
|
||||
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful")
|
||||
)
|
||||
stop_operation_id = awaiting["last_operation"]["operation_id"]
|
||||
|
||||
runtime.phase = "idle"
|
||||
runtime.source_mode = "idle"
|
||||
runtime.source_ready = False
|
||||
failed = service.state()
|
||||
|
||||
stop_operation = next(
|
||||
item for item in failed["operations"] if item["operation_id"] == stop_operation_id
|
||||
)
|
||||
assert failed["acquisition"]["state"] == "failed"
|
||||
assert failed["acquisition"]["result"] == {
|
||||
"receiver_stopped": True,
|
||||
"device_state": "unknown",
|
||||
}
|
||||
assert stop_operation["status"] == "failed"
|
||||
assert stop_operation["error"]["code"] == ("receiver-completed-before-device-stop-confirmation")
|
||||
|
||||
|
||||
def test_abort_failure_terminalizes_acquisition_and_pending_start(tmp_path: Path) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
runtime.stop_error = RuntimeError("synthetic receiver stop failure")
|
||||
|
||||
with pytest.raises(RuntimeError, match="synthetic receiver stop failure"):
|
||||
service.abort_acquisition(AbortAcquisitionRequest(acquisition_id=acquisition_id))
|
||||
|
||||
state = service.state()
|
||||
operations = {item["action"]: item for item in state["operations"]}
|
||||
assert state["acquisition"]["state"] == "failed"
|
||||
assert state["acquisition"]["result"] == {
|
||||
"receiver_stopped": False,
|
||||
"device_state": "unknown",
|
||||
}
|
||||
assert operations["acquisition.start"]["status"] == "cancelled"
|
||||
assert operations["acquisition.abort"]["status"] == "failed"
|
||||
assert state["source_mode"] == "live"
|
||||
|
||||
|
||||
def test_replay_is_rejected_during_nonterminal_acquisition(tmp_path: Path) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="активной acquisition-сессии"):
|
||||
service.start_replay("sessions/fixture.k1mqtt", speed=1.0, loop=False)
|
||||
|
||||
assert service.state()["acquisition"]["state"] == "prepared"
|
||||
|
||||
|
||||
def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_boundary(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [ # noqa: SLF001 - deliberate white-box concurrency fixture
|
||||
{"device_id": "k1-a"},
|
||||
{"device_id": "k1-b"},
|
||||
]
|
||||
boundary_calls: list[tuple[str, str, str]] = []
|
||||
|
||||
async def scenario() -> dict[str, Any]:
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_provision(
|
||||
device_id: str,
|
||||
ssid: str,
|
||||
password: str,
|
||||
**_: object,
|
||||
) -> dict[str, Any]:
|
||||
boundary_calls.append((device_id, ssid, password))
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return {
|
||||
"started_at_utc": "2026-07-16T12:00:00Z",
|
||||
"completed_at_utc": "2026-07-16T12:00:01Z",
|
||||
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||
"outcome": "lan_address_observed",
|
||||
"observations": [{"status": {"ipv4": "192.168.1.20"}}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
|
||||
first = asyncio.create_task(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
await asyncio.wait_for(entered.wait(), timeout=1.0)
|
||||
with pytest.raises(RuntimeError, match="уже выполняется"):
|
||||
await service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-b",
|
||||
ssid="other-network",
|
||||
password=SecretStr(SECONDARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
release.set()
|
||||
return await asyncio.wait_for(first, timeout=1.0)
|
||||
|
||||
connected = asyncio.run(scenario())
|
||||
|
||||
assert boundary_calls == [("k1-a", "lab-network", PRIMARY_TEST_CREDENTIAL)]
|
||||
assert connected["k1_ip"] == "192.168.1.20"
|
||||
assert PRIMARY_TEST_CREDENTIAL not in str(connected)
|
||||
provision_operations = [
|
||||
item for item in connected["operations"] if item["action"] == "network.provision"
|
||||
]
|
||||
assert {item["status"] for item in provision_operations} == {"succeeded", "failed"}
|
||||
|
||||
|
||||
def test_provisioning_cannot_switch_device_during_active_acquisition(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
|
||||
service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
called = False
|
||||
|
||||
async def should_not_run(*_: object, **__: object) -> dict[str, Any]:
|
||||
nonlocal called
|
||||
called = True
|
||||
raise AssertionError("provisioning boundary must not be reached")
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", should_not_run)
|
||||
|
||||
with pytest.raises(RuntimeError, match="активной acquisition-сессии"):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_sensor_catalog_exposes_observed_camera_only_after_profile_attestation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
|
||||
initial = service.state()
|
||||
initial_camera = next(
|
||||
stream
|
||||
for stream in initial["sensor_catalog"]["streams"]
|
||||
if stream["stream_id"] == "camera.preview.live"
|
||||
)
|
||||
assert initial_camera["availability"] == "unverified"
|
||||
|
||||
state = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
camera = next(
|
||||
stream
|
||||
for stream in state["sensor_catalog"]["streams"]
|
||||
if stream["stream_id"] == "camera.preview.live"
|
||||
)
|
||||
|
||||
assert camera["availability"] == "observed"
|
||||
assert camera["modality"] == "encoded-video"
|
||||
assert camera["decode_status"] == "transport-observed-runtime-adapter-pending"
|
||||
assert state["connection_verification"]["network_reachability"] == "unknown"
|
||||
assert state["device_calibration"]["status"] == "unavailable"
|
||||
assert state["device_calibration"]["vehicle_extrinsics"] == ("host-domain-not-owned-by-plugin")
|
||||
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).parents[1]
|
||||
LOADER_PATH = REPOSITORY_ROOT / "plugins" / "xgrids-k1" / "profile_loader.py"
|
||||
|
||||
|
||||
def _load_module() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location("xgrids_k1_profile_loader", LOADER_PATH)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
LOADER = _load_module()
|
||||
|
||||
|
||||
def _by_id(items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
return {item["id"]: item for item in items}
|
||||
|
||||
|
||||
def _boolean_write_flags(value: Any) -> list[bool]:
|
||||
flags: list[bool] = []
|
||||
if isinstance(value, dict):
|
||||
write_enabled = value.get("write_enabled")
|
||||
if isinstance(write_enabled, bool):
|
||||
flags.append(write_enabled)
|
||||
for child in value.values():
|
||||
flags.extend(_boolean_write_flags(child))
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
flags.extend(_boolean_write_flags(child))
|
||||
return flags
|
||||
|
||||
|
||||
def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None:
|
||||
profile = LOADER.load_compatibility_profile()
|
||||
|
||||
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
|
||||
assert profile["scope"]["topology"] == "direct-lan"
|
||||
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
|
||||
assert not LOADER.matches_target(profile, firmware="3.0.3", topology="direct-lan")
|
||||
assert not LOADER.matches_target(profile, firmware="3.0.2", topology="device-ap")
|
||||
|
||||
for source in profile["evidence_sources"]:
|
||||
assert (REPOSITORY_ROOT / source["path"]).is_file()
|
||||
|
||||
|
||||
def test_xgrids_compatibility_profile_keeps_evidence_levels_independent() -> None:
|
||||
profile = LOADER.load_compatibility_profile()
|
||||
channels = _by_id(profile["channels"])
|
||||
|
||||
verified_stream = {
|
||||
"observed": True,
|
||||
"decoded": True,
|
||||
"replay_verified": True,
|
||||
"physical_verified": True,
|
||||
"write_enabled": False,
|
||||
}
|
||||
raw_status = {
|
||||
"observed": True,
|
||||
"decoded": False,
|
||||
"replay_verified": False,
|
||||
"physical_verified": True,
|
||||
"write_enabled": False,
|
||||
}
|
||||
observed_raw = {
|
||||
"observed": True,
|
||||
"decoded": False,
|
||||
"replay_verified": False,
|
||||
"physical_verified": True,
|
||||
"write_enabled": False,
|
||||
}
|
||||
|
||||
assert channels["spatial.point-cloud.live"]["evidence"] == verified_stream
|
||||
assert channels["spatial.pose.live"]["evidence"] == verified_stream
|
||||
assert channels["device.status.live"]["evidence"] == raw_status
|
||||
assert channels["device.heartbeat.live"]["evidence"] == raw_status
|
||||
assert channels["device.status.live"]["semantic_payload"] is None
|
||||
|
||||
camera = channels["camera.preview.live"]
|
||||
assert camera["discovery_status"] == "observed"
|
||||
assert camera["topic"] is None
|
||||
assert set(camera["endpoint_templates"]) == {
|
||||
"rtsp://{confirmed-device-private-ipv4}:8554/live/chn_left_main",
|
||||
"rtsp://{confirmed-device-private-ipv4}:8554/live/chn_right_main",
|
||||
}
|
||||
assert "H.264" in camera["wire_format"]
|
||||
assert camera["evidence"] == observed_raw
|
||||
|
||||
|
||||
def test_xgrids_compatibility_profile_declares_only_reviewed_transports() -> None:
|
||||
profile = LOADER.load_compatibility_profile()
|
||||
transports = _by_id(profile["transports"])
|
||||
|
||||
ble = transports["ble.wifi-bootstrap.fw3.v1"]
|
||||
assert ble["service_uuid"] == "00007f00-0000-1000-8000-00805f9b34fb"
|
||||
assert ble["characteristics"] == {
|
||||
"wifi_request": "00007f01-0000-1000-8000-00805f9b34fb",
|
||||
"wifi_status": "00007f02-0000-1000-8000-00805f9b34fb",
|
||||
}
|
||||
assert ble["request_frame_bytes"] == 99
|
||||
assert ble["evidence"]["observed"] is True
|
||||
assert ble["evidence"]["physical_verified"] is True
|
||||
assert ble["evidence"]["write_enabled"] is False
|
||||
|
||||
mqtt = transports["mqtt.direct-lan.fw3.v1"]
|
||||
assert mqtt["protocol"] == "MQTT 3.1.1"
|
||||
assert mqtt["network"]["transport"] == "TCP"
|
||||
assert mqtt["network"]["port"] == 1883
|
||||
assert mqtt["network"]["tls"] is False
|
||||
assert "lixel/application/report/#" in mqtt["subscription_allowlist"]
|
||||
assert not any("/request/" in topic for topic in mqtt["subscription_allowlist"])
|
||||
assert mqtt["evidence"]["observed"] is True
|
||||
assert mqtt["evidence"]["decoded"] is False
|
||||
assert mqtt["evidence"]["write_enabled"] is False
|
||||
|
||||
rtsp = transports["rtsp.camera-preview.fw3.v1"]
|
||||
assert rtsp["protocol"] == "RTSP 1.0 with interleaved RTP over TCP"
|
||||
assert rtsp["network"]["port"] == 8554
|
||||
assert rtsp["media"]["codec"] == "H.264"
|
||||
assert rtsp["media"]["rtp_payload_type"] == 96
|
||||
assert rtsp["evidence"]["observed"] is True
|
||||
assert rtsp["evidence"]["write_enabled"] is False
|
||||
|
||||
|
||||
def test_xgrids_compatibility_profile_maps_actions_without_enabling_writes() -> None:
|
||||
profile = LOADER.load_compatibility_profile()
|
||||
control = profile["acquisition_control"]
|
||||
actions = _by_id(control["semantic_actions"])
|
||||
|
||||
assert profile["safety"]["default_mode"] == "read-only"
|
||||
assert profile["safety"]["vendor_writes_enabled"] is False
|
||||
assert control["mode"] == "operator-manual"
|
||||
assert control["verified_device_control"]["gesture"] == "physical-double-click"
|
||||
|
||||
for action_id, action_code in (("acquisition.start", 1), ("acquisition.stop", 2)):
|
||||
action = actions[action_id]
|
||||
mapping = action["vendor_request_mapping"]
|
||||
assert action["execution"] == "operator-manual"
|
||||
assert mapping["evidence_kind"] == "owner-controlled-wire-observation"
|
||||
assert mapping["topic"] == "lixel/application/request/modeling"
|
||||
assert mapping["qos"] == 2
|
||||
assert mapping["message_type"] == "ModelingRequest"
|
||||
assert mapping["action_field_value"] == action_code
|
||||
assert mapping["required_unresolved_context"]
|
||||
assert mapping["evidence"]["observed"] is True
|
||||
assert mapping["evidence"]["decoded"] is True
|
||||
assert mapping["evidence"]["physical_verified"] is True
|
||||
assert mapping["evidence"]["replay_verified"] is False
|
||||
assert mapping["write_enabled"] is False
|
||||
|
||||
calibration = actions["calibration.device.start"]
|
||||
assert calibration["execution"] == "unavailable"
|
||||
assert calibration["vendor_request_mapping"] is None
|
||||
assert calibration["evidence"]["observed"] is False
|
||||
assert _boolean_write_flags(profile)
|
||||
assert not any(_boolean_write_flags(profile))
|
||||
|
||||
|
||||
def test_xgrids_compatibility_profile_rejects_vendor_write_promotion() -> None:
|
||||
profile = LOADER.load_compatibility_profile()
|
||||
modified = copy.deepcopy(profile)
|
||||
actions = _by_id(modified["acquisition_control"]["semantic_actions"])
|
||||
actions["acquisition.start"]["vendor_request_mapping"]["write_enabled"] = True
|
||||
|
||||
with pytest.raises(LOADER.CompatibilityProfileError, match="must remain false"):
|
||||
LOADER.validate_compatibility_profile(modified)
|
||||
|
||||
|
||||
def test_xgrids_compatibility_profile_rejects_claimed_camera_endpoint() -> None:
|
||||
profile = LOADER.load_compatibility_profile()
|
||||
modified = copy.deepcopy(profile)
|
||||
channels = _by_id(modified["channels"])
|
||||
channels["camera.preview.live"]["endpoint_templates"].append("rtsp://unverified")
|
||||
|
||||
with pytest.raises(LOADER.CompatibilityProfileError, match="camera endpoint templates"):
|
||||
LOADER.validate_compatibility_profile(modified)
|
||||
|
||||
|
||||
def test_xgrids_compatibility_profile_rejects_duplicate_json_keys(tmp_path: Path) -> None:
|
||||
profile_path = tmp_path / "duplicate.json"
|
||||
profile_path.write_text('{"schema_version": 1, "schema_version": 1}', encoding="utf-8")
|
||||
|
||||
with pytest.raises(LOADER.CompatibilityProfileError, match="duplicate JSON key"):
|
||||
LOADER.load_compatibility_profile(profile_path)
|
||||
Reference in New Issue
Block a user