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, ) from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import ( CONTROL_SUBSCRIPTION_GROUPS, ApplicationCommandOutcomeUnknown, ReviewedApplicationMqttTransport, ) from k1link.device_plugins.xgrids_k1.protocol.application_publish import ( OneShotPublishEnvelope, ) 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) -> 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.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", (DEVICE_INFO_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 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_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