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, MODELING_STATUS_RESPONSE_TOPIC, LiveDeviceControlBinding, ) from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import ( CONTROL_SUBSCRIPTION_GROUPS, 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, SessionState, ) from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import DEVICE_STATUS_TOPIC VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" DEVICE_SERIAL = "K1SERIAL01" 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 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, b"response"))) 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 _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPublishEnvelope: payload = b"synthetic-reviewed-request" return OneShotPublishEnvelope( operation_key=operation_key, topic="lixel/application/request/device_info", payload=payload, payload_sha256=hashlib.sha256(payload).hexdigest(), payload_bytes=len(payload), qos=2, retain=False, ) def _modeling_status_envelope() -> OneShotPublishEnvelope: payload = b"synthetic-reviewed-modeling-status" return OneShotPublishEnvelope( operation_key="dialogue:12:ModelingStatusRequest", topic="lixel/application/request/modeling_status", payload=payload, payload_sha256=hashlib.sha256(payload).hexdigest(), payload_bytes=len(payload), qos=2, retain=False, ) 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="K1", 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_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_topics={DEVICE_INFO_RESPONSE_TOPIC}, ) transport.close() assert fake.connect_calls == [("192.168.1.20", 1883, 60)] assert fake.subscribe_calls == [list(group) for group in CONTROL_SUBSCRIPTION_GROUPS] assert fake.publish_calls == [ ( "lixel/application/request/device_info", b"synthetic-reviewed-request", 2, False, ) ] assert responses == {DEVICE_INFO_RESPONSE_TOPIC: b"response"} 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_topics={DEVICE_INFO_RESPONSE_TOPIC}, ) with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"): transport.exchange_batch_once( [_envelope()], required_response_topics={DEVICE_INFO_RESPONSE_TOPIC}, ) 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_topics=(), ) 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_topics={DEVICE_INFO_RESPONSE_TOPIC}, ) 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_topics={DEVICE_INFO_RESPONSE_TOPIC}, ) assert len(fake.publish_calls) == 1