from __future__ import annotations 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, OperatorDialogueCheckpoint, PhysicalAcceptanceChecklist, PhysicalAcceptanceDialogueExecutor, PhysicalAcceptancePermit, ScanInitializationTimeout, ) 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_publish import ( OneShotPublishEnvelope, ) from k1link.device_plugins.xgrids_k1.protocol.modeling_control import ( OPENAPI_SUCCESS, CommandHeaderIdentity, ModelingAction, MountType, RecordMode, ScanMode, encode_modeling_start, encode_modeling_stop, ) from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import ( ShadowModelingCommand, ) APPLICATION_KEY = "11111111-2222-3333-4444-555555555555" VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" 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 _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, *, device_model: str = "LixelKity K1", device_type: str = "A4", ) -> bytes: base_info = b"".join( ( _text(2, "V3.0.2_20250624.122658"), _text(3, "V3.0.2"), _text(6, device_model), _text(7, "K1SERIAL01"), _text(8, device_type), ) ) working_status = _uint(1, 1) device_info = _bytes(2, base_info) + _bytes(7, working_status) error = _uint(1, OPENAPI_SUCCESS) return _bytes(1, _header(session_id)) + _bytes(2, device_info) + _bytes(15, error) def _generic_response(session_id: str) -> bytes: return _bytes(1, _header(session_id)) + _bytes(15, _uint(1, OPENAPI_SUCCESS)) def _modeling_response(action: ModelingAction) -> bytes: session = f"{VENDOR_DEVICE_ID}:ModelingRequest" return _bytes(1, _header(session)) + _uint(2, action) + _bytes(15, _uint(1, OPENAPI_SUCCESS)) class SyntheticAcceptanceTransport: 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 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 self.device_model = device_model self.device_type = device_type def exchange_batch_once( self, envelopes: Sequence[OneShotPublishEnvelope], *, required_response_operation_keys: Collection[str], ) -> dict[str, bytes]: self.batches.append(tuple(envelope.operation_key for envelope in envelopes)) responses: dict[str, bytes] = {} 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 modeling_operation == "modeling:start" else ModelingAction.STOP ) responses[modeling_operation] = _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 in {"bootstrap", "dialogue"} ordinal = int(ordinal_text) if message_type == "DeviceInfoRequest": session = ( ":DeviceInfoRequest" if ordinal == 1 else f"{VENDOR_DEVICE_ID}:DeviceInfoRequest" ) 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}" ) if message_type == "DeviceConfigRequest": session += ":Publish_Proto_DeviceConfig_SetTime" response = _generic_response(session) if envelope.operation_key in required_response_operation_keys: responses[envelope.operation_key] = 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 _checklist(action: ModelingAction) -> PhysicalAcceptanceChecklist: return PhysicalAcceptanceChecklist( action=action, operator_present=True, owner_controlled_device=True, lixelgo_closed=True, battery_storage_confirmed=True, expected_physical_state_confirmed=True, ) 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", ) 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, 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", 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( 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, ) ) 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 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) executor.maintain_post_stop_until_standby() assert stop_response.action is ModelingAction.STOP 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",)) } 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, 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: class CorruptDeviceConfigTransport(SyntheticAcceptanceTransport): def exchange_batch_once( self, envelopes: Sequence[OneShotPublishEnvelope], *, required_response_operation_keys: Collection[str], ) -> dict[str, bytes]: responses = super().exchange_batch_once( envelopes, required_response_operation_keys=required_response_operation_keys, ) operation_key = "bootstrap:4:DeviceConfigRequest" if operation_key in responses: responses[operation_key] = _generic_response( f"{VENDOR_DEVICE_ID}:wrong-session" ) return responses executor = PhysicalAcceptanceDialogueExecutor( CorruptDeviceConfigTransport(), ) orchestrator = ShadowApplicationBootstrapOrchestrator( ApplicationControlAuthority(openapi_key=APPLICATION_KEY), epoch_seconds=1_752_680_000, timezone_name="Europe/Moscow", ) with pytest.raises( ApplicationAcceptanceError, match=r"ordinal 4 \(DeviceConfigRequest\).*session correlation", ): executor.run_connection_stage(orchestrator) snapshot = executor.snapshot() failure = snapshot["correlation_failure"] assert failure == { "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, list) assert evidence[-1]["payload_sha256"] == hashlib.sha256( _generic_response(f"{VENDOR_DEVICE_ID}:wrong-session") ).hexdigest() assert evidence[-1]["payload_bytes"] > 0 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: transport = SyntheticAcceptanceTransport() start = PhysicalAcceptanceDialogueExecutor(transport) start_command = ShadowModelingCommand.from_command( encode_modeling_start( CommandHeaderIdentity(device_id=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, ) ) with pytest.raises(ApplicationAcceptanceError, match="standalone modeling commands"): start.execute_modeling(start_command) stop = PhysicalAcceptanceDialogueExecutor(transport) stop_command = ShadowModelingCommand.from_command( encode_modeling_stop( CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY) ) ) 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: with pytest.raises(ApplicationAcceptanceError, match="explicitly true"): PhysicalAcceptanceChecklist( action=ModelingAction.START, operator_present=False, # type: ignore[arg-type] owner_controlled_device=True, lixelgo_closed=True, battery_storage_confirmed=True, expected_physical_state_confirmed=True, ) now = [100.0] permit = PhysicalAcceptancePermit( _checklist(ModelingAction.STOP), ttl_seconds=15.0, monotonic=lambda: now[0], ) now[0] = 115.0 with pytest.raises(ApplicationAcceptanceError, match="expired"): permit.consume(ModelingAction.STOP)