feat(k1): complete canonical control lifecycle
This commit is contained in:
@@ -10,11 +10,14 @@ 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,
|
||||
@@ -26,12 +29,17 @@ from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
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
|
||||
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:
|
||||
@@ -70,6 +78,7 @@ class FakeControlClient:
|
||||
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))
|
||||
@@ -94,7 +103,7 @@ class FakeControlClient:
|
||||
self.next_mid += 1
|
||||
if self.emit_exchange:
|
||||
self.events.append(("publish", mid))
|
||||
self.events.append(("message", (self.response_topic, b"response")))
|
||||
self.events.append(("message", (self.response_topic, payload)))
|
||||
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
|
||||
|
||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||
@@ -131,11 +140,32 @@ class FakeControlClient:
|
||||
return mqtt.MQTT_ERR_SUCCESS
|
||||
|
||||
|
||||
def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPublishEnvelope:
|
||||
payload = b"synthetic-reviewed-request"
|
||||
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="lixel/application/request/device_info",
|
||||
topic=topic,
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
@@ -144,10 +174,15 @@ def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPu
|
||||
)
|
||||
|
||||
|
||||
def _modeling_status_envelope() -> OneShotPublishEnvelope:
|
||||
payload = b"synthetic-reviewed-modeling-status"
|
||||
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="dialogue:12:ModelingStatusRequest",
|
||||
operation_key=operation_key,
|
||||
topic="lixel/application/request/modeling_status",
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
@@ -157,6 +192,33 @@ def _modeling_status_envelope() -> OneShotPublishEnvelope:
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
@@ -203,7 +265,7 @@ def _binding() -> LiveDeviceControlBinding:
|
||||
software_version="V3.0.2-20260101-release",
|
||||
system_version="V3.0.2",
|
||||
device_model="LixelKity K1",
|
||||
device_type="K1",
|
||||
device_type="A4",
|
||||
is_activated=True,
|
||||
)
|
||||
|
||||
@@ -234,21 +296,24 @@ def test_acceptance_transport_connects_once_and_completes_one_qos2_exchange() ->
|
||||
opened = transport.open().as_dict()
|
||||
responses = transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
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",
|
||||
b"synthetic-reviewed-request",
|
||||
_application_message(":DeviceInfoRequest"),
|
||||
2,
|
||||
False,
|
||||
)
|
||||
]
|
||||
assert responses == {DEVICE_INFO_RESPONSE_TOPIC: b"response"}
|
||||
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
|
||||
@@ -270,18 +335,325 @@ def test_consumed_operation_key_can_never_be_published_again() -> None:
|
||||
transport.open()
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
)
|
||||
|
||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
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(
|
||||
@@ -297,7 +669,7 @@ def test_post_start_status_can_be_response_free_and_socket_is_continuously_servi
|
||||
|
||||
responses = transport.exchange_batch_once(
|
||||
[_modeling_status_envelope()],
|
||||
required_response_topics=(),
|
||||
required_response_operation_keys=(),
|
||||
)
|
||||
transport.maintain_open_for(
|
||||
2.0,
|
||||
@@ -386,7 +758,7 @@ def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"):
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
)
|
||||
|
||||
assert transport.snapshot().state == "poisoned"
|
||||
@@ -394,6 +766,6 @@ def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
||||
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
)
|
||||
assert len(fake.publish_calls) == 1
|
||||
|
||||
Reference in New Issue
Block a user