feat(k1): add local connection matrix

This commit is contained in:
DCCONSTRUCTIONS
2026-07-19 09:52:22 +03:00
parent fecb5885d0
commit 57b2208cae
20 changed files with 1405 additions and 115 deletions
+181 -48
View File
@@ -14,7 +14,7 @@ from collections.abc import Callable, Mapping
from datetime import UTC, datetime
from functools import wraps
from pathlib import Path
from typing import Any, Concatenate, Literal, Protocol, cast
from typing import Any, Concatenate, Literal, Protocol, Self, cast
from bleak.exc import BleakError
from missioncore_plugin_sdk.v0alpha2 import (
@@ -28,6 +28,7 @@ from pydantic import (
SecretStr,
ValidationError,
field_validator,
model_validator,
)
from k1link.artifacts import write_json_atomic
@@ -44,6 +45,7 @@ from k1link.device_plugins.xgrids_k1.camera import (
XgridsK1CameraGateway,
build_xgrids_k1_camera_router,
)
from k1link.device_plugins.xgrids_k1.macos_wifi import associate_with_wifi_once
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
from k1link.device_plugins.xgrids_k1.mqtt.capture import seal_capture_clock
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
@@ -53,6 +55,9 @@ from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
ApplicationAuthorityLoader,
DormantApplicationControlCoordinator,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
ReviewedApplicationMqttTransport,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
UninstalledApplicationPublishSink,
WriteDisabledOneShotPublisher,
@@ -90,9 +95,19 @@ from k1link.web.plugin_runtime import (
)
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
XGRIDS_K1_PLUGIN_VERSION = "0.5.0"
XGRIDS_K1_PLUGIN_VERSION = "0.6.0"
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = (
"xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
)
ConnectionMode = Literal["bridge", "quick-connect", "direct-connect"]
ConnectionTopology = Literal["direct-lan", "device-ap", "controller-hotspot"]
CONNECTION_TOPOLOGY_BY_MODE: dict[ConnectionMode, ConnectionTopology] = {
"bridge": "direct-lan",
"quick-connect": "device-ap",
"direct-connect": "controller-hotspot",
}
ACTION_STATE_READ = "state.read"
ACTION_DISCOVERY_SCAN = "discovery.scan"
@@ -182,7 +197,7 @@ class CompatibilityAttestationRequest(StrictRequest):
"""Selected profile whose facts must be verified from live DeviceInfo."""
firmware_version: Literal["3.0.2"]
topology: Literal["direct-lan"]
topology: ConnectionTopology
verification: Literal["live-device-info"]
@@ -190,11 +205,26 @@ class ConnectRequest(StrictRequest):
device_id: str = Field(min_length=1, max_length=128)
ssid: str = Field(min_length=1, max_length=128)
password: SecretStr = Field(min_length=1, max_length=256)
connection_mode: Literal["bridge"] = "bridge"
connection_mode: ConnectionMode = "bridge"
compatibility_attestation: CompatibilityAttestationRequest
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
idempotency_key: str | None = Field(default=None, min_length=1, max_length=160)
@model_validator(mode="after")
def validate_connection_topology(self) -> Self:
if not 1 <= len(self.ssid.encode("utf-8")) <= 32:
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
if not 1 <= len(self.password.get_secret_value().encode("utf-8")) <= 64:
raise ValueError(
"Wi-Fi password must contain between 1 and 64 UTF-8 bytes"
)
expected = CONNECTION_TOPOLOGY_BY_MODE[self.connection_mode]
if self.compatibility_attestation.topology != expected:
raise ValueError(
f"connection_mode={self.connection_mode} requires topology={expected}"
)
return self
class LiveRequest(StrictRequest):
project_name: str = Field(min_length=1, max_length=96)
@@ -335,7 +365,7 @@ class XgridsK1CompatibilityService:
self._devices: list[dict[str, Any]] = []
self._selected_device_id: str | None = None
self._k1_ip: str | None = None
self._connection_mode: Literal["bridge"] | None = None
self._connection_mode: ConnectionMode | None = None
self._device_ids_by_transport_ref: dict[str, str] = {}
self._device_id: str | None = None
self._device_session_id: str | None = None
@@ -369,7 +399,8 @@ class XgridsK1CompatibilityService:
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
)
self._application_control_session = InteractiveApplicationControlSession(
authority_loader
authority_loader,
transport_factory=self._application_control_transport,
)
self.runtime = VisualizationRuntime(
normalizer=normalize_k1_message,
@@ -380,6 +411,14 @@ class XgridsK1CompatibilityService:
XGRIDS_K1_PLUGIN_ID,
)
def _application_control_transport(self, host: str) -> ReviewedApplicationMqttTransport:
with self._lock:
connection_mode = self._connection_mode
return ReviewedApplicationMqttTransport(
host,
allow_device_ap=connection_mode == "quick-connect",
)
@_serialized_acquisition_access
def state(self) -> dict[str, Any]:
application_control = self._application_control.snapshot().as_dict()
@@ -600,9 +639,10 @@ class XgridsK1CompatibilityService:
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
# Unwrap once at the provisioning service boundary. The plain value is
# kept only in this stack frame, included in a keyed request digest, and
# passed to the reviewed BLE write boundary; it is never journaled.
# Unwrap once at the network service boundary. The plain value is kept
# only in this stack frame, included in a keyed request digest, and
# passed either to the reviewed BLE write or to the short-lived macOS
# CoreWLAN helper over stdin; it is never journaled.
password = request.password.get_secret_value()
request_fingerprint = self._request_fingerprint(
ACTION_NETWORK_PROVISION,
@@ -647,7 +687,8 @@ class XgridsK1CompatibilityService:
raise RuntimeError("другая операция настройки Wi-Fi уже выполняется")
session_dir: Path | None = None
write_attempted = False
network_change_attempted = False
quick_connect = request.connection_mode == "quick-connect"
try:
with self._lock:
active_acquisition = self._acquisition
@@ -659,55 +700,109 @@ class XgridsK1CompatibilityService:
"нельзя менять устройство или его сеть во время активной acquisition-сессии"
)
self._provisioning_active = True
# Once a new network operation is admitted, the previous route
# and device session can no longer be represented as current.
# This is especially important when Quick Connect may move the
# host off the prior LAN before CoreWLAN reports an outcome.
self._selected_device_id = None
self._k1_ip = None
self._connection_mode = None
self._compatibility_attestation = None
self._device_session_id = None
self._device_session_opened_at = None
self._connection_verification = {
"status": "not-probed",
"endpoint_validation": "not-performed",
"network_reachability": "unknown",
"observed_at": None,
}
# A reprovision can replace both the device session and its address.
# A connection-mode change can replace both the device session and
# its address.
self._application_control.disarm()
# Revoke preview/producer state only after the active-acquisition
# guard; a rejected network write must never stop evidence capture.
self.camera_preview.stop_current()
self._set_operation(
"provisioning",
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
operation_message = (
"Подключаем этот Mac к точке доступа выбранного K1 одним запросом."
if quick_connect
else "Передаём устройству настройки Wi-Fi одним подтверждённым запросом."
)
self._set_operation("provisioning", operation_message)
session_dir = _new_operation_session_dir(
self.evidence_root,
"viewer_wifi_provisioning",
"viewer_k1_ap_association"
if quick_connect
else "viewer_wifi_provisioning",
)
session_dir.mkdir(parents=True, exist_ok=False)
self._operations.transition(
operation.operation_id,
"running",
stage_code="ble-provisioning-write",
stage_code=(
"host-wifi-association" if quick_connect else "ble-provisioning-write"
),
message_code="network.provision.running",
)
write_attempted = True
result = await provision_wifi_once(
request.device_id,
request.ssid,
password,
timeout_seconds=45.0,
write_mode="auto",
)
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
ipv4 = _provisioned_ipv4(result)
local_address_conflict = ipv4 is not None and _target_is_local_ipv4(ipv4)
write_json_atomic(
session_dir / "manifest.redacted.json",
{
network_change_attempted = True
if quick_connect:
started_at = _utc_now_iso()
association = await asyncio.to_thread(
associate_with_wifi_once,
self.repository_root
/ "plugins"
/ "xgrids-k1"
/ "macos"
/ "associate_wifi.swift",
request.ssid,
password,
timeout_seconds=45.0,
)
completed_at = _utc_now_iso()
ipv4: str | None = AP_FALLBACK_IPV4
connection_manifest: dict[str, Any] = {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": completed_at,
"operation": "single_corewlan_k1_ap_association",
"connection_mode": request.connection_mode,
"topology": request.compatibility_attestation.topology,
"outcome": association["outcome"],
"credentials_persisted_by_connector": False,
}
else:
result = await provision_wifi_once(
request.device_id,
request.ssid,
password,
timeout_seconds=45.0,
write_mode="auto",
)
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
ipv4 = _provisioned_ipv4(result)
connection_manifest = {
"schema_version": 1,
"started_at_utc": result["started_at_utc"],
"completed_at_utc": result["completed_at_utc"],
"operation": "single_reviewed_wifi_provisioning_write",
"profile_id": result["profile_id"],
"connection_mode": request.connection_mode,
"topology": request.compatibility_attestation.topology,
"outcome": result["outcome"],
"k1_lan_address_observed": ipv4 is not None,
"k1_lan_address_admitted": ipv4 is not None
and not local_address_conflict,
"local_address_conflict": local_address_conflict,
"credentials_persisted_by_connector": False,
},
}
local_address_conflict = ipv4 is not None and _target_is_local_ipv4(ipv4)
connection_manifest.update(
{
"k1_lan_address_observed": ipv4 is not None,
"k1_lan_address_admitted": (
ipv4 is not None and not local_address_conflict
),
"local_address_conflict": local_address_conflict,
}
)
write_json_atomic(session_dir / "manifest.redacted.json", connection_manifest)
if ipv4 is None:
raise RuntimeError(
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
@@ -730,9 +825,21 @@ class XgridsK1CompatibilityService:
self._compatibility_attestation = _attestation_snapshot(
request.compatibility_attestation
)
self._operation_message = (
"Устройство подключено к Wi-Fi и сообщило локальный адрес."
)
self._connection_verification = {
"status": "not-probed",
"endpoint_validation": "not-performed",
"network_reachability": "unknown",
"observed_at": None,
}
self._operation_message = {
"bridge": "K1 подключён к общей сети и сообщил локальный адрес.",
"direct-connect": (
"K1 подключён к хотспоту контроллера и сообщил локальный адрес."
),
"quick-connect": (
"Mission Core подключён к точке доступа K1; адрес K1 подтверждён."
),
}[request.connection_mode]
self._operations.transition(
operation.operation_id,
"succeeded",
@@ -742,6 +849,13 @@ class XgridsK1CompatibilityService:
"device_id": self._device_id,
"device_session_id": self._device_session_id,
"lan_address_observed": True,
"connection_mode": request.connection_mode,
"topology": request.compatibility_attestation.topology,
"address_source": (
"reviewed-k1-ap-baseline"
if quick_connect
else "ble-wifi-status"
),
},
evidence_refs=(f"evidence-session-{session_dir.name}",),
)
@@ -753,9 +867,11 @@ class XgridsK1CompatibilityService:
message_code="network.provision.failed",
error=_operation_error(
exc,
category="device",
side_effect_status="unknown" if write_attempted else "none",
safe_to_retry=not write_attempted,
category="transport" if quick_connect else "device",
side_effect_status=(
"unknown" if network_change_attempted else "none"
),
safe_to_retry=not network_change_attempted,
),
evidence_refs=(
(f"evidence-session-{session_dir.name}",) if session_dir is not None else ()
@@ -853,7 +969,7 @@ class XgridsK1CompatibilityService:
if self._k1_ip is None or self._selected_device_id is None:
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
if self._compatibility_attestation is None:
raise RuntimeError("exact FW 3.0.2 direct-LAN profile не выбран")
raise RuntimeError("exact FW 3.0.2 local-network profile не выбран")
acquisition = self._acquisition
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError(
@@ -908,10 +1024,24 @@ class XgridsK1CompatibilityService:
"сначала подключите устройство к Wi-Fi или укажите его локальный адрес"
)
target = validate_private_ipv4(target)
if target == AP_FALLBACK_IPV4:
with self._lock:
connection_mode = self._connection_mode
if connection_mode is None and request.compatibility_attestation.topology != "direct-lan":
raise ValueError(
"адрес точки доступа устройства нельзя использовать как direct-LAN target"
"не-direct-LAN topology требует сначала завершить выбранный connection flow"
)
if connection_mode is not None:
expected_topology = CONNECTION_TOPOLOGY_BY_MODE[connection_mode]
if request.compatibility_attestation.topology != expected_topology:
raise ValueError(
"compatibility attestation не совпадает с активным способом подключения"
)
if target == AP_FALLBACK_IPV4 and connection_mode != "quick-connect":
raise ValueError(
"адрес точки доступа K1 разрешён только после Quick Connect"
)
if connection_mode == "quick-connect" and target != AP_FALLBACK_IPV4:
raise ValueError("Quick Connect должен использовать подтверждённый AP-адрес K1")
if control_mode == "plugin-commanded":
with self._lock:
control_target = self._k1_ip
@@ -1830,6 +1960,7 @@ class XgridsK1CompatibilityService:
current_session_id = self._device_session_id
target = self._k1_ip
attestation = self._compatibility_attestation
connection_mode = self._connection_mode
if current_session_id is None or device_session_id != current_session_id:
raise ValueError("указана неактивная device-сессия")
if attestation is None:
@@ -1837,8 +1968,10 @@ class XgridsK1CompatibilityService:
if target is None:
raise ValueError("у плагина нет подтверждённого локального адреса K1")
target = validate_private_ipv4(target)
if target == AP_FALLBACK_IPV4:
raise ValueError("адрес точки доступа K1 нельзя использовать как camera target")
if target == AP_FALLBACK_IPV4 and connection_mode != "quick-connect":
raise ValueError("адрес точки доступа K1 не принят для этой device-сессии")
if connection_mode == "quick-connect" and target != AP_FALLBACK_IPV4:
raise ValueError("Quick Connect camera target не совпадает с AP-адресом K1")
return target
def _require_acquisition(self, acquisition_id: str | None) -> AcquisitionRecord:
@@ -2647,7 +2780,7 @@ def _validate_installed_compatibility_profile(repository_root: Path) -> None:
plugin_root = repository_root.resolve() / "plugins" / "xgrids-k1"
loader_path = plugin_root / "profile_loader.py"
profile_path = plugin_root / "profiles" / "fw-3.0.2" / "direct-lan.v1.json"
profile_path = plugin_root / "profiles" / "fw-3.0.2" / "local-network.v2.json"
spec = importlib.util.spec_from_file_location(
"missioncore_installed_xgrids_k1_profile_loader",
loader_path,
@@ -0,0 +1,99 @@
from __future__ import annotations
import json
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any, TypedDict
class HostWifiAssociationResult(TypedDict):
schema_version: int
adapter: str
outcome: str
already_associated: bool
class HostWifiAssociationError(RuntimeError):
"""One bounded host-side Wi-Fi association attempt failed."""
def __init__(self, reason_code: str) -> None:
self.reason_code = reason_code
super().__init__(f"macOS Wi-Fi association failed: {reason_code}")
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
def associate_with_wifi_once(
helper_path: Path,
ssid: str,
password: str,
*,
timeout_seconds: float = 45.0,
runner: RunProcess = subprocess.run,
) -> HostWifiAssociationResult:
"""Associate the Mac with one operator-selected Wi-Fi network exactly once.
The credential is sent to the short-lived CoreWLAN helper through stdin. It
never appears in argv, the environment, stdout, stderr, or a persisted
artifact. The helper performs at most one scan and one association call.
"""
if sys.platform != "darwin":
raise HostWifiAssociationError("unsupported-platform")
if not helper_path.is_file():
raise HostWifiAssociationError("corewlan-helper-missing")
if not 1 <= len(ssid.encode("utf-8")) <= 32:
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
if not 1 <= len(password.encode("utf-8")) <= 64:
raise ValueError("Wi-Fi password must contain between 1 and 64 UTF-8 bytes")
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
request_bytes = bytearray(
json.dumps(
{"ssid": ssid, "password": password},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
)
try:
completed = runner(
["/usr/bin/xcrun", "swift", str(helper_path.resolve())],
input=request_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout_seconds,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise HostWifiAssociationError("corewlan-helper-unavailable") from exc
finally:
request_bytes[:] = b"\x00" * len(request_bytes)
if len(completed.stdout) > 4096:
raise HostWifiAssociationError("corewlan-response-too-large")
try:
response: Any = json.loads(completed.stdout.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise HostWifiAssociationError("corewlan-response-invalid") from exc
if not isinstance(response, dict):
raise HostWifiAssociationError("corewlan-response-invalid")
reason_code = response.get("reason_code")
if completed.returncode != 0 or response.get("ok") is not True:
if not isinstance(reason_code, str) or not reason_code:
reason_code = "corewlan-association-failed"
raise HostWifiAssociationError(reason_code)
already_associated = response.get("already_associated")
if not isinstance(already_associated, bool):
raise HostWifiAssociationError("corewlan-response-invalid")
return {
"schema_version": 1,
"adapter": "CoreWLAN",
"outcome": "already-associated" if already_associated else "associated",
"already_associated": already_associated,
}
@@ -19,7 +19,7 @@ MAX_APPLICATION_PAYLOAD_BYTES = 64 * 1024
MAX_HEADER_BYTES = 4 * 1024
MAX_TEXT_BYTES = 4 * 1024
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
REVIEWED_DEVICE_MODEL = "LixelKity K1"
REVIEWED_DEVICE_TYPE = "A4"
REVIEWED_PROFILE_ATTESTATION_FAILURE = (
@@ -73,8 +73,10 @@ class ApplicationControlAuthority:
openapi_key: str = field(repr=False)
source: Literal["owner-captured-lixelgo-application"] = "owner-captured-lixelgo-application"
compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = (
"xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
compatibility_profile_id: Literal[
"xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
] = (
"xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"
)
def __post_init__(self) -> None:
@@ -292,11 +292,12 @@ class ReviewedApplicationMqttTransport:
port: int = 1883,
connect_timeout_seconds: float = CONTROL_CONNECT_TIMEOUT_SECONDS,
exchange_timeout_seconds: float = CONTROL_EXCHANGE_TIMEOUT_SECONDS,
allow_device_ap: bool = False,
client_factory: Callable[[], mqtt.Client] | None = None,
monotonic: Callable[[], float] = time.monotonic,
) -> None:
self._target_ipv4 = validate_private_ipv4(host)
if self._target_ipv4 == AP_FALLBACK_IPV4:
if self._target_ipv4 == AP_FALLBACK_IPV4 and not allow_device_ap:
raise ValueError("K1 access-point fallback address is not a direct-LAN control target")
if not 1 <= port <= 65535:
raise ValueError("port must be between 1 and 65535")