from __future__ import annotations import hashlib from collections import deque from types import SimpleNamespace from typing import Any, cast import paho.mqtt.client as mqtt import pytest from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( DEVICE_INFO_RESPONSE_TOPIC, GET_RTK_ADVANCE_REQUEST_TOPIC, GET_RTK_ADVANCE_RESPONSE_TOPIC, MODELING_STATUS_RESPONSE_TOPIC, LiveDeviceControlBinding, ) from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import ( CONTROL_SUBSCRIPTION_GROUPS, MODELING_RESPONSE_TOPIC, SYSTEM_ERROR_TOPIC, ApplicationCommandOutcomeUnknown, ApplicationControlDeviceFault, ReviewedApplicationMqttTransport, ) from k1link.device_plugins.xgrids_k1.protocol.application_publish import ( OneShotPublishEnvelope, ) from k1link.device_plugins.xgrids_k1.protocol.modeling_control import ( MODELING_STATE_BASE, SYSTEM_ERROR_STATE_BASE, ModelingAction, SessionState, ) from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import ( DEVICE_STATUS_TOPIC, MODELING_REQUEST_TOPIC, ) VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" DEVICE_SERIAL = "K1SERIAL01" APPLICATION_KEY = "11111111-2222-3333-4444-555555555555" class FakeReasonCode: def __init__(self, *, failure: bool = False) -> None: self.is_failure = failure class FakeClock: def __init__(self) -> None: self.now = 100.0 def __call__(self) -> float: return self.now class FakeControlClient: def __init__( self, *, emit_exchange: bool = True, clock: FakeClock | None = None, response_topic: str = DEVICE_INFO_RESPONSE_TOPIC, ) -> 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.emit_exchange = emit_exchange self.clock = clock self.response_topic = response_topic 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.disconnect_calls = 0 self.next_mid = 20 self.connect_timeout = 5.0 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) mid = 6 + len(self.subscribe_calls) self.events.append(("subscribe", mid)) return mqtt.MQTT_ERR_SUCCESS, mid 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 if self.emit_exchange: self.events.append(("publish", mid)) self.events.append(("message", (self.response_topic, payload))) return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid) def loop(self, timeout: float) -> mqtt.MQTTErrorCode: del timeout if self.clock is not None: self.clock.now += 1.0 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": group = CONTROL_SUBSCRIPTION_GROUPS[int(value) - 7] self.on_subscribe( self, None, value, [FakeReasonCode() for _item in group], 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: self.disconnect_calls += 1 return mqtt.MQTT_ERR_SUCCESS def _application_message( session_id: str, *, device_id: str | None = None, ) -> bytes: header = b"".join( ( _text(4, device_id) if device_id is not None else b"", _text(5, session_id), _text(6, APPLICATION_KEY), ) ) return _bytes(1, header) def _envelope( operation_key: str = "bootstrap:1:DeviceInfoRequest", *, topic: str = "lixel/application/request/device_info", session_id: str = ":DeviceInfoRequest", device_id: str | None = None, ) -> OneShotPublishEnvelope: payload = _application_message(session_id, device_id=device_id) return OneShotPublishEnvelope( operation_key=operation_key, topic=topic, payload=payload, payload_sha256=hashlib.sha256(payload).hexdigest(), payload_bytes=len(payload), qos=2, retain=False, ) def _modeling_status_envelope( operation_key: str = "dialogue:12:ModelingStatusRequest", ) -> OneShotPublishEnvelope: payload = _application_message( f"{VENDOR_DEVICE_ID}:ModelingStatusRequest", device_id=VENDOR_DEVICE_ID, ) return OneShotPublishEnvelope( operation_key=operation_key, topic="lixel/application/request/modeling_status", payload=payload, payload_sha256=hashlib.sha256(payload).hexdigest(), payload_bytes=len(payload), qos=2, retain=False, ) def _modeling_envelope(action: ModelingAction) -> OneShotPublishEnvelope: payload = _application_message( f"{VENDOR_DEVICE_ID}:ModelingRequest", device_id=VENDOR_DEVICE_ID, ) + _uint(2, action) return OneShotPublishEnvelope( operation_key=f"modeling:{action.name.casefold()}", topic=MODELING_REQUEST_TOPIC, payload=payload, payload_sha256=hashlib.sha256(payload).hexdigest(), payload_bytes=len(payload), qos=2, retain=False, ) def _modeling_response(action: ModelingAction) -> bytes: return ( _application_message( f"{VENDOR_DEVICE_ID}:ModelingRequest", device_id=VENDOR_DEVICE_ID, ) + _uint(2, action) + _bytes(15, _uint(1, 0)) ) 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()) def _device_status(state: SessionState, *, project_bound: bool, init_ready: bool) -> bytes: header = _text(4, VENDOR_DEVICE_ID) fields = [ _bytes(1, header), _uint(2, MODELING_STATE_BASE + state), _text(3, DEVICE_SERIAL), ] if project_bound: fields.append(_text(4, "project-present")) if init_ready: fields.append(_uint(6, 1)) return b"".join(fields) def _system_error(state: SessionState) -> bytes: return _bytes(15, _uint(1, SYSTEM_ERROR_STATE_BASE + state)) def _binding() -> LiveDeviceControlBinding: return LiveDeviceControlBinding( vendor_device_id=VENDOR_DEVICE_ID, device_serial=DEVICE_SERIAL, software_version="V3.0.2-20260101-release", system_version="V3.0.2", device_model="LixelKity K1", device_type="A4", is_activated=True, ) def test_acceptance_transport_rejects_the_k1_access_point_fallback() -> None: with pytest.raises(ValueError, match="not a direct-LAN"): ReviewedApplicationMqttTransport("192.168.56.1") def test_acceptance_transport_admits_the_k1_ap_only_with_an_explicit_gate() -> None: transport = ReviewedApplicationMqttTransport( "192.168.56.1", allow_device_ap=True, ) assert transport.snapshot().state == "new" def test_retained_control_subscription_batches_remain_exact_and_separate_from_points() -> None: assert [len(group) for group in CONTROL_SUBSCRIPTION_GROUPS] == [9, 5, 42] assert CONTROL_SUBSCRIPTION_GROUPS[0][-1] == (DEVICE_INFO_RESPONSE_TOPIC, 0) assert ("lixel/application/response/modeling", 2) in CONTROL_SUBSCRIPTION_GROUPS[2] assert all( topic not in {"RealtimePointcloud", "lixel/application/report/lio_pcl"} for group in CONTROL_SUBSCRIPTION_GROUPS for topic, _qos in group ) def test_acceptance_transport_connects_once_and_completes_one_qos2_exchange() -> None: fake = FakeControlClient() transport = ReviewedApplicationMqttTransport( "192.168.1.20", client_factory=lambda: cast(mqtt.Client, fake), ) opened = transport.open().as_dict() responses = transport.exchange_batch_once( [_envelope()], required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"}, ) transport.close() assert fake.connect_calls == [("192.168.1.20", 1883, 60)] assert fake.connect_timeout == 10.0 assert fake.subscribe_calls == [list(group) for group in CONTROL_SUBSCRIPTION_GROUPS] assert fake.publish_calls == [ ( "lixel/application/request/device_info", _application_message(":DeviceInfoRequest"), 2, False, ) ] assert responses == { "bootstrap:1:DeviceInfoRequest": _application_message(":DeviceInfoRequest") } assert opened["clean_session"] is False assert opened["automatic_reconnect"] is False assert opened["automatic_retry"] is False snapshot = transport.snapshot().as_dict() assert snapshot["state"] == "closed" assert snapshot["publish_attempts"] == 1 assert snapshot["subscribe_attempts"] == 3 assert snapshot["qos2_completions"] == 1 assert snapshot["correlated_responses"] == 1 assert snapshot["operation_keys_consumed"] == 1 def test_consumed_operation_key_can_never_be_published_again() -> None: fake = FakeControlClient() transport = ReviewedApplicationMqttTransport( "192.168.1.20", client_factory=lambda: cast(mqtt.Client, fake), ) transport.open() transport.exchange_batch_once( [_envelope()], required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"}, ) with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"): transport.exchange_batch_once( [_envelope()], required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"}, ) assert len(fake.publish_calls) == 1 def test_same_topic_inflight_responses_are_routed_by_exact_session_without_retry() -> None: fake = FakeControlClient(response_topic=GET_RTK_ADVANCE_RESPONSE_TOPIC) transport = ReviewedApplicationMqttTransport( "192.168.1.20", client_factory=lambda: cast(mqtt.Client, fake), ) transport.open() unbound = _envelope( "bootstrap:3:GetRtkAdvanceRequest", topic=GET_RTK_ADVANCE_REQUEST_TOPIC, session_id=":GetRtkAdvanceRequest", ) bound_session = f"{VENDOR_DEVICE_ID}:GetRtkAdvanceRequest" bound = _envelope( "bootstrap:6:GetRtkAdvanceRequest", topic=GET_RTK_ADVANCE_REQUEST_TOPIC, session_id=bound_session, device_id=VENDOR_DEVICE_ID, ) responses = transport.exchange_batch_once( [unbound, bound], required_response_operation_keys={unbound.operation_key, bound.operation_key}, ) assert responses[unbound.operation_key] == _application_message( ":GetRtkAdvanceRequest" ) assert responses[bound.operation_key] == _application_message( bound_session, device_id=VENDOR_DEVICE_ID, ) snapshot = transport.snapshot() assert snapshot.publish_attempts == 2 assert snapshot.qos2_completions == 2 assert snapshot.correlated_responses == 2 assert snapshot.late_known_responses == 0 assert snapshot.automatic_retry is False assert len(fake.publish_calls) == 2 def test_duplicate_start_response_blocks_stop_before_stop_is_published() -> None: class ModelingControlClient(FakeControlClient): def __init__(self) -> None: super().__init__( emit_exchange=False, response_topic=MODELING_RESPONSE_TOPIC, ) def publish( self, topic: str, payload: bytes, qos: int, retain: bool, ) -> SimpleNamespace: info = super().publish(topic, payload, qos, retain) action = ( ModelingAction.START if len(self.publish_calls) == 1 else ModelingAction.STOP ) self.events.append(("publish", info.mid)) self.events.append( ( "message", (MODELING_RESPONSE_TOPIC, _modeling_response(action)), ) ) return info fake = ModelingControlClient() transport = ReviewedApplicationMqttTransport( "192.168.1.20", client_factory=lambda: cast(mqtt.Client, fake), ) transport.open() transport.exchange_batch_once( [_modeling_envelope(ModelingAction.START)], required_response_operation_keys={"modeling:start"}, ) fake.events.append( ( "message", ( MODELING_RESPONSE_TOPIC, _modeling_response(ModelingAction.START), ), ) ) with pytest.raises(ApplicationCommandOutcomeUnknown, match="duplicate application response"): transport.exchange_batch_once( [_modeling_envelope(ModelingAction.STOP)], required_response_operation_keys={"modeling:stop"}, ) snapshot = transport.snapshot() assert snapshot.publish_attempts == 1 assert snapshot.correlated_responses == 1 assert snapshot.late_known_responses == 0 assert snapshot.state == "poisoned" assert len(fake.publish_calls) == 1 def test_unanswered_optional_status_does_not_steal_same_identity_required_refresh() -> None: class ReusedStatusClient(FakeControlClient): def __init__(self) -> None: super().__init__( emit_exchange=False, response_topic=MODELING_STATUS_RESPONSE_TOPIC, ) def publish( self, topic: str, payload: bytes, qos: int, retain: bool, ) -> SimpleNamespace: info = super().publish(topic, payload, qos, retain) self.events.append(("publish", info.mid)) if len(self.publish_calls) == 2: # Ordinals 12 and 14 intentionally reuse the same protocol # identity. The capture has only one response after ordinal # 14; unanswered optional ordinal 12 must not swallow it. self.events.append( ("message", (MODELING_STATUS_RESPONSE_TOPIC, payload)) ) return info fake = ReusedStatusClient() transport = ReviewedApplicationMqttTransport( "192.168.1.20", client_factory=lambda: cast(mqtt.Client, fake), ) transport.open() immediate = _modeling_status_envelope() refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest") transport.exchange_batch_once( [immediate], required_response_operation_keys=(), ) responses = transport.exchange_batch_once( [refresh], required_response_operation_keys={refresh.operation_key}, ) assert responses[refresh.operation_key] == refresh.payload snapshot = transport.snapshot() assert snapshot.publish_attempts == 2 assert snapshot.correlated_responses == 1 assert snapshot.ignored_known_responses == 0 assert snapshot.late_known_responses == 0 def test_already_arrived_optional_status_is_consumed_before_required_refresh() -> None: class ReusedStatusClient(FakeControlClient): def __init__(self) -> None: super().__init__( emit_exchange=False, response_topic=MODELING_STATUS_RESPONSE_TOPIC, ) def publish( self, topic: str, payload: bytes, qos: int, retain: bool, ) -> SimpleNamespace: info = super().publish(topic, payload, qos, retain) self.events.append(("publish", info.mid)) if len(self.publish_calls) == 2: self.events.append( ("message", (MODELING_STATUS_RESPONSE_TOPIC, payload)) ) return info fake = ReusedStatusClient() transport = ReviewedApplicationMqttTransport( "192.168.1.20", client_factory=lambda: cast(mqtt.Client, fake), ) transport.open() immediate = _modeling_status_envelope() refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest") transport.exchange_batch_once( [immediate], required_response_operation_keys=(), ) # This callback is already available before ordinal 14 is admitted. The # retained socket's zero-wait service turn must consume it as ordinal 12. fake.events.append( ("message", (MODELING_STATUS_RESPONSE_TOPIC, immediate.payload)) ) responses = transport.exchange_batch_once( [refresh], required_response_operation_keys={refresh.operation_key}, ) assert responses[refresh.operation_key] == refresh.payload snapshot = transport.snapshot() assert snapshot.publish_attempts == 2 assert snapshot.correlated_responses == 1 assert snapshot.ignored_known_responses == 1 assert snapshot.late_known_responses == 1 def test_unbound_optional_status_remains_distinct_from_bound_required_refresh() -> None: class DistinctStatusClient(FakeControlClient): def __init__(self) -> None: super().__init__( emit_exchange=False, response_topic=MODELING_STATUS_RESPONSE_TOPIC, ) def publish( self, topic: str, payload: bytes, qos: int, retain: bool, ) -> SimpleNamespace: info = super().publish(topic, payload, qos, retain) self.events.append(("publish", info.mid)) if len(self.publish_calls) == 2: self.events.append( ( "message", ( MODELING_STATUS_RESPONSE_TOPIC, _application_message(":ModelingStatusRequest"), ), ) ) self.events.append( ("message", (MODELING_STATUS_RESPONSE_TOPIC, payload)) ) return info fake = DistinctStatusClient() transport = ReviewedApplicationMqttTransport( "192.168.1.20", client_factory=lambda: cast(mqtt.Client, fake), ) transport.open() initial = _envelope( "bootstrap:2:ModelingStatusRequest", topic="lixel/application/request/modeling_status", session_id=":ModelingStatusRequest", ) refresh = _modeling_status_envelope("dialogue:14:ModelingStatusRequest") transport.exchange_batch_once( [initial], required_response_operation_keys=(), ) responses = transport.exchange_batch_once( [refresh], required_response_operation_keys={refresh.operation_key}, ) assert responses[refresh.operation_key] == refresh.payload snapshot = transport.snapshot() assert snapshot.publish_attempts == 2 assert snapshot.correlated_responses == 1 assert snapshot.ignored_known_responses == 1 assert snapshot.late_known_responses == 1 def test_duplicate_current_application_response_fails_closed() -> None: class DuplicateResponseClient(FakeControlClient): def publish( self, topic: str, payload: bytes, qos: int, retain: bool, ) -> SimpleNamespace: info = super().publish(topic, payload, qos, retain) self.events.append(("message", (self.response_topic, payload))) return info fake = DuplicateResponseClient() transport = ReviewedApplicationMqttTransport( "192.168.1.20", client_factory=lambda: cast(mqtt.Client, fake), ) transport.open() with pytest.raises( ApplicationCommandOutcomeUnknown, match="duplicate application response", ) as raised: transport.exchange_batch_once( [_envelope()], required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"}, ) assert raised.value.reason_code == "duplicate_application_response" assert transport.snapshot().state == "poisoned" assert len(fake.publish_calls) == 1 def test_post_start_status_can_be_response_free_and_socket_is_continuously_serviced() -> None: clock = FakeClock() fake = FakeControlClient( clock=clock, response_topic=MODELING_STATUS_RESPONSE_TOPIC, ) transport = ReviewedApplicationMqttTransport( "192.168.1.20", client_factory=lambda: cast(mqtt.Client, fake), monotonic=clock, ) transport.open() responses = transport.exchange_batch_once( [_modeling_status_envelope()], required_response_operation_keys=(), ) transport.maintain_open_for( 2.0, allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC}, ) assert responses == {} assert transport.snapshot().state == "ready" assert transport.snapshot().qos2_completions == 1 assert transport.snapshot().ignored_known_responses == 1 def test_control_owner_uses_live_state_gates_and_surfaces_system_error() -> None: clock = FakeClock() fake = FakeControlClient(clock=clock) transport = ReviewedApplicationMqttTransport( "192.168.1.20", client_factory=lambda: cast(mqtt.Client, fake), monotonic=clock, ) transport.open() fake.events.append( ( "message", ( DEVICE_STATUS_TOPIC, _device_status( SessionState.READY, project_bound=False, init_ready=False, ), ), ) ) transport.maintain_open_for(1.0) assert transport.pre_start_ready(_binding()) is True fake.events.append( ( "message", ( DEVICE_STATUS_TOPIC, _device_status( SessionState.SCANNING, project_bound=True, init_ready=True, ), ), ) ) transport.maintain_open_for(1.0) assert transport.scan_initialization_complete(_binding()) is True assert transport.pre_start_ready(_binding()) is False assert transport.standby_complete(_binding()) is False fake.events.append( ( "message", (SYSTEM_ERROR_TOPIC, _system_error(SessionState.ALGORITHM_ERROR)), ) ) transport.maintain_open_for(1.0) snapshot = transport.snapshot().as_dict() assert snapshot["device_status_reports"] == 2 assert snapshot["system_error_reports"] == 1 assert snapshot["latest_system_error_code"] == 0x32040133 assert snapshot["latest_system_error_state"] == "algorithm_error" with pytest.raises(ApplicationControlDeviceFault, match="algorithm_error"): transport.scan_initialization_complete(_binding()) def test_post_publish_timeout_poisoned_transport_never_retries() -> None: clock = FakeClock() fake = FakeControlClient(emit_exchange=False, clock=clock) transport = ReviewedApplicationMqttTransport( "192.168.1.20", exchange_timeout_seconds=2.0, client_factory=lambda: cast(mqtt.Client, fake), monotonic=clock, ) transport.open() with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"): transport.exchange_batch_once( [_envelope()], required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"}, ) assert transport.snapshot().state == "poisoned" assert len(fake.publish_calls) == 1 with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"): transport.exchange_batch_once( [_envelope()], required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"}, ) assert len(fake.publish_calls) == 1