feat(k1): complete canonical control lifecycle
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"source": "sanitized-owner-captured-clean-cycle-device-info",
|
||||
"device_model": "LixelKity K1",
|
||||
"platform_type": "A4",
|
||||
"software_version": "V3.0.2_20250624.122658",
|
||||
"system_version": "V3.0.2",
|
||||
"is_activated": true,
|
||||
"redaction": "No device identifier, serial, authority, network address, credential, or raw payload is retained."
|
||||
}
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import cli
|
||||
from k1link.device_plugins.xgrids_k1.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -25,6 +26,26 @@ def test_doctor_json() -> None:
|
||||
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
||||
|
||||
|
||||
def test_serve_resolves_frontend_from_repository_root(monkeypatch: Any) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_run(application: str, **kwargs: object) -> None:
|
||||
captured.update({"application": application, **kwargs})
|
||||
|
||||
monkeypatch.setattr(cli.uvicorn, "run", fake_run)
|
||||
|
||||
result = runner.invoke(app, ["serve"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured == {
|
||||
"application": "k1link.web.app:app",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8000,
|
||||
"log_level": "info",
|
||||
"access_log": True,
|
||||
}
|
||||
|
||||
|
||||
def test_authority_provision_requires_explicit_reviewed_value_confirmation() -> None:
|
||||
result = runner.invoke(app, ["authority", "provision"])
|
||||
|
||||
|
||||
@@ -361,6 +361,37 @@ def test_dispatcher_routes_allowlisted_action_to_xgrids_facade() -> None:
|
||||
assert service.calls == [("scan", 6.0)]
|
||||
|
||||
|
||||
def test_runtime_classifies_non_json_plugin_output_as_execution_failure(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
retained_marker = "must-not-appear-in-the-server-log"
|
||||
|
||||
class NonJsonAdapter:
|
||||
plugin_id = "example.non-json"
|
||||
action_ids = frozenset({"state.read"})
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
_invocation: RuntimeActionInvocation,
|
||||
) -> dict[str, Any]:
|
||||
return {"dialogue": {"response_evidence": (retained_marker,)}}
|
||||
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[
|
||||
_in_process_runtime(
|
||||
NonJsonAdapter(),
|
||||
plugin_version="0.1.0",
|
||||
host_api_version="missioncore.nodedc/v1alpha2",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(PluginExecutionError, match="non-JSON"):
|
||||
asyncio.run(dispatcher.invoke("example.non-json", "state.read", {}))
|
||||
|
||||
assert retained_marker not in caplog.text
|
||||
|
||||
|
||||
def test_dispatcher_rejects_unknown_plugin_and_action() -> None:
|
||||
dispatcher = DevicePluginDispatcher(
|
||||
[_in_process_runtime(XgridsK1PluginFacade(FakeXgridsService()))]
|
||||
@@ -404,7 +435,7 @@ def test_facade_validates_payload_before_calling_service() -> None:
|
||||
"compatibility_attestation": {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"operator_confirmed": True,
|
||||
"verification": "live-device-info",
|
||||
},
|
||||
},
|
||||
"live",
|
||||
|
||||
@@ -17,6 +17,7 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling import (
|
||||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||
ProtobufWireError,
|
||||
decode_zigzag64,
|
||||
encode_zigzag64,
|
||||
iter_fields,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||
@@ -94,6 +95,13 @@ def test_protobuf_wire_zigzag_and_bounds() -> None:
|
||||
assert decode_zigzag64(0) == 0
|
||||
assert decode_zigzag64(1) == -1
|
||||
assert decode_zigzag64(2) == 1
|
||||
assert encode_zigzag64(0) == 0
|
||||
assert encode_zigzag64(-1) == 1
|
||||
assert encode_zigzag64(1) == 2
|
||||
assert decode_zigzag64(encode_zigzag64(-(1 << 63))) == -(1 << 63)
|
||||
assert decode_zigzag64(encode_zigzag64((1 << 63) - 1)) == (1 << 63) - 1
|
||||
with pytest.raises(ProtobufWireError, match="outside int64"):
|
||||
encode_zigzag64(1 << 63)
|
||||
with pytest.raises(ProtobufWireError, match="truncated"):
|
||||
list(iter_fields(b"\x0a\x02\x01"))
|
||||
with pytest.raises(ProtobufWireError, match="unsupported"):
|
||||
|
||||
@@ -77,7 +77,7 @@ def test_validation_errors_do_not_echo_sensitive_request_values(
|
||||
"compatibility_attestation": {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"operator_confirmed": True,
|
||||
"verification": "live-device-info",
|
||||
},
|
||||
}
|
||||
payload = {"input": action_input} if wrap_input else action_input
|
||||
|
||||
@@ -31,7 +31,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ATTESTATION = CompatibilityAttestationRequest(
|
||||
firmware_version="3.0.2",
|
||||
topology="direct-lan",
|
||||
operator_confirmed=True,
|
||||
verification="live-device-info",
|
||||
)
|
||||
PRIMARY_TEST_CREDENTIAL = "x" * 24
|
||||
SECONDARY_TEST_CREDENTIAL = "y" * 24
|
||||
@@ -120,12 +120,11 @@ class FakeInteractiveControlSession:
|
||||
self.state = "workspace-ready"
|
||||
self.start_projects: list[str] = []
|
||||
self.stop_calls = 0
|
||||
self.confirm_calls = 0
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"can_confirm_standby": self.state == "awaiting-standby-confirmation",
|
||||
"can_confirm_standby": False,
|
||||
}
|
||||
|
||||
def open_project_prompt(self) -> dict[str, object]:
|
||||
@@ -147,12 +146,6 @@ class FakeInteractiveControlSession:
|
||||
self.state = "awaiting-standby-confirmation"
|
||||
return self.snapshot()
|
||||
|
||||
def confirm_standby(self) -> dict[str, object]:
|
||||
assert self.state == "awaiting-standby-confirmation"
|
||||
self.confirm_calls += 1
|
||||
self.state = "completed"
|
||||
return self.snapshot()
|
||||
|
||||
def close_prestart(self) -> dict[str, object]:
|
||||
self.state = "closed"
|
||||
return self.snapshot()
|
||||
@@ -211,6 +204,8 @@ def test_prepare_creates_provisional_device_session_and_profiled_acquisition(
|
||||
assert state["device_ref"]["device_id"] != state["device_session"]["device_session_id"]
|
||||
assert state["acquisition"]["state"] == "prepared"
|
||||
assert state["acquisition"]["project_name"] == PROJECT_NAME
|
||||
assert state["acquisition"]["mount_type"] == "handheld"
|
||||
assert state["acquisition"]["gnss_mode"] == "none"
|
||||
assert state["acquisition"]["compatibility_profile_id"] == (XGRIDS_K1_COMPATIBILITY_PROFILE_ID)
|
||||
assert state["compatibility"]["vendor_writes_enabled"] is False
|
||||
|
||||
@@ -230,6 +225,29 @@ def test_project_name_is_normalized_and_control_characters_are_rejected() -> Non
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
def test_only_physically_accepted_configuration_values_are_admitted() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ConnectRequest(
|
||||
device_id="synthetic-device",
|
||||
ssid="synthetic-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
connection_mode="quick-connect", # type: ignore[arg-type]
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
PrepareAcquisitionRequest(
|
||||
project_name=PROJECT_NAME,
|
||||
host="192.168.1.20",
|
||||
mount_type="uav", # type: ignore[arg-type]
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
PrepareAcquisitionRequest(
|
||||
project_name=PROJECT_NAME,
|
||||
host="192.168.1.20",
|
||||
gnss_mode="rtk", # type: ignore[arg-type]
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
|
||||
|
||||
def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
|
||||
@@ -247,7 +265,8 @@ def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
|
||||
service._compatibility_attestation = { # noqa: SLF001
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"basis": "operator-attested",
|
||||
"verification": "live-device-info",
|
||||
"basis": "selected-profile-live-device-info-required",
|
||||
"observed_at": "2026-07-18T00:00:00Z",
|
||||
}
|
||||
|
||||
@@ -274,7 +293,7 @@ def test_facade_arms_bounded_shadow_lease_without_installing_publish_transport(
|
||||
assert disarmed["application_control_execution"]["lease"] is None
|
||||
|
||||
|
||||
def test_shadow_arm_requires_idle_connected_attested_device_before_keychain_read(
|
||||
def test_shadow_arm_requires_idle_connected_profile_selected_device_before_keychain_read(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loader = FakeApplicationAuthorityLoader()
|
||||
@@ -405,21 +424,82 @@ def test_plugin_commanded_acquisition_keeps_start_and_stop_as_explicit_actions(
|
||||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||
)
|
||||
)
|
||||
stop_operation = stopping["last_operation"]
|
||||
assert control.stop_calls == 1
|
||||
assert stopping["acquisition"]["state"] == "awaiting_external_stop"
|
||||
assert stopping["last_operation"]["status"] == "running"
|
||||
|
||||
completed = service.stop_acquisition(
|
||||
control.state = "completed"
|
||||
completed = service.state()
|
||||
assert control.stop_calls == 1
|
||||
assert completed["acquisition"]["state"] == "completed"
|
||||
assert completed["acquisition"]["result"]["device_state"] == "ready"
|
||||
assert completed["last_operation"]["status"] == "succeeded"
|
||||
assert runtime.stop_calls == 1
|
||||
|
||||
|
||||
def test_device_standby_retires_sources_after_terminal_local_stop_failure(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
control = FakeInteractiveControlSession()
|
||||
service._application_control_session = control # type: ignore[assignment] # noqa: SLF001
|
||||
service._k1_ip = "192.168.1.20" # noqa: SLF001
|
||||
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
|
||||
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
project_name="TEST001",
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(
|
||||
StartAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||
)
|
||||
)
|
||||
runtime.mark_ready()
|
||||
runtime.pcl_frames = 1
|
||||
service.state()
|
||||
|
||||
stopping = service.stop_acquisition(
|
||||
StopAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
mode="graceful",
|
||||
operator_confirmed=True,
|
||||
operation_id=stop_operation["operation_id"],
|
||||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||
)
|
||||
)
|
||||
stop_operation = stopping["last_operation"]
|
||||
with service._lock: # noqa: SLF001
|
||||
assert service._acquisition is not None # noqa: SLF001
|
||||
service._acquisition.transition( # noqa: SLF001
|
||||
"failed",
|
||||
message_code="acquisition.camera_failed",
|
||||
result={"camera_failure_code": "camera-source-ended"},
|
||||
)
|
||||
service._operations.transition( # noqa: SLF001
|
||||
stop_operation["operation_id"],
|
||||
"failed",
|
||||
stage_code="runtime-failed",
|
||||
message_code="acquisition.stop.runtime_failed",
|
||||
error={
|
||||
"category": "stream",
|
||||
"code": "runtime-failed",
|
||||
"retryable": False,
|
||||
"safe_to_retry": False,
|
||||
"side_effect_status": "unknown",
|
||||
},
|
||||
)
|
||||
|
||||
control.state = "completed"
|
||||
recovered = service.state()
|
||||
|
||||
assert control.stop_calls == 1
|
||||
assert control.confirm_calls == 1
|
||||
assert completed["acquisition"]["state"] == "completed"
|
||||
assert control.state == "completed"
|
||||
assert recovered["acquisition"]["state"] == "failed"
|
||||
assert recovered["application_control_session"]["state"] == "completed"
|
||||
assert runtime.stop_calls == 1
|
||||
|
||||
|
||||
@@ -1257,7 +1337,7 @@ def test_prepare_rejects_stream_subsets_and_duplicates(
|
||||
)
|
||||
|
||||
|
||||
def test_exact_profile_is_inactive_until_explicit_operator_attestation(
|
||||
def test_exact_profile_is_inactive_until_selected_for_live_device_info_verification(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
@@ -1267,7 +1347,7 @@ def test_exact_profile_is_inactive_until_explicit_operator_attestation(
|
||||
"profile_id": None,
|
||||
"decision": "unknown",
|
||||
"permitted_mode": "evidence-only",
|
||||
"firmware_claim": "exact-3.0.2-profile-not-attested",
|
||||
"firmware_claim": "exact-3.0.2-profile-not-selected",
|
||||
"attestation": None,
|
||||
"vendor_writes_enabled": False,
|
||||
"camera_preview": "unverified",
|
||||
@@ -1284,7 +1364,9 @@ def test_exact_profile_is_inactive_until_explicit_operator_attestation(
|
||||
|
||||
assert attested["compatibility"]["profile_id"] == XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
assert attested["compatibility"]["decision"] == "limited"
|
||||
assert attested["compatibility"]["attestation"]["basis"] == "operator-attested"
|
||||
assert attested["compatibility"]["attestation"]["basis"] == (
|
||||
"selected-profile-live-device-info-required"
|
||||
)
|
||||
assert attested["device_session"]["compatibility_profile_id"] == (
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
)
|
||||
@@ -1722,6 +1804,7 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
|
||||
|
||||
assert boundary_calls == [("k1-a", "lab-network", PRIMARY_TEST_CREDENTIAL)]
|
||||
assert connected["k1_ip"] == "192.168.1.20"
|
||||
assert connected["connection_mode"] == "bridge"
|
||||
assert PRIMARY_TEST_CREDENTIAL not in str(connected)
|
||||
provision_operations = [
|
||||
item for item in connected["operations"] if item["action"] == "network.provision"
|
||||
@@ -1729,6 +1812,48 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
|
||||
assert {item["status"] for item in provision_operations} == {"succeeded", "failed"}
|
||||
|
||||
|
||||
def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
|
||||
|
||||
async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||
return {
|
||||
"started_at_utc": "2026-07-18T15:19:25Z",
|
||||
"completed_at_utc": "2026-07-18T15:19:26Z",
|
||||
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||
"outcome": "lan_address_observed",
|
||||
"observations": [{"status": {"ipv4": "192.168.68.51"}}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
|
||||
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
assert state["selected_device_id"] is None
|
||||
assert state["k1_ip"] is None
|
||||
operation = next(
|
||||
item for item in state["operations"] if item["action"] == "network.provision"
|
||||
)
|
||||
assert operation["status"] == "failed"
|
||||
assert operation["error"]["safe_to_retry"] is False
|
||||
assert operation["error"]["side_effect_status"] == "unknown"
|
||||
|
||||
|
||||
def test_provisioning_cannot_switch_device_during_active_acquisition(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -16,6 +16,23 @@ from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
def test_runtime_authority_loads_through_security_framework_without_subprocess(
|
||||
_system: object,
|
||||
) -> None:
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def framework_reader(*, service: str, account: str) -> bytes:
|
||||
calls.append((service, account))
|
||||
return PRIVATE_AUTHORITY
|
||||
|
||||
loader = MacOSKeychainApplicationAuthorityLoader(framework_reader=framework_reader)
|
||||
authority = loader.load()
|
||||
|
||||
assert calls == [(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)]
|
||||
assert PRIVATE_AUTHORITY.decode().strip() not in repr(authority)
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
@patch("shutil.which", return_value="/usr/bin/security")
|
||||
def test_authority_loads_only_from_fixed_macos_keychain_item(
|
||||
@@ -111,6 +128,21 @@ def test_authority_loader_rejects_wrong_profile_length_without_reflection(
|
||||
assert invalid_secret.decode() not in str(error.value)
|
||||
|
||||
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
def test_framework_authority_failure_is_redacted(_system: object) -> None:
|
||||
private_error = "private-framework-diagnostic"
|
||||
|
||||
def framework_reader(*, service: str, account: str) -> bytes:
|
||||
assert service == KEYCHAIN_SERVICE
|
||||
assert account == KEYCHAIN_ACCOUNT
|
||||
raise RuntimeError(private_error)
|
||||
|
||||
with pytest.raises(ApplicationAuthorityLoadError) as error:
|
||||
MacOSKeychainApplicationAuthorityLoader(framework_reader=framework_reader).load()
|
||||
|
||||
assert private_error not in str(error.value)
|
||||
|
||||
|
||||
@patch("sys.stdout.isatty", return_value=True)
|
||||
@patch("sys.stdin.isatty", return_value=True)
|
||||
@patch("platform.system", return_value="Darwin")
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
@@ -16,6 +19,14 @@ from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields
|
||||
|
||||
APPLICATION_KEY = "11111111-2222-3333-4444-555555555555"
|
||||
VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
CAPTURE_FACTS = json.loads(
|
||||
(
|
||||
Path(__file__).parent
|
||||
/ "fixtures"
|
||||
/ "xgrids_k1"
|
||||
/ "device_info_fw3_0_2_semantics.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
@@ -47,11 +58,11 @@ def _binding(**changes: object) -> LiveDeviceControlBinding:
|
||||
values = {
|
||||
"vendor_device_id": VENDOR_DEVICE_ID,
|
||||
"device_serial": "K1SERIAL01",
|
||||
"software_version": "V3.0.2-20260101-release",
|
||||
"system_version": "V3.0.2",
|
||||
"device_model": "LixelKity K1",
|
||||
"device_type": "K1",
|
||||
"is_activated": True,
|
||||
"software_version": CAPTURE_FACTS["software_version"],
|
||||
"system_version": CAPTURE_FACTS["system_version"],
|
||||
"device_model": CAPTURE_FACTS["device_model"],
|
||||
"device_type": CAPTURE_FACTS["platform_type"],
|
||||
"is_activated": CAPTURE_FACTS["is_activated"],
|
||||
}
|
||||
values.update(changes)
|
||||
return LiveDeviceControlBinding(**values) # type: ignore[arg-type]
|
||||
@@ -62,13 +73,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
|
||||
base_info = b"".join(
|
||||
(
|
||||
_text(1, "2026-01-01T00:00:00"),
|
||||
_text(2, "V3.0.2-20260101-release"),
|
||||
_text(3, "V3.0.2"),
|
||||
_text(2, CAPTURE_FACTS["software_version"]),
|
||||
_text(3, CAPTURE_FACTS["system_version"]),
|
||||
_text(4, "V1.2.3"),
|
||||
_text(5, "V1.0"),
|
||||
_text(6, "LixelKity K1"),
|
||||
_text(6, CAPTURE_FACTS["device_model"]),
|
||||
_text(7, "K1SERIAL01"),
|
||||
_text(8, "K1"),
|
||||
_text(8, CAPTURE_FACTS["platform_type"]),
|
||||
)
|
||||
)
|
||||
working_status = _uint(1, 1) + _uint(2, 0)
|
||||
@@ -149,6 +160,27 @@ def test_bootstrap_headers_switch_from_unbound_to_live_device_identity() -> None
|
||||
)
|
||||
|
||||
|
||||
def test_device_config_timestamp_uses_captured_sint64_zigzag_wire_semantics() -> None:
|
||||
captured_epoch_seconds = 1_784_215_030
|
||||
plan = build_shadow_bootstrap(
|
||||
_authority(),
|
||||
_binding(),
|
||||
epoch_seconds=captured_epoch_seconds,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
config_request = plan.requests[3]
|
||||
top_fields = {field.number: field.value for field in iter_fields(config_request.payload)}
|
||||
time_config = top_fields[4]
|
||||
assert isinstance(time_config, bytes)
|
||||
time_fields = {field.number: field.value for field in iter_fields(time_config)}
|
||||
|
||||
assert time_fields == {
|
||||
1: 3_568_430_060,
|
||||
2: b"Europe/Moscow",
|
||||
}
|
||||
|
||||
|
||||
def test_canonical_post_start_observation_preserves_retained_operations_12_to_14() -> None:
|
||||
plan = build_canonical_post_start_observation(_authority(), _binding())
|
||||
|
||||
@@ -169,6 +201,7 @@ def test_device_info_response_produces_live_binding_without_a_saved_device_profi
|
||||
|
||||
assert response.result_code == OPENAPI_SUCCESS
|
||||
assert response.binding.ready_for_reviewed_profile
|
||||
assert response.binding.matches_reviewed_device
|
||||
assert response.binding.matches_reviewed_firmware
|
||||
assert response.binding.is_activated
|
||||
assert VENDOR_DEVICE_ID not in repr(response)
|
||||
@@ -181,7 +214,6 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
|
||||
_device_info_response(session_id=":AnotherRequest"),
|
||||
_authority(),
|
||||
)
|
||||
|
||||
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
||||
build_shadow_bootstrap(
|
||||
_authority(),
|
||||
@@ -191,6 +223,37 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_model", "device_type"),
|
||||
(
|
||||
("lixelkity K1", "A4"),
|
||||
("LixelKity K1 ", "A4"),
|
||||
("LixelKity K11", "A4"),
|
||||
("LixelKity K1", "a4"),
|
||||
("LixelKity K1", " A4"),
|
||||
("LixelKity K1", "K1"),
|
||||
),
|
||||
)
|
||||
def test_reviewed_profile_rejects_non_exact_device_model_and_type(
|
||||
device_model: str,
|
||||
device_type: str,
|
||||
) -> None:
|
||||
binding = _binding(device_model=device_model, device_type=device_type)
|
||||
|
||||
assert binding.matches_reviewed_firmware
|
||||
assert not binding.matches_reviewed_device
|
||||
assert not binding.ready_for_reviewed_profile
|
||||
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
||||
build_shadow_bootstrap(
|
||||
_authority(),
|
||||
binding,
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
with pytest.raises(ApplicationBootstrapError, match="does not attest"):
|
||||
build_canonical_post_start_observation(_authority(), binding)
|
||||
|
||||
|
||||
def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
_authority(),
|
||||
@@ -202,35 +265,37 @@ def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
||||
assert [request.ordinal for request in identity] == [1]
|
||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||
orchestrator.next_batch()
|
||||
orchestrator.accept_response(identity[0].response_topic, _device_info_response())
|
||||
|
||||
initial_reads = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in initial_reads] == [2, 3]
|
||||
assert [request.response_required for request in initial_reads] == [False, True]
|
||||
orchestrator.accept_response(
|
||||
initial_reads[1].response_topic,
|
||||
_generic_response(initial_reads[1].session_id),
|
||||
"bootstrap:1:DeviceInfoRequest",
|
||||
_device_info_response(),
|
||||
)
|
||||
|
||||
preparation = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in preparation] == [4, 5, 6]
|
||||
device_info = preparation[1]
|
||||
initial_reads = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in initial_reads] == [2, 3, 4, 5, 6]
|
||||
assert [request.response_required for request in initial_reads] == [
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
True,
|
||||
True,
|
||||
]
|
||||
device_info = initial_reads[3]
|
||||
orchestrator.accept_response(
|
||||
device_info.response_topic,
|
||||
"bootstrap:5:DeviceInfoRequest",
|
||||
_device_info_response(session_id=device_info.session_id),
|
||||
)
|
||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||
orchestrator.next_batch()
|
||||
for request in (preparation[2], preparation[0]):
|
||||
for request in (initial_reads[4], initial_reads[1], initial_reads[2]):
|
||||
orchestrator.accept_response(
|
||||
request.response_topic,
|
||||
f"bootstrap:{request.ordinal}:{request.message_type}",
|
||||
_generic_response(request.session_id),
|
||||
)
|
||||
|
||||
ntrip = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in ntrip] == [7]
|
||||
orchestrator.accept_response(
|
||||
ntrip[0].response_topic,
|
||||
"bootstrap:7:GetNtripProfileRequest",
|
||||
_generic_response(ntrip[0].session_id),
|
||||
)
|
||||
|
||||
@@ -242,7 +307,10 @@ def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
||||
if request.message_type == "DeviceInfoRequest"
|
||||
else _generic_response(request.session_id)
|
||||
)
|
||||
orchestrator.accept_response(request.response_topic, payload)
|
||||
orchestrator.accept_response(
|
||||
f"bootstrap:{request.ordinal}:{request.message_type}",
|
||||
payload,
|
||||
)
|
||||
|
||||
snapshot = orchestrator.snapshot()
|
||||
assert snapshot.bootstrap_complete
|
||||
@@ -264,7 +332,7 @@ def test_orchestrator_rejects_unexpected_response_without_advancing() -> None:
|
||||
|
||||
with pytest.raises(ApplicationBootstrapError, match="not required"):
|
||||
orchestrator.accept_response(
|
||||
"lixel/application/response/get_rtk_advance",
|
||||
"bootstrap:3:GetRtkAdvanceRequest",
|
||||
_generic_response(":GetRtkAdvanceRequest"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,7 +40,7 @@ def _envelope() -> OneShotPublishEnvelope:
|
||||
software_version="V3.0.2-build.1",
|
||||
system_version="V3.0.2",
|
||||
device_model="LixelKity K1",
|
||||
device_type="K1",
|
||||
device_type="A4",
|
||||
is_activated=True,
|
||||
)
|
||||
request = build_shadow_bootstrap(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -37,6 +38,11 @@ class FakeTransportSnapshot:
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"publish_attempts": 0,
|
||||
"qos2_completions": 0,
|
||||
"correlated_responses": 0,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"latest_device_session_state": "ready" if self.ready else "scanning",
|
||||
"latest_device_project_bound": not self.ready,
|
||||
"automatic_retry": False,
|
||||
@@ -71,8 +77,8 @@ class FakeExecutor:
|
||||
device_serial="serial-id",
|
||||
software_version="3.0.2",
|
||||
system_version="3.0.2",
|
||||
device_model="K1",
|
||||
device_type="scanner",
|
||||
device_model="LixelKity K1",
|
||||
device_type="A4",
|
||||
is_activated=True,
|
||||
)
|
||||
|
||||
@@ -124,13 +130,17 @@ class FakeExecutor:
|
||||
self.transport.ready = True
|
||||
return object()
|
||||
|
||||
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None:
|
||||
self.records.append("wait:steady-green")
|
||||
while not observed():
|
||||
threading.Event().wait(0.005)
|
||||
def maintain_post_stop_until_standby(self) -> None:
|
||||
self.records.append("wait:device-standby")
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {"records": tuple(self.records), "automatic_retry": False}
|
||||
return {
|
||||
"records": list(self.records),
|
||||
"dialogue_stage": "test-stage",
|
||||
"start_attempted": False,
|
||||
"stop_attempted": False,
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
|
||||
def _confirmation() -> OperatorPresenceConfirmation:
|
||||
@@ -156,7 +166,7 @@ def _wait_phase(
|
||||
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
|
||||
|
||||
|
||||
def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||
def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
FakeExecutor.records = []
|
||||
@@ -195,12 +205,9 @@ def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||
assert FakeExecutor.records[-1] == "wait:stop"
|
||||
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
standby = _wait_phase(session, "awaiting-standby-confirmation")
|
||||
assert standby["can_confirm_standby"] is True
|
||||
assert FakeExecutor.records[-1] == "wait:steady-green"
|
||||
|
||||
session.confirm_standby()
|
||||
completed = _wait_phase(session, "completed")
|
||||
assert completed["can_confirm_standby"] is False
|
||||
assert completed["pending_operator_action"] is None
|
||||
assert loader.calls == 1
|
||||
assert completed["automatic_retry"] is False
|
||||
assert completed["scripted_transitions"] is False
|
||||
@@ -214,5 +221,508 @@ def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||
"start:11-14",
|
||||
"wait:stop",
|
||||
"stop",
|
||||
"wait:steady-green",
|
||||
"wait:device-standby",
|
||||
]
|
||||
|
||||
|
||||
def test_prestart_failure_is_reported_and_requires_a_new_operator_click(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
class BootstrapFailureExecutor(FakeExecutor):
|
||||
def run_connection_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
raise RuntimeError("bootstrap correlation failed")
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"dialogue_stage": "connection",
|
||||
"start_attempted": False,
|
||||
"stop_attempted": False,
|
||||
"response_evidence": [],
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
BootstrapFailureExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
_wait_phase(session, "failed")
|
||||
failed_thread = session._thread # noqa: SLF001
|
||||
assert failed_thread is not None
|
||||
failed_thread.join(timeout=2.0)
|
||||
assert not failed_thread.is_alive()
|
||||
failed = session.snapshot()
|
||||
|
||||
assert failed["outcome_unknown"] is False
|
||||
assert failed["can_open"] is True
|
||||
assert failed["automatic_retry"] is False
|
||||
assert failed["failure"] == {
|
||||
"code": "RuntimeError",
|
||||
"reason_code": "unexpected_runtime_error",
|
||||
"message": "bootstrap correlation failed",
|
||||
"failed_phase": "connecting",
|
||||
"dialogue_stage": "connection",
|
||||
"transport_state": "ready",
|
||||
"publish_attempts": 0,
|
||||
"qos2_completions": 0,
|
||||
"correlated_responses": 0,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"modeling_command_attempted": False,
|
||||
"diagnostic_snapshot_unavailable": [],
|
||||
"diagnostic_evidence_unavailable": [],
|
||||
"correlation_failure": None,
|
||||
"compatibility_failure": None,
|
||||
"safe_to_retry": True,
|
||||
}
|
||||
assert "reason_code=unexpected_runtime_error" in caplog.text
|
||||
|
||||
|
||||
def test_correlated_read_only_profile_mismatch_allows_only_a_fresh_explicit_attempt(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class ProfileMismatchError(RuntimeError):
|
||||
reason_code = "compatibility_profile_mismatch"
|
||||
|
||||
@dataclass
|
||||
class CorrelatedReadSnapshot:
|
||||
state: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"publish_attempts": 1,
|
||||
"qos2_completions": 1,
|
||||
"correlated_responses": 1,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
|
||||
class CorrelatedReadTransport(FakeTransport):
|
||||
def snapshot(self) -> CorrelatedReadSnapshot:
|
||||
return CorrelatedReadSnapshot(self.state)
|
||||
|
||||
class ProfileMismatchExecutor(FakeExecutor):
|
||||
def run_connection_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
raise ProfileMismatchError("live DeviceInfo profile mismatch")
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"dialogue_stage": "new",
|
||||
"start_attempted": False,
|
||||
"stop_attempted": False,
|
||||
"response_evidence": [],
|
||||
"correlation_failure": None,
|
||||
"compatibility_failure": {
|
||||
"operation_key": "bootstrap:1:DeviceInfoRequest",
|
||||
"reason_code": "compatibility_profile_mismatch",
|
||||
},
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
ProfileMismatchExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: CorrelatedReadTransport(host), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
failed_thread = session._thread # noqa: SLF001
|
||||
assert failed_thread is not None
|
||||
failed_thread.join(timeout=2.0)
|
||||
assert not failed_thread.is_alive()
|
||||
|
||||
failed = session.snapshot()
|
||||
assert failed["state"] == "failed"
|
||||
assert failed["outcome_unknown"] is False
|
||||
assert failed["automatic_retry"] is False
|
||||
assert failed["can_open"] is True
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["reason_code"] == "compatibility_profile_mismatch"
|
||||
assert failure["modeling_command_attempted"] is False
|
||||
assert failure["safe_to_retry"] is True
|
||||
|
||||
|
||||
def test_new_explicit_session_waits_for_old_worker_transport_retirement(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
old_close_entered = threading.Event()
|
||||
release_old_close = threading.Event()
|
||||
transports: list[TaggedTransport] = []
|
||||
|
||||
@dataclass
|
||||
class TaggedTransportSnapshot:
|
||||
state: str
|
||||
ready: bool
|
||||
tag: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"tag": self.tag,
|
||||
"publish_attempts": 0,
|
||||
"qos2_completions": 0,
|
||||
"correlated_responses": 0,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"latest_device_session_state": "ready" if self.ready else "scanning",
|
||||
"latest_device_project_bound": not self.ready,
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
|
||||
class TaggedTransport(FakeTransport):
|
||||
def __init__(self, host: str, tag: str) -> None:
|
||||
super().__init__(host)
|
||||
self.tag = tag
|
||||
self.close_calls = 0
|
||||
|
||||
def close(self) -> None:
|
||||
if self.tag == "generation-1":
|
||||
old_close_entered.set()
|
||||
assert release_old_close.wait(timeout=2.0)
|
||||
self.close_calls += 1
|
||||
super().close()
|
||||
|
||||
def snapshot(self) -> TaggedTransportSnapshot:
|
||||
return TaggedTransportSnapshot(self.state, self.ready, self.tag)
|
||||
|
||||
class RacingExecutor(FakeExecutor):
|
||||
instances = 0
|
||||
|
||||
def __init__(self, transport: TaggedTransport) -> None:
|
||||
super().__init__(transport)
|
||||
type(self).instances += 1
|
||||
self.instance = type(self).instances
|
||||
|
||||
def run_connection_stage(
|
||||
self,
|
||||
orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
if self.instance == 1:
|
||||
raise RuntimeError("first generation failed before publish")
|
||||
return super().run_connection_stage(orchestrator)
|
||||
|
||||
def transport_factory(host: str) -> TaggedTransport:
|
||||
transport = TaggedTransport(host, f"generation-{len(transports) + 1}")
|
||||
transports.append(transport)
|
||||
return transport
|
||||
|
||||
FakeExecutor.records = []
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
RacingExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=transport_factory, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
_wait_phase(session, "failed")
|
||||
assert old_close_entered.wait(timeout=2.0)
|
||||
old_thread = session._thread # noqa: SLF001
|
||||
assert old_thread is not None
|
||||
|
||||
# The failure is safe to retry at the protocol level, but the old worker
|
||||
# still owns its socket. A new explicit click must not construct or open a
|
||||
# second transport until close() completes and the worker exits.
|
||||
retiring = session.snapshot()
|
||||
assert retiring["failure"]["safe_to_retry"] is True # type: ignore[index]
|
||||
assert retiring["can_open"] is False
|
||||
with pytest.raises(
|
||||
session_module.ApplicationAcceptanceError,
|
||||
match="already open or requires manual recovery",
|
||||
):
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
assert len(transports) == 1
|
||||
assert transports[0].close_calls == 0
|
||||
|
||||
release_old_close.set()
|
||||
old_thread.join(timeout=2.0)
|
||||
assert not old_thread.is_alive()
|
||||
retired = session.snapshot()
|
||||
assert retired["state"] == "failed"
|
||||
assert retired["can_open"] is True
|
||||
assert transports[0].close_calls == 1
|
||||
|
||||
# Only a fresh explicit action after retirement creates generation 2.
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
new_thread = session._thread # noqa: SLF001
|
||||
assert new_thread is not None
|
||||
current = _wait_phase(session, "connection-ready")
|
||||
assert current["transport"]["tag"] == "generation-2" # type: ignore[index]
|
||||
assert session._transport is transports[1] # noqa: SLF001
|
||||
assert transports[1].close_calls == 0
|
||||
|
||||
# Complete the second synthetic session so no background waiter remains.
|
||||
session.enter_workspace()
|
||||
_wait_phase(session, "workspace-ready")
|
||||
session.open_project_prompt()
|
||||
_wait_phase(session, "project-ready")
|
||||
session.request_start(project_name="TEST002", confirmation=_confirmation())
|
||||
_wait_phase(session, "scanning")
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
_wait_phase(session, "completed")
|
||||
new_thread.join(timeout=2.0)
|
||||
assert not new_thread.is_alive()
|
||||
assert transports[1].close_calls == 1
|
||||
|
||||
|
||||
def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class StartFailureExecutor(FakeExecutor):
|
||||
start_attempted = False
|
||||
|
||||
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
|
||||
type(self).start_attempted = True
|
||||
raise session_module.ApplicationCommandOutcomeUnknown(
|
||||
"START response was not correlated"
|
||||
)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"dialogue_stage": "start-attempted",
|
||||
"start_attempted": type(self).start_attempted,
|
||||
"stop_attempted": False,
|
||||
"response_evidence": [],
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
FakeExecutor.records = []
|
||||
StartFailureExecutor.start_attempted = False
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
StartFailureExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
_wait_phase(session, "connection-ready")
|
||||
session.enter_workspace()
|
||||
_wait_phase(session, "workspace-ready")
|
||||
session.open_project_prompt()
|
||||
_wait_phase(session, "project-ready")
|
||||
session.request_start(project_name="TEST001", confirmation=_confirmation())
|
||||
failed = _wait_phase(session, "failed")
|
||||
|
||||
assert failed["outcome_unknown"] is True
|
||||
assert failed["can_open"] is False
|
||||
assert failed["failure"]["modeling_command_attempted"] is True # type: ignore[index]
|
||||
assert failed["failure"]["safe_to_retry"] is False # type: ignore[index]
|
||||
|
||||
|
||||
def test_unavailable_transport_snapshot_after_publish_blocks_reopen(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor_entered = threading.Event()
|
||||
release_failure = threading.Event()
|
||||
|
||||
class SnapshotFailureTransport(FakeTransport):
|
||||
def __init__(self, host: str) -> None:
|
||||
super().__init__(host)
|
||||
self.simulated_publish_attempts = 0
|
||||
self.snapshot_unavailable = False
|
||||
|
||||
def snapshot(self) -> FakeTransportSnapshot:
|
||||
if self.snapshot_unavailable:
|
||||
raise RuntimeError("simulated transport snapshot failure")
|
||||
return super().snapshot()
|
||||
|
||||
class PublishedThenFailedExecutor(FakeExecutor):
|
||||
def run_connection_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
executor_entered.set()
|
||||
assert release_failure.wait(timeout=2.0)
|
||||
assert isinstance(self.transport, SnapshotFailureTransport)
|
||||
self.transport.simulated_publish_attempts = 1
|
||||
self.transport.snapshot_unavailable = True
|
||||
raise RuntimeError("bootstrap failed after simulated publish")
|
||||
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
PublishedThenFailedExecutor,
|
||||
)
|
||||
transport = SnapshotFailureTransport("192.168.1.20")
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda _host: transport, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
assert executor_entered.wait(timeout=2.0)
|
||||
failed_thread = session._thread # noqa: SLF001
|
||||
assert failed_thread is not None
|
||||
release_failure.set()
|
||||
failed_thread.join(timeout=2.0)
|
||||
assert not failed_thread.is_alive()
|
||||
failed = session.snapshot()
|
||||
|
||||
assert transport.simulated_publish_attempts == 1
|
||||
assert failed["state"] == "failed"
|
||||
assert failed["outcome_unknown"] is True
|
||||
assert failed["can_open"] is False
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["transport_state"] is None
|
||||
assert failure["publish_attempts"] is None
|
||||
assert failure["diagnostic_snapshot_unavailable"] == ["transport"]
|
||||
assert failure["diagnostic_evidence_unavailable"] == ["transport.publish_attempts"]
|
||||
assert failure["modeling_command_attempted"] is False
|
||||
assert failure["safe_to_retry"] is False
|
||||
with pytest.raises(
|
||||
session_module.ApplicationAcceptanceError,
|
||||
match="already open or requires manual recovery",
|
||||
):
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
|
||||
|
||||
def test_unavailable_dialogue_snapshot_is_outcome_unknown(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class DialogueSnapshotFailureExecutor(FakeExecutor):
|
||||
def run_connection_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
raise RuntimeError("bootstrap failed with unavailable dialogue evidence")
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
raise RuntimeError("simulated dialogue snapshot failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
DialogueSnapshotFailureExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
failed_thread = session._thread # noqa: SLF001
|
||||
assert failed_thread is not None
|
||||
failed_thread.join(timeout=2.0)
|
||||
assert not failed_thread.is_alive()
|
||||
failed = session.snapshot()
|
||||
|
||||
assert failed["state"] == "failed"
|
||||
assert failed["outcome_unknown"] is True
|
||||
assert failed["can_open"] is False
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["publish_attempts"] == 0
|
||||
assert failure["modeling_command_attempted"] is None
|
||||
assert failure["diagnostic_snapshot_unavailable"] == ["dialogue"]
|
||||
assert failure["diagnostic_evidence_unavailable"] == ["dialogue.modeling_command_attempted"]
|
||||
assert failure["safe_to_retry"] is False
|
||||
|
||||
|
||||
def test_pretransport_authority_failure_remains_safe_after_worker_retirement() -> None:
|
||||
class FailingAuthorityLoader(FakeAuthorityLoader):
|
||||
def load(self) -> ApplicationControlAuthority:
|
||||
self.calls += 1
|
||||
raise RuntimeError("authority unavailable before transport creation")
|
||||
|
||||
transport_factory_calls = 0
|
||||
|
||||
def transport_factory(host: str) -> FakeTransport:
|
||||
nonlocal transport_factory_calls
|
||||
transport_factory_calls += 1
|
||||
return FakeTransport(host)
|
||||
|
||||
session = InteractiveApplicationControlSession(
|
||||
FailingAuthorityLoader(),
|
||||
transport_factory=transport_factory, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
failed_thread = session._thread # noqa: SLF001
|
||||
assert failed_thread is not None
|
||||
failed_thread.join(timeout=2.0)
|
||||
assert not failed_thread.is_alive()
|
||||
failed = session.snapshot()
|
||||
|
||||
assert transport_factory_calls == 0
|
||||
assert failed["state"] == "failed"
|
||||
assert failed["outcome_unknown"] is False
|
||||
assert failed["can_open"] is True
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["publish_attempts"] is None
|
||||
assert failure["modeling_command_attempted"] is False
|
||||
assert failure["diagnostic_snapshot_unavailable"] == []
|
||||
assert failure["diagnostic_evidence_unavailable"] == []
|
||||
assert failure["safe_to_retry"] is True
|
||||
|
||||
@@ -64,6 +64,23 @@ def _burst_ffmpeg(tmp_path: Path) -> Path:
|
||||
return executable
|
||||
|
||||
|
||||
def _clean_source_end_ffmpeg(tmp_path: Path) -> Path:
|
||||
executable = tmp_path / "clean-source-end-ffmpeg"
|
||||
executable.write_text(
|
||||
f"#!{sys.executable}\n"
|
||||
"import sys, time\n"
|
||||
"def box(kind, payload=b''):\n"
|
||||
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
|
||||
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
|
||||
"sys.stdout.buffer.write(box(b'moof') + box(b'mdat', b'final-frame'))\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
"time.sleep(0.15)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
executable.chmod(0o700)
|
||||
return executable
|
||||
|
||||
|
||||
def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path:
|
||||
executable = tmp_path / "buffered-tail-ffmpeg"
|
||||
executable.write_text(
|
||||
@@ -289,6 +306,76 @@ def test_acquisition_records_without_browser_and_source_switch_seals_epochs(
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_expected_camera_source_end_during_device_stop_seals_complete_epoch(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"MISSIONCORE_FFMPEG_BINARY",
|
||||
str(_clean_source_end_ffmpeg(tmp_path)),
|
||||
)
|
||||
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
gateway.expect_source_end_for_device_stop()
|
||||
|
||||
_wait_until(
|
||||
lambda: gateway.snapshot()["recording"]["completed_epochs"] == 1,
|
||||
)
|
||||
state = gateway.snapshot()
|
||||
assert state["phase"] == "idle"
|
||||
assert state["error"] is None
|
||||
assert state["delivery"] is None
|
||||
assert state["recording"]["active"] is True
|
||||
assert state["recording"]["source_end_expected"] is False
|
||||
|
||||
gateway.stop_recording(status="complete")
|
||||
epoch = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
)
|
||||
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "complete"
|
||||
assert summary["failure_code"] is None
|
||||
assert summary["media_segment_count"] == 1
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_unexpected_camera_source_end_remains_a_recording_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv(
|
||||
"MISSIONCORE_FFMPEG_BINARY",
|
||||
str(_clean_source_end_ffmpeg(tmp_path)),
|
||||
)
|
||||
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
|
||||
session = tmp_path / "session"
|
||||
session.mkdir()
|
||||
try:
|
||||
selected = gateway.select("sensor.camera.left", "192.168.1.20")
|
||||
gateway.start_recording(session)
|
||||
|
||||
_wait_until(lambda: gateway.snapshot()["phase"] == "error")
|
||||
epoch = (
|
||||
session
|
||||
/ "media"
|
||||
/ "sensor.camera.left"
|
||||
/ f"epoch-{selected['generation']}"
|
||||
)
|
||||
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "interrupted"
|
||||
assert summary["failure_code"] == "camera-source-ended"
|
||||
finally:
|
||||
gateway.close()
|
||||
|
||||
|
||||
def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -501,7 +588,8 @@ def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
|
||||
service._compatibility_attestation = {
|
||||
"firmware_version": "3.0.2",
|
||||
"topology": "direct-lan",
|
||||
"basis": "operator-attested",
|
||||
"verification": "live-device-info",
|
||||
"basis": "selected-profile-live-device-info-required",
|
||||
"observed_at": "2026-07-16T20:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None
|
||||
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
assert profile["scope"]["vendor"] == "XGRIDS"
|
||||
assert profile["scope"]["model"] == "LixelKity K1"
|
||||
assert profile["scope"]["platform_type"] == "A4"
|
||||
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
|
||||
assert profile["scope"]["topology"] == "direct-lan"
|
||||
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
|
||||
@@ -146,6 +147,15 @@ def test_xgrids_compatibility_profile_maps_actions_without_enabling_writes() ->
|
||||
assert profile["safety"]["default_mode"] == "read-only"
|
||||
assert profile["safety"]["vendor_writes_enabled"] is False
|
||||
assert control["mode"] == "operator-manual"
|
||||
assert control["software_acceptance_transport"] == {
|
||||
"status": "installed-operator-present",
|
||||
"default_authority": "disabled",
|
||||
"profile_gate": "live-device-info-exact-match",
|
||||
"dialogue": "single-socket-canonical-start-to-stop",
|
||||
"automatic_retry": False,
|
||||
"supported_mount_type": "handheld",
|
||||
"supported_gnss_mode": "none",
|
||||
}
|
||||
assert control["verified_device_control"]["gesture"] == "physical-double-click"
|
||||
|
||||
for action_id, action_code in (("acquisition.start", 1), ("acquisition.stop", 2)):
|
||||
|
||||
@@ -75,7 +75,7 @@ def _binding(**changes: object) -> LiveDeviceControlBinding:
|
||||
"software_version": "V3.0.2-build.1",
|
||||
"system_version": "V3.0.2",
|
||||
"device_model": "LixelKity K1",
|
||||
"device_type": "K1",
|
||||
"device_type": "A4",
|
||||
"is_activated": True,
|
||||
}
|
||||
values.update(changes)
|
||||
|
||||
Reference in New Issue
Block a user