fix(k1): gate control dialogue on live state
This commit is contained in:
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||
ApplicationAcceptanceError,
|
||||
OperatorDialogueCheckpoint,
|
||||
PhysicalAcceptanceChecklist,
|
||||
PhysicalAcceptanceDialogueExecutor,
|
||||
PhysicalAcceptancePermit,
|
||||
@@ -15,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
ApplicationControlAuthority,
|
||||
ShadowApplicationBootstrapOrchestrator,
|
||||
build_canonical_post_start_observation,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||
MODELING_RESPONSE_TOPIC,
|
||||
@@ -91,8 +93,15 @@ def _modeling_response(action: ModelingAction) -> bytes:
|
||||
|
||||
|
||||
class SyntheticAcceptanceTransport:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, clock: FakeClock | None = None) -> None:
|
||||
self.batches: list[tuple[str, ...]] = []
|
||||
self.maintain_calls: list[tuple[float, tuple[str, ...]]] = []
|
||||
self.clock = clock
|
||||
self.pre_start_maintain_calls = 0
|
||||
self.active_maintain_calls = 0
|
||||
self.post_stop_maintain_calls = 0
|
||||
self.start_emitted = False
|
||||
self.stop_emitted = False
|
||||
|
||||
def exchange_batch_once(
|
||||
self,
|
||||
@@ -109,11 +118,15 @@ class SyntheticAcceptanceTransport:
|
||||
else ModelingAction.STOP
|
||||
)
|
||||
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
|
||||
if action is ModelingAction.START:
|
||||
self.start_emitted = True
|
||||
else:
|
||||
self.stop_emitted = True
|
||||
return responses
|
||||
|
||||
for envelope in envelopes:
|
||||
prefix, ordinal_text, message_type = envelope.operation_key.split(":", 2)
|
||||
assert prefix == "bootstrap"
|
||||
assert prefix in {"bootstrap", "dialogue"}
|
||||
ordinal = int(ordinal_text)
|
||||
if message_type == "DeviceInfoRequest":
|
||||
session = (
|
||||
@@ -134,6 +147,36 @@ class SyntheticAcceptanceTransport:
|
||||
responses[topic] = response
|
||||
return responses
|
||||
|
||||
def maintain_open_for(
|
||||
self,
|
||||
duration_seconds: float,
|
||||
*,
|
||||
allowed_response_topics: Collection[str] = (),
|
||||
) -> None:
|
||||
self.maintain_calls.append(
|
||||
(duration_seconds, tuple(sorted(allowed_response_topics)))
|
||||
)
|
||||
if self.clock is not None:
|
||||
self.clock.now += duration_seconds
|
||||
if self.stop_emitted:
|
||||
self.post_stop_maintain_calls += 1
|
||||
elif self.start_emitted:
|
||||
self.active_maintain_calls += 1
|
||||
else:
|
||||
self.pre_start_maintain_calls += 1
|
||||
|
||||
def scan_initialization_complete(self, _binding: object) -> bool:
|
||||
return self.active_maintain_calls >= 2
|
||||
|
||||
def pre_start_ready(self, _binding: object) -> bool:
|
||||
return not self.start_emitted
|
||||
|
||||
def standby_complete(self, _binding: object) -> bool:
|
||||
return self.stop_emitted and self.post_stop_maintain_calls >= 2
|
||||
|
||||
def validate_bound_status(self, _binding: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _response_suffix(message_type: str) -> str:
|
||||
values = {
|
||||
@@ -158,17 +201,56 @@ def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist:
|
||||
)
|
||||
|
||||
|
||||
def test_start_acceptance_requires_full_bootstrap_and_consumes_one_short_permit() -> None:
|
||||
transport = SyntheticAcceptanceTransport()
|
||||
permit = PhysicalAcceptancePermit(_checklist(ModelingAction.START))
|
||||
executor = PhysicalAcceptanceDialogueExecutor(transport, permit)
|
||||
class FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 100.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> None:
|
||||
clock = FakeClock()
|
||||
transport = SyntheticAcceptanceTransport(clock)
|
||||
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
binding = executor.run_bootstrap(orchestrator)
|
||||
with pytest.raises(ApplicationAcceptanceError, match="collapsed bootstrap is disabled"):
|
||||
executor.run_bootstrap(orchestrator)
|
||||
|
||||
binding = executor.run_connection_stage(orchestrator)
|
||||
with pytest.raises(ApplicationAcceptanceError, match="not issued by this canonical session"):
|
||||
executor.run_workspace_entry_stage(
|
||||
orchestrator,
|
||||
OperatorDialogueCheckpoint(
|
||||
event="workspace-entered",
|
||||
operator_initiated=True,
|
||||
owner_token=object(),
|
||||
),
|
||||
)
|
||||
assert [len(batch) for batch in transport.batches] == [1, 2, 3]
|
||||
workspace_checks = iter((False, False, True))
|
||||
workspace_checkpoint = executor.wait_for_operator_checkpoint(
|
||||
"workspace-entered",
|
||||
lambda: next(workspace_checks),
|
||||
)
|
||||
executor.run_workspace_entry_stage(
|
||||
orchestrator,
|
||||
workspace_checkpoint,
|
||||
)
|
||||
project_checks = iter((False, False, True))
|
||||
project_checkpoint = executor.wait_for_operator_checkpoint(
|
||||
"project-prompt-opened",
|
||||
lambda: next(project_checks),
|
||||
)
|
||||
executor.run_project_prompt_stage(
|
||||
orchestrator,
|
||||
project_checkpoint,
|
||||
)
|
||||
command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_start(
|
||||
CommandHeaderIdentity(
|
||||
@@ -181,23 +263,82 @@ def test_start_acceptance_requires_full_bootstrap_and_consumes_one_short_permit(
|
||||
mount_type=MountType.HANDHELD,
|
||||
)
|
||||
)
|
||||
response = executor.execute_modeling(command)
|
||||
with pytest.raises(ApplicationAcceptanceError, match="standalone modeling commands"):
|
||||
executor.execute_modeling(command)
|
||||
|
||||
start_wait_polls = 0
|
||||
|
||||
def start_confirmed() -> bool:
|
||||
nonlocal start_wait_polls
|
||||
start_wait_polls += 1
|
||||
return start_wait_polls > 202
|
||||
|
||||
start_checkpoint = executor.wait_for_operator_checkpoint(
|
||||
"start-confirmed",
|
||||
start_confirmed,
|
||||
)
|
||||
# Human/UI time is serviced on the socket and precedes the command permit.
|
||||
start_permit = PhysicalAcceptancePermit(
|
||||
_checklist(ModelingAction.START),
|
||||
ttl_seconds=120.0,
|
||||
monotonic=clock,
|
||||
)
|
||||
post_start = build_canonical_post_start_observation(
|
||||
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||
binding,
|
||||
)
|
||||
response = executor.execute_canonical_start(
|
||||
command,
|
||||
post_start,
|
||||
authority=ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||
binding=binding,
|
||||
permit=start_permit,
|
||||
checkpoint=start_checkpoint,
|
||||
)
|
||||
|
||||
assert response.action is ModelingAction.START
|
||||
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1]
|
||||
assert permit.snapshot()["consumed"] is True
|
||||
stop_checks = iter((False, False, True))
|
||||
executor.maintain_active_until_stop_requested(lambda: next(stop_checks))
|
||||
stop_permit = PhysicalAcceptancePermit(
|
||||
_checklist(ModelingAction.STOP),
|
||||
ttl_seconds=120.0,
|
||||
monotonic=clock,
|
||||
)
|
||||
stop_command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_stop(
|
||||
CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=APPLICATION_KEY,
|
||||
)
|
||||
)
|
||||
)
|
||||
stop_response = executor.execute_canonical_stop(stop_command, stop_permit)
|
||||
standby_checks = iter((False, False, True))
|
||||
executor.maintain_post_stop_until_standby_confirmed(
|
||||
lambda: next(standby_checks)
|
||||
)
|
||||
|
||||
assert stop_response.action is ModelingAction.STOP
|
||||
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1, 1, 2, 1]
|
||||
assert len(transport.maintain_calls) == 214
|
||||
assert set(transport.maintain_calls) == {
|
||||
(1.0, ("lixel/application/response/modeling_status",))
|
||||
}
|
||||
assert start_permit.snapshot()["consumed"] is True
|
||||
assert stop_permit.snapshot()["consumed"] is True
|
||||
assert executor.snapshot()["bootstrap_complete"] is True
|
||||
assert executor.snapshot()["command_complete"] is True
|
||||
assert executor.snapshot()["start_complete"] is True
|
||||
assert executor.snapshot()["stop_complete"] is True
|
||||
assert executor.snapshot()["dialogue_stage"] == "standby-confirmed"
|
||||
response_evidence = executor.snapshot()["response_evidence"]
|
||||
assert isinstance(response_evidence, tuple)
|
||||
assert len(response_evidence) == 10
|
||||
assert len(response_evidence) == 13
|
||||
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
|
||||
assert response_evidence[-1]["operation_key"] == "modeling:start"
|
||||
assert response_evidence[-1]["operation_key"] == "modeling:stop"
|
||||
assert all("payload" not in item for item in response_evidence)
|
||||
assert APPLICATION_KEY not in str(executor.snapshot())
|
||||
assert VENDOR_DEVICE_ID not in str(executor.snapshot())
|
||||
with pytest.raises(ApplicationAcceptanceError, match="already attempted"):
|
||||
executor.execute_modeling(command)
|
||||
|
||||
|
||||
def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None:
|
||||
@@ -220,7 +361,6 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||
|
||||
executor = PhysicalAcceptanceDialogueExecutor(
|
||||
CorruptDeviceConfigTransport(),
|
||||
PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
|
||||
)
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||
@@ -232,7 +372,7 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||
ApplicationAcceptanceError,
|
||||
match=r"ordinal 4 \(DeviceConfigRequest\).*session correlation",
|
||||
):
|
||||
executor.run_bootstrap(orchestrator)
|
||||
executor.run_connection_stage(orchestrator)
|
||||
|
||||
snapshot = executor.snapshot()
|
||||
failure = snapshot["correlation_failure"]
|
||||
@@ -253,12 +393,9 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||
assert VENDOR_DEVICE_ID not in str(snapshot)
|
||||
|
||||
|
||||
def test_start_cannot_skip_bootstrap_and_stop_uses_a_separate_action_permit() -> None:
|
||||
def test_standalone_start_and_stop_are_both_disabled() -> None:
|
||||
transport = SyntheticAcceptanceTransport()
|
||||
start = PhysicalAcceptanceDialogueExecutor(
|
||||
transport,
|
||||
PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
|
||||
)
|
||||
start = PhysicalAcceptanceDialogueExecutor(transport)
|
||||
start_command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_start(
|
||||
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY),
|
||||
@@ -268,20 +405,18 @@ def test_start_cannot_skip_bootstrap_and_stop_uses_a_separate_action_permit() ->
|
||||
mount_type=MountType.HANDHELD,
|
||||
)
|
||||
)
|
||||
with pytest.raises(ApplicationAcceptanceError, match="requires.*bootstrap"):
|
||||
with pytest.raises(ApplicationAcceptanceError, match="standalone modeling commands"):
|
||||
start.execute_modeling(start_command)
|
||||
|
||||
stop = PhysicalAcceptanceDialogueExecutor(
|
||||
transport,
|
||||
PhysicalAcceptancePermit(_checklist(ModelingAction.STOP)),
|
||||
)
|
||||
stop = PhysicalAcceptanceDialogueExecutor(transport)
|
||||
stop_command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_stop(
|
||||
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY)
|
||||
)
|
||||
)
|
||||
assert stop.execute_modeling(stop_command).action is ModelingAction.STOP
|
||||
assert transport.batches[-1] == ("modeling:stop",)
|
||||
with pytest.raises(ApplicationAcceptanceError, match="standalone modeling commands"):
|
||||
stop.execute_modeling(stop_command)
|
||||
assert transport.batches == []
|
||||
|
||||
|
||||
def test_acceptance_permit_rejects_implicit_or_expired_authority() -> None:
|
||||
|
||||
@@ -7,6 +7,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
ShadowApplicationBootstrapOrchestrator,
|
||||
build_canonical_post_start_observation,
|
||||
build_shadow_bootstrap,
|
||||
decode_and_bind_device_info_response,
|
||||
)
|
||||
@@ -148,6 +149,21 @@ def test_bootstrap_headers_switch_from_unbound_to_live_device_identity() -> None
|
||||
)
|
||||
|
||||
|
||||
def test_canonical_post_start_observation_preserves_retained_operations_12_to_14() -> None:
|
||||
plan = build_canonical_post_start_observation(_authority(), _binding())
|
||||
|
||||
assert [request.ordinal for request in plan.requests] == [12, 13, 14]
|
||||
assert [request.message_type for request in plan.requests] == [
|
||||
"ModelingStatusRequest",
|
||||
"DeviceInfoRequest",
|
||||
"ModelingStatusRequest",
|
||||
]
|
||||
assert [request.payload_bytes for request in plan.requests] == [139, 135, 139]
|
||||
assert [request.response_required for request in plan.requests] == [False, True, True]
|
||||
assert all(request.phase == "post-start-observation" for request in plan.requests)
|
||||
assert all(not request.mutates_device for request in plan.requests)
|
||||
|
||||
|
||||
def test_device_info_response_produces_live_binding_without_a_saved_device_profile() -> None:
|
||||
response = decode_and_bind_device_info_response(_device_info_response(), _authority())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
MODELING_STATE_BASE,
|
||||
OPENAPI_SUCCESS,
|
||||
SYSTEM_ERROR_STATE_BASE,
|
||||
CommandHeaderIdentity,
|
||||
ModelingAction,
|
||||
ModelingCommandRejected,
|
||||
@@ -21,6 +22,7 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
correlate_modeling_response,
|
||||
decode_device_status_report,
|
||||
decode_modeling_response,
|
||||
decode_system_error_report,
|
||||
encode_modeling_start,
|
||||
encode_modeling_stop,
|
||||
)
|
||||
@@ -277,6 +279,22 @@ def test_device_status_maps_base_offset_states_and_preserves_unknown() -> None:
|
||||
decode_device_status_report(_uint(6, 2) + _uint(2, MODELING_STATE_BASE))
|
||||
|
||||
|
||||
def test_system_error_maps_observed_algorithm_error_without_exposing_identity() -> None:
|
||||
identity = _identity()
|
||||
payload = _bytes(1, _header(identity)) + _bytes(
|
||||
15,
|
||||
_uint(1, SYSTEM_ERROR_STATE_BASE + SessionState.ALGORITHM_ERROR),
|
||||
)
|
||||
|
||||
report = decode_system_error_report(payload)
|
||||
|
||||
assert report.error_code == 0x3204_0133
|
||||
assert report.session_state is SessionState.ALGORITHM_ERROR
|
||||
assert "synthetic" not in repr(report)
|
||||
with pytest.raises(ModelingProtocolError, match="no error code"):
|
||||
decode_system_error_report(_bytes(1, _header(identity)))
|
||||
|
||||
|
||||
def test_state_machine_never_promotes_status_only_evidence_to_durable_save() -> None:
|
||||
machine = DeviceAcquisitionStateMachine()
|
||||
assert machine.snapshot.phase is AcquisitionPhase.UNOBSERVED
|
||||
|
||||
Reference in New Issue
Block a user