feat(k1): complete canonical control lifecycle
This commit is contained in:
@@ -4,6 +4,7 @@ import hashlib
|
||||
from collections.abc import Collection, Sequence
|
||||
|
||||
import pytest
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||
ApplicationAcceptanceError,
|
||||
@@ -11,6 +12,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
|
||||
PhysicalAcceptanceChecklist,
|
||||
PhysicalAcceptanceDialogueExecutor,
|
||||
PhysicalAcceptancePermit,
|
||||
ScanInitializationTimeout,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
@@ -18,9 +20,6 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ShadowApplicationBootstrapOrchestrator,
|
||||
build_canonical_post_start_observation,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
|
||||
MODELING_RESPONSE_TOPIC,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
@@ -67,14 +66,19 @@ def _header(session_id: str) -> bytes:
|
||||
return _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
||||
|
||||
|
||||
def _device_info_response(session_id: str) -> bytes:
|
||||
def _device_info_response(
|
||||
session_id: str,
|
||||
*,
|
||||
device_model: str = "LixelKity K1",
|
||||
device_type: str = "A4",
|
||||
) -> bytes:
|
||||
base_info = b"".join(
|
||||
(
|
||||
_text(2, "V3.0.2-20260101-release"),
|
||||
_text(2, "V3.0.2_20250624.122658"),
|
||||
_text(3, "V3.0.2"),
|
||||
_text(6, "LixelKity K1"),
|
||||
_text(6, device_model),
|
||||
_text(7, "K1SERIAL01"),
|
||||
_text(8, "K1"),
|
||||
_text(8, device_type),
|
||||
)
|
||||
)
|
||||
working_status = _uint(1, 1)
|
||||
@@ -93,7 +97,13 @@ def _modeling_response(action: ModelingAction) -> bytes:
|
||||
|
||||
|
||||
class SyntheticAcceptanceTransport:
|
||||
def __init__(self, clock: FakeClock | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
clock: FakeClock | None = None,
|
||||
*,
|
||||
device_model: str = "LixelKity K1",
|
||||
device_type: str = "A4",
|
||||
) -> None:
|
||||
self.batches: list[tuple[str, ...]] = []
|
||||
self.maintain_calls: list[tuple[float, tuple[str, ...]]] = []
|
||||
self.clock = clock
|
||||
@@ -102,22 +112,32 @@ class SyntheticAcceptanceTransport:
|
||||
self.post_stop_maintain_calls = 0
|
||||
self.start_emitted = False
|
||||
self.stop_emitted = False
|
||||
self.device_model = device_model
|
||||
self.device_type = device_type
|
||||
|
||||
def exchange_batch_once(
|
||||
self,
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_topics: Collection[str],
|
||||
required_response_operation_keys: Collection[str],
|
||||
) -> dict[str, bytes]:
|
||||
self.batches.append(tuple(envelope.operation_key for envelope in envelopes))
|
||||
responses: dict[str, bytes] = {}
|
||||
if MODELING_RESPONSE_TOPIC in required_response_topics:
|
||||
modeling_operation = next(
|
||||
(
|
||||
operation_key
|
||||
for operation_key in required_response_operation_keys
|
||||
if operation_key in {"modeling:start", "modeling:stop"}
|
||||
),
|
||||
None,
|
||||
)
|
||||
if modeling_operation is not None:
|
||||
action = (
|
||||
ModelingAction.START
|
||||
if envelopes[0].operation_key == "modeling:start"
|
||||
if modeling_operation == "modeling:start"
|
||||
else ModelingAction.STOP
|
||||
)
|
||||
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
|
||||
responses[modeling_operation] = _modeling_response(action)
|
||||
if action is ModelingAction.START:
|
||||
self.start_emitted = True
|
||||
else:
|
||||
@@ -134,7 +154,11 @@ class SyntheticAcceptanceTransport:
|
||||
if ordinal == 1
|
||||
else f"{VENDOR_DEVICE_ID}:DeviceInfoRequest"
|
||||
)
|
||||
response = _device_info_response(session)
|
||||
response = _device_info_response(
|
||||
session,
|
||||
device_model=self.device_model,
|
||||
device_type=self.device_type,
|
||||
)
|
||||
else:
|
||||
session = (
|
||||
f":{message_type}" if ordinal <= 3 else f"{VENDOR_DEVICE_ID}:{message_type}"
|
||||
@@ -142,9 +166,8 @@ class SyntheticAcceptanceTransport:
|
||||
if message_type == "DeviceConfigRequest":
|
||||
session += ":Publish_Proto_DeviceConfig_SetTime"
|
||||
response = _generic_response(session)
|
||||
for topic in required_response_topics:
|
||||
if topic.endswith(_response_suffix(message_type)):
|
||||
responses[topic] = response
|
||||
if envelope.operation_key in required_response_operation_keys:
|
||||
responses[envelope.operation_key] = response
|
||||
return responses
|
||||
|
||||
def maintain_open_for(
|
||||
@@ -178,18 +201,6 @@ class SyntheticAcceptanceTransport:
|
||||
return None
|
||||
|
||||
|
||||
def _response_suffix(message_type: str) -> str:
|
||||
values = {
|
||||
"DeviceInfoRequest": "device_info",
|
||||
"ModelingStatusRequest": "modeling_status",
|
||||
"GetRtkAdvanceRequest": "get_rtk_advance",
|
||||
"DeviceConfigRequest": "device_config",
|
||||
"GetNtripProfileRequest": "get_ntrip_profile",
|
||||
"GetCloudServerConfigRequest": "get_cloud_server_config",
|
||||
}
|
||||
return values[message_type]
|
||||
|
||||
|
||||
def _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist:
|
||||
return PhysicalAcceptanceChecklist(
|
||||
action=action,
|
||||
@@ -232,7 +243,14 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
||||
owner_token=object(),
|
||||
),
|
||||
)
|
||||
assert [len(batch) for batch in transport.batches] == [1, 2, 3]
|
||||
assert [len(batch) for batch in transport.batches] == [1, 5]
|
||||
assert transport.batches[1] == (
|
||||
"bootstrap:2:ModelingStatusRequest",
|
||||
"bootstrap:3:GetRtkAdvanceRequest",
|
||||
"bootstrap:4:DeviceConfigRequest",
|
||||
"bootstrap:5:DeviceInfoRequest",
|
||||
"bootstrap:6:GetRtkAdvanceRequest",
|
||||
)
|
||||
workspace_checks = iter((False, False, True))
|
||||
workspace_checkpoint = executor.wait_for_operator_checkpoint(
|
||||
"workspace-entered",
|
||||
@@ -313,14 +331,11 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
||||
)
|
||||
)
|
||||
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)
|
||||
)
|
||||
executor.maintain_post_stop_until_standby()
|
||||
|
||||
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 [len(batch) for batch in transport.batches] == [1, 5, 1, 3, 1, 1, 2, 1]
|
||||
assert len(transport.maintain_calls) == 212
|
||||
assert set(transport.maintain_calls) == {
|
||||
(1.0, ("lixel/application/response/modeling_status",))
|
||||
}
|
||||
@@ -332,13 +347,120 @@ def test_canonical_session_owns_start_active_scan_stop_and_save_boundary() -> No
|
||||
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 isinstance(response_evidence, list)
|
||||
assert len(response_evidence) == 13
|
||||
assert response_evidence[0]["operation_key"] == "bootstrap:1:DeviceInfoRequest"
|
||||
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())
|
||||
TypeAdapter(JsonValue).validate_python(executor.snapshot())
|
||||
|
||||
|
||||
def test_start_initialization_wait_has_fail_closed_watchdog() -> None:
|
||||
class NeverInitializedTransport(SyntheticAcceptanceTransport):
|
||||
def scan_initialization_complete(self, _binding: object) -> bool:
|
||||
return False
|
||||
|
||||
clock = FakeClock()
|
||||
transport = NeverInitializedTransport(clock)
|
||||
executor = PhysicalAcceptanceDialogueExecutor(
|
||||
transport,
|
||||
monotonic=clock,
|
||||
scan_initialization_timeout_seconds=2.0,
|
||||
)
|
||||
authority = ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
authority,
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
binding = executor.run_connection_stage(orchestrator)
|
||||
executor.run_workspace_entry_stage(
|
||||
orchestrator,
|
||||
executor.wait_for_operator_checkpoint("workspace-entered", lambda: True),
|
||||
)
|
||||
executor.run_project_prompt_stage(
|
||||
orchestrator,
|
||||
executor.wait_for_operator_checkpoint("project-prompt-opened", lambda: True),
|
||||
)
|
||||
start_checkpoint = executor.wait_for_operator_checkpoint(
|
||||
"start-confirmed",
|
||||
lambda: True,
|
||||
)
|
||||
command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_start(
|
||||
CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=APPLICATION_KEY,
|
||||
),
|
||||
project_name="SAFE_PROJECT",
|
||||
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||
scan_mode=ScanMode.LCC,
|
||||
mount_type=MountType.HANDHELD,
|
||||
)
|
||||
)
|
||||
permit = PhysicalAcceptancePermit(
|
||||
_checklist(ModelingAction.START),
|
||||
monotonic=clock,
|
||||
)
|
||||
|
||||
with pytest.raises(ScanInitializationTimeout):
|
||||
executor.execute_canonical_start(
|
||||
command,
|
||||
build_canonical_post_start_observation(authority, binding),
|
||||
authority=authority,
|
||||
binding=binding,
|
||||
permit=permit,
|
||||
checkpoint=start_checkpoint,
|
||||
)
|
||||
|
||||
assert transport.start_emitted
|
||||
assert not transport.stop_emitted
|
||||
assert executor.snapshot()["dialogue_stage"] == "initializing"
|
||||
assert executor.snapshot()["start_attempted"] is True
|
||||
assert executor.snapshot()["start_complete"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_model", "device_type"),
|
||||
(
|
||||
("LixelKity K2", "A4"),
|
||||
("LixelKity K1", "K1"),
|
||||
),
|
||||
)
|
||||
def test_connection_stage_rejects_other_device_model_or_type_before_preparation(
|
||||
device_model: str,
|
||||
device_type: str,
|
||||
) -> None:
|
||||
transport = SyntheticAcceptanceTransport(
|
||||
device_model=device_model,
|
||||
device_type=device_type,
|
||||
)
|
||||
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
with pytest.raises(ApplicationAcceptanceError, match="incompatible with the selected"):
|
||||
executor.run_connection_stage(orchestrator)
|
||||
|
||||
assert transport.batches == [("bootstrap:1:DeviceInfoRequest",)]
|
||||
assert not transport.start_emitted
|
||||
assert orchestrator.binding is None
|
||||
snapshot = executor.snapshot()
|
||||
assert snapshot["correlation_failure"] is None
|
||||
compatibility_failure = snapshot["compatibility_failure"]
|
||||
assert isinstance(compatibility_failure, dict)
|
||||
assert compatibility_failure["reason_code"] == "compatibility_profile_mismatch"
|
||||
assert compatibility_failure["expected"] == {
|
||||
"device_model": "LixelKity K1",
|
||||
"platform_type": "A4",
|
||||
"firmware": "3.0.2",
|
||||
"is_activated": True,
|
||||
}
|
||||
|
||||
|
||||
def test_bootstrap_correlation_failure_records_only_redacted_response_evidence() -> None:
|
||||
@@ -347,14 +469,15 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||
self,
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_topics: Collection[str],
|
||||
required_response_operation_keys: Collection[str],
|
||||
) -> dict[str, bytes]:
|
||||
responses = super().exchange_batch_once(
|
||||
envelopes,
|
||||
required_response_topics=required_response_topics,
|
||||
required_response_operation_keys=required_response_operation_keys,
|
||||
)
|
||||
if DEVICE_CONFIG_RESPONSE_TOPIC in responses:
|
||||
responses[DEVICE_CONFIG_RESPONSE_TOPIC] = _generic_response(
|
||||
operation_key = "bootstrap:4:DeviceConfigRequest"
|
||||
if operation_key in responses:
|
||||
responses[operation_key] = _generic_response(
|
||||
f"{VENDOR_DEVICE_ID}:wrong-session"
|
||||
)
|
||||
return responses
|
||||
@@ -380,10 +503,11 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||
"phase": "bootstrap",
|
||||
"operation_key": "bootstrap:4:DeviceConfigRequest",
|
||||
"response_topic": DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
"reason_code": "response_session_mismatch",
|
||||
"reason": "application response session correlation failed",
|
||||
}
|
||||
evidence = snapshot["response_evidence"]
|
||||
assert isinstance(evidence, tuple)
|
||||
assert isinstance(evidence, list)
|
||||
assert evidence[-1]["payload_sha256"] == hashlib.sha256(
|
||||
_generic_response(f"{VENDOR_DEVICE_ID}:wrong-session")
|
||||
).hexdigest()
|
||||
@@ -391,6 +515,7 @@ def test_bootstrap_correlation_failure_records_only_redacted_response_evidence()
|
||||
assert all("payload" not in item for item in evidence)
|
||||
assert APPLICATION_KEY not in str(snapshot)
|
||||
assert VENDOR_DEVICE_ID not in str(snapshot)
|
||||
TypeAdapter(JsonValue).validate_python(snapshot)
|
||||
|
||||
|
||||
def test_standalone_start_and_stop_are_both_disabled() -> None:
|
||||
|
||||
Reference in New Issue
Block a user