400 lines
13 KiB
Python
400 lines
13 KiB
Python
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
|