feat(k1): add physical acceptance transport

This commit is contained in:
DCCONSTRUCTIONS
2026-07-18 15:17:46 +03:00
parent a4accf0fb6
commit eddc09008e
22 changed files with 1574 additions and 33 deletions
+35
View File
@@ -25,6 +25,41 @@ def test_doctor_json() -> None:
assert any(item["name"] == "tcpdump" for item in payload["tools"])
def test_authority_provision_requires_explicit_reviewed_value_confirmation() -> None:
result = runner.invoke(app, ["authority", "provision"])
assert result.exit_code == 2
assert "provisioning not confirmed" in result.stdout
def test_authority_provision_never_accepts_the_secret_as_a_cli_value(monkeypatch: Any) -> None:
calls = 0
class FakeSnapshot:
service = "fixed-service"
account = "fixed-account"
class FakeProvisioner:
def provision_interactively(self) -> FakeSnapshot:
nonlocal calls
calls += 1
return FakeSnapshot()
monkeypatch.setattr(
"k1link.device_plugins.xgrids_k1.cli.MacOSKeychainApplicationAuthorityProvisioner",
FakeProvisioner,
)
result = runner.invoke(
app,
["authority", "provision", "--confirm-reviewed-authority"],
)
assert result.exit_code == 0
assert calls == 1
assert "Keychain item validated" in result.stdout
assert "No K1 command was sent" in " ".join(result.stdout.split())
def test_mqtt_capture_requires_owned_device_confirmation(tmp_path: Path) -> None:
result = runner.invoke(
app,
+243
View File
@@ -0,0 +1,243 @@
from __future__ import annotations
from collections.abc import Collection, Sequence
import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
ApplicationAcceptanceError,
PhysicalAcceptanceChecklist,
PhysicalAcceptanceDialogueExecutor,
PhysicalAcceptancePermit,
)
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
ShadowApplicationBootstrapOrchestrator,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
MODELING_RESPONSE_TOPIC,
)
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) -> bytes:
base_info = b"".join(
(
_text(2, "V3.0.2-20260101-release"),
_text(3, "V3.0.2"),
_text(6, "LixelKity K1"),
_text(7, "K1SERIAL01"),
_text(8, "K1"),
)
)
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) -> None:
self.batches: list[tuple[str, ...]] = []
def exchange_batch_once(
self,
envelopes: Sequence[OneShotPublishEnvelope],
*,
required_response_topics: 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:
action = (
ModelingAction.START
if envelopes[0].operation_key == "modeling:start"
else ModelingAction.STOP
)
responses[MODELING_RESPONSE_TOPIC] = _modeling_response(action)
return responses
for envelope in envelopes:
prefix, ordinal_text, message_type = envelope.operation_key.split(":", 2)
assert prefix == "bootstrap"
ordinal = int(ordinal_text)
if message_type == "DeviceInfoRequest":
session = (
":DeviceInfoRequest"
if ordinal == 1
else f"{VENDOR_DEVICE_ID}:DeviceInfoRequest"
)
response = _device_info_response(session)
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)
for topic in required_response_topics:
if topic.endswith(_response_suffix(message_type)):
responses[topic] = response
return responses
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,
operator_present=True,
owner_controlled_device=True,
lixelgo_closed=True,
battery_storage_confirmed=True,
expected_physical_state_confirmed=True,
)
def test_start_acceptance_requires_full_bootstrap_and_consumes_one_short_permit() -> None:
transport = SyntheticAcceptanceTransport()
permit = PhysicalAcceptancePermit(_checklist(ModelingAction.START))
executor = PhysicalAcceptanceDialogueExecutor(transport, permit)
orchestrator = ShadowApplicationBootstrapOrchestrator(
ApplicationControlAuthority(openapi_key=APPLICATION_KEY),
epoch_seconds=1_752_680_000,
timezone_name="Europe/Moscow",
)
binding = executor.run_bootstrap(orchestrator)
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,
)
)
response = executor.execute_modeling(command)
assert response.action is ModelingAction.START
assert [len(batch) for batch in transport.batches] == [1, 2, 3, 1, 3, 1]
assert permit.snapshot()["consumed"] is True
assert executor.snapshot()["bootstrap_complete"] is True
assert executor.snapshot()["command_complete"] is True
with pytest.raises(ApplicationAcceptanceError, match="already attempted"):
executor.execute_modeling(command)
def test_start_cannot_skip_bootstrap_and_stop_uses_a_separate_action_permit() -> None:
transport = SyntheticAcceptanceTransport()
start = PhysicalAcceptanceDialogueExecutor(
transport,
PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
)
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="requires.*bootstrap"):
start.execute_modeling(start_command)
stop = PhysicalAcceptanceDialogueExecutor(
transport,
PhysicalAcceptancePermit(_checklist(ModelingAction.STOP)),
)
stop_command = ShadowModelingCommand.from_command(
encode_modeling_stop(
CommandHeaderIdentity(device_id=VENDOR_DEVICE_ID, openapi_key=APPLICATION_KEY)
)
)
assert stop.execute_modeling(stop_command).action is ModelingAction.STOP
assert transport.batches[-1] == ("modeling:stop",)
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)
@@ -10,6 +10,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
KEYCHAIN_SERVICE,
ApplicationAuthorityLoadError,
MacOSKeychainApplicationAuthorityLoader,
MacOSKeychainApplicationAuthorityProvisioner,
)
PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n"
@@ -108,3 +109,67 @@ def test_authority_loader_rejects_wrong_profile_length_without_reflection(
MacOSKeychainApplicationAuthorityLoader(runner=runner).load()
assert invalid_secret.decode() 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")
@patch("shutil.which", return_value="/usr/bin/security")
def test_authority_provisioning_uses_hidden_security_prompt_and_validates_item(
_which: object,
_system: object,
_stdin_tty: object,
_stdout_tty: object,
) -> None:
provisioning_calls: list[list[str]] = []
def interactive_runner(
args: list[str],
*,
check: bool,
timeout: float,
) -> subprocess.CompletedProcess[bytes]:
provisioning_calls.append(args)
assert check is False
assert timeout == 300.0
return subprocess.CompletedProcess(args, 0)
def lookup_runner(
args: list[str],
**_kwargs: object,
) -> subprocess.CompletedProcess[bytes]:
return subprocess.CompletedProcess(args, 0, stdout=PRIVATE_AUTHORITY, stderr=b"")
provisioner = MacOSKeychainApplicationAuthorityProvisioner(
runner=interactive_runner,
loader=MacOSKeychainApplicationAuthorityLoader(runner=lookup_runner),
)
snapshot = provisioner.provision_interactively()
assert provisioning_calls == [
[
"/usr/bin/security",
"add-generic-password",
"-U",
"-s",
KEYCHAIN_SERVICE,
"-a",
KEYCHAIN_ACCOUNT,
"-w",
]
]
assert PRIVATE_AUTHORITY.decode().strip() not in str(provisioning_calls)
assert snapshot.secret_cached is False
assert snapshot.secret_exportable is False
@patch("sys.stdout.isatty", return_value=False)
@patch("sys.stdin.isatty", return_value=False)
@patch("platform.system", return_value="Darwin")
def test_authority_provisioning_requires_an_interactive_terminal(
_system: object,
_stdin_tty: object,
_stdout_tty: object,
) -> None:
with pytest.raises(ApplicationAuthorityLoadError, match="interactive terminal"):
MacOSKeychainApplicationAuthorityProvisioner().provision_interactively()
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
import hashlib
from collections import deque
from types import SimpleNamespace
from typing import Any, cast
import paho.mqtt.client as mqtt
import pytest
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
DEVICE_INFO_RESPONSE_TOPIC,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
CONTROL_SUBSCRIPTION_GROUPS,
ApplicationCommandOutcomeUnknown,
ReviewedApplicationMqttTransport,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
OneShotPublishEnvelope,
)
class FakeReasonCode:
def __init__(self, *, failure: bool = False) -> None:
self.is_failure = failure
class FakeClock:
def __init__(self) -> None:
self.now = 100.0
def __call__(self) -> float:
return self.now
class FakeControlClient:
def __init__(self, *, emit_exchange: bool = True, clock: FakeClock | None = None) -> None:
self.on_connect: Any = None
self.on_subscribe: Any = None
self.on_publish: Any = None
self.on_message: Any = None
self.on_disconnect: Any = None
self.emit_exchange = emit_exchange
self.clock = clock
self.events: deque[tuple[str, object]] = deque()
self.connect_calls: list[tuple[str, int, int]] = []
self.subscribe_calls: list[list[tuple[str, int]]] = []
self.publish_calls: list[tuple[str, bytes, int, bool]] = []
self.unsubscribe_calls: list[list[str]] = []
self.disconnect_calls = 0
self.next_mid = 20
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
self.connect_calls.append((host, port, keepalive))
self.events.append(("connect", FakeReasonCode()))
return mqtt.MQTT_ERR_SUCCESS
def subscribe(self, topics: list[tuple[str, int]]) -> tuple[mqtt.MQTTErrorCode, int]:
self.subscribe_calls.append(topics)
mid = 6 + len(self.subscribe_calls)
self.events.append(("subscribe", mid))
return mqtt.MQTT_ERR_SUCCESS, mid
def publish(
self,
topic: str,
payload: bytes,
qos: int,
retain: bool,
) -> SimpleNamespace:
self.publish_calls.append((topic, payload, qos, retain))
mid = self.next_mid
self.next_mid += 1
if self.emit_exchange:
self.events.append(("publish", mid))
self.events.append(("message", (DEVICE_INFO_RESPONSE_TOPIC, b"response")))
return SimpleNamespace(rc=mqtt.MQTT_ERR_SUCCESS, mid=mid)
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
del timeout
if self.clock is not None:
self.clock.now += 1.0
if not self.events:
return mqtt.MQTT_ERR_SUCCESS
kind, value = self.events.popleft()
if kind == "connect":
self.on_connect(self, None, SimpleNamespace(), value, None)
elif kind == "subscribe":
group = CONTROL_SUBSCRIPTION_GROUPS[int(value) - 7]
self.on_subscribe(
self,
None,
value,
[FakeReasonCode() for _item in group],
None,
)
elif kind == "publish":
self.on_publish(self, None, value, FakeReasonCode(), None)
elif kind == "message":
topic, payload = value
self.on_message(self, None, SimpleNamespace(topic=topic, payload=payload))
return mqtt.MQTT_ERR_SUCCESS
def unsubscribe(self, topics: list[str]) -> tuple[mqtt.MQTTErrorCode, int]:
self.unsubscribe_calls.append(topics)
return mqtt.MQTT_ERR_SUCCESS, 50
def disconnect(self) -> mqtt.MQTTErrorCode:
self.disconnect_calls += 1
return mqtt.MQTT_ERR_SUCCESS
def _envelope(operation_key: str = "bootstrap:1:DeviceInfoRequest") -> OneShotPublishEnvelope:
payload = b"synthetic-reviewed-request"
return OneShotPublishEnvelope(
operation_key=operation_key,
topic="lixel/application/request/device_info",
payload=payload,
payload_sha256=hashlib.sha256(payload).hexdigest(),
payload_bytes=len(payload),
qos=2,
retain=False,
)
def test_acceptance_transport_rejects_the_k1_access_point_fallback() -> None:
with pytest.raises(ValueError, match="not a direct-LAN"):
ReviewedApplicationMqttTransport("192.168.56.1")
def test_retained_control_subscription_batches_remain_exact_and_separate_from_points() -> None:
assert [len(group) for group in CONTROL_SUBSCRIPTION_GROUPS] == [9, 5, 42]
assert CONTROL_SUBSCRIPTION_GROUPS[0][-1] == (DEVICE_INFO_RESPONSE_TOPIC, 0)
assert ("lixel/application/response/modeling", 2) in CONTROL_SUBSCRIPTION_GROUPS[2]
assert all(
topic not in {"RealtimePointcloud", "lixel/application/report/lio_pcl"}
for group in CONTROL_SUBSCRIPTION_GROUPS
for topic, _qos in group
)
def test_acceptance_transport_connects_once_and_completes_one_qos2_exchange() -> None:
fake = FakeControlClient()
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
client_factory=lambda: cast(mqtt.Client, fake),
)
opened = transport.open().as_dict()
responses = transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
)
transport.close()
assert fake.connect_calls == [("192.168.1.20", 1883, 60)]
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",
2,
False,
)
]
assert responses == {DEVICE_INFO_RESPONSE_TOPIC: b"response"}
assert opened["clean_session"] is False
assert opened["automatic_reconnect"] is False
assert opened["automatic_retry"] is False
snapshot = transport.snapshot().as_dict()
assert snapshot["state"] == "closed"
assert snapshot["publish_attempts"] == 1
assert snapshot["subscribe_attempts"] == 3
assert snapshot["qos2_completions"] == 1
assert snapshot["correlated_responses"] == 1
assert snapshot["operation_keys_consumed"] == 1
def test_consumed_operation_key_can_never_be_published_again() -> None:
fake = FakeControlClient()
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
client_factory=lambda: cast(mqtt.Client, fake),
)
transport.open()
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
)
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
)
assert len(fake.publish_calls) == 1
def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
clock = FakeClock()
fake = FakeControlClient(emit_exchange=False, clock=clock)
transport = ReviewedApplicationMqttTransport(
"192.168.1.20",
exchange_timeout_seconds=2.0,
client_factory=lambda: cast(mqtt.Client, fake),
monotonic=clock,
)
transport.open()
with pytest.raises(ApplicationCommandOutcomeUnknown, match="automatic retry is forbidden"):
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
)
assert transport.snapshot().state == "poisoned"
assert len(fake.publish_calls) == 1
with pytest.raises(ApplicationCommandOutcomeUnknown, match="already consumed"):
transport.exchange_batch_once(
[_envelope()],
required_response_topics={DEVICE_INFO_RESPONSE_TOPIC},
)
assert len(fake.publish_calls) == 1
+1
View File
@@ -79,6 +79,7 @@ def test_publish_envelope_redacts_payload_and_rejects_retry_semantics() -> None:
assert PRIVATE_AUTHORITY not in str(envelope.as_dict())
with pytest.raises(ValueError, match="one-shot contract"):
OneShotPublishEnvelope(
operation_key=envelope.operation_key,
topic=envelope.topic,
payload=envelope.payload,
payload_sha256=envelope.payload_sha256,