fix(k1): gate control dialogue on live state

This commit is contained in:
DCCONSTRUCTIONS
2026-07-18 17:19:21 +03:00
parent a29b38a0d7
commit 36f0c93d2a
14 changed files with 1435 additions and 122 deletions
+176 -2
View File
@@ -10,15 +10,28 @@ 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:
@@ -35,7 +48,13 @@ class FakeClock:
class FakeControlClient:
def __init__(self, *, emit_exchange: bool = True, clock: FakeClock | None = None) -> None:
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
@@ -43,6 +62,7 @@ class FakeControlClient:
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]]] = []
@@ -74,7 +94,7 @@ class FakeControlClient:
self.next_mid += 1
if self.emit_exchange:
self.events.append(("publish", mid))
self.events.append(("message", (DEVICE_INFO_RESPONSE_TOPIC, b"response")))
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:
@@ -124,6 +144,70 @@ def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPu
)
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")
@@ -198,6 +282,96 @@ def test_consumed_operation_key_can_never_be_published_again() -> None:
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)