From 52da9b75b749aeacb5ae110ff9a767e2412f7d06 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 6 Aug 2026 12:43:00 +0300 Subject: [PATCH] fix(k1): harden BLE discovery and bridge recovery --- .../test/devicePluginContracts.test.mjs | 58 +++ .../devicePluginFrontendBoundary.test.mjs | 24 + plugins/xgrids-k1/frontend/src/api.ts | 9 + .../src/components/K1ProvisioningPipeline.tsx | 33 +- .../frontend/src/useXgridsK1Runtime.ts | 13 +- .../xgrids_k1/ble/ap_activation.py | 14 +- .../xgrids_k1/ble/wifi_provisioning.py | 13 +- src/k1link/device_plugins/xgrids_k1/facade.py | 300 +++++++++++- src/k1link/web/runtime_diagnostics.py | 7 + tests/test_plugin_runtime.py | 61 +++ tests/test_viewer_diagnostics_api.py | 14 + tests/test_wifi_provisioning.py | 40 ++ tests/test_xgrids_acquisition_lifecycle.py | 426 +++++++++++++++++- tests/test_xgrids_ap_activation.py | 46 ++ 14 files changed, 1010 insertions(+), 48 deletions(-) diff --git a/apps/control-station/test/devicePluginContracts.test.mjs b/apps/control-station/test/devicePluginContracts.test.mjs index ff655da..9ae693e 100644 --- a/apps/control-station/test/devicePluginContracts.test.mjs +++ b/apps/control-station/test/devicePluginContracts.test.mjs @@ -19,6 +19,7 @@ let presentation; let operatorIntentGeneration; let configuration; let compatibility; +let networkProvisionFailureMessage; before(async () => { server = await createServer({ @@ -62,6 +63,9 @@ before(async () => { ({ localizeRuntimeMessage } = await server.ssrLoadModule( "@xgrids-k1/frontend/messages.ts", )); + ({ networkProvisionFailureMessage } = await server.ssrLoadModule( + "@xgrids-k1/frontend/useXgridsK1Runtime.ts", + )); }); after(async () => { @@ -216,6 +220,7 @@ test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions", assert.equal(xgridsK1Actions.acquisitionPrepare, "acquisition.prepare"); assert.equal(xgridsK1Actions.acquisitionStart, "acquisition.start"); assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop"); + assert.equal(xgridsK1Actions.connectionVerify, "connection.verify"); }); test("one operator action can open at most one K1 control session", () => { @@ -693,3 +698,56 @@ test("device mutations send explicit nested compatibility attestation", async () assert.deepEqual(prepare.input.compatibility_attestation, attestation); assert.equal(prepare.input.project_name, "Mission 01"); }); + +test("connection verification supports refresh and explicit read-only adoption", async () => { + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (path, init) => { + calls.push({ path, init }); + return new Response(JSON.stringify({ state: { source_mode: "idle" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + const attestation = { + firmware_version: "3.0.2", + topology: "direct-lan", + verification: "live-device-info", + }; + + try { + await xgridsK1Api.verifyConnection(); + await xgridsK1Api.verifyConnection({ + device_id: "fresh-ble-device", + compatibility_attestation: attestation, + }); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(calls.length, 2); + assert.match(String(calls[0].path), /actions\/connection\.verify$/); + assert.match(String(calls[1].path), /actions\/connection\.verify$/); + assert.deepEqual(JSON.parse(calls[0].init.body), { input: {} }); + const adoption = JSON.parse(calls[1].init.body).input; + assert.deepEqual(adoption, { + device_id: "fresh-ble-device", + compatibility_attestation: attestation, + }); + assert.equal("ssid" in adoption, false); + assert.equal("password" in adoption, false); + assert.equal("connection_mode" in adoption, false); +}); + +test("rejected network-profile writes use safe operator copy", () => { + const message = networkProvisionFailureMessage({ + status: "failed", + error: { code: "BleakGATTProtocolError" }, + }); + + assert.equal( + message, + "Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.", + ); + assert.doesNotMatch(message, /Bleak|GATT|ATT/i); +}); diff --git a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs index 5303f9b..f716eeb 100644 --- a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs +++ b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs @@ -162,6 +162,30 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline" assert.match(spatialControls, /Повторить остановку/); }); +test("K1 Bridge adoption remains one explicit read-only plugin action", () => { + const provisioning = readFileSync( + join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"), + "utf8", + ); + const selectionEffectStart = provisioning.indexOf("useEffect(() => {"); + const selectionEffectEnd = provisioning.indexOf( + "useEffect(() => {", + selectionEffectStart + 1, + ); + const selectionEffect = provisioning.slice(selectionEffectStart, selectionEffectEnd); + assert.match(provisioning, /connectionMode === "bridge"/); + assert.match(provisioning, /Подхватить существующее подключение/); + assert.match(provisioning, /pendingAction === "verify"/); + assert.match( + provisioning, + /compatibility_attestation: profileSelectionForConnectionMode\("bridge"\)/, + ); + assert.match(provisioning, /без изменения настроек Wi‑Fi/); + assert.match(provisioning, /deviceSummary !== undefined/); + assert.match(selectionEffect, /!state\.devices\.some/); + assert.match(selectionEffect, /setSelectedDeviceId\(""\)/); +}); + test("generic Control Station has one composition import and no K1 implementation knowledge", () => { const compositionPath = join(coreSourceRoot, "composition/devicePlugins.ts"); const composition = readFileSync(compositionPath, "utf8"); diff --git a/plugins/xgrids-k1/frontend/src/api.ts b/plugins/xgrids-k1/frontend/src/api.ts index 4b98baf..538e445 100644 --- a/plugins/xgrids-k1/frontend/src/api.ts +++ b/plugins/xgrids-k1/frontend/src/api.ts @@ -386,6 +386,11 @@ export interface ConnectRequest { idempotency_key?: string; } +export interface ConnectionVerifyRequest { + device_id: string; + compatibility_attestation: CompatibilityAttestation; +} + export interface PrepareAcquisitionRequest { project_name: string; mount_type: "handheld"; @@ -593,6 +598,10 @@ export const xgridsK1Api = { return invokeState(xgridsK1Actions.networkProvision, body); }, + verifyConnection(body?: ConnectionVerifyRequest): Promise { + return invokeState(xgridsK1Actions.connectionVerify, body); + }, + prepareAcquisition(body: PrepareAcquisitionRequest): Promise { return invokeState(xgridsK1Actions.acquisitionPrepare, body); }, diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx index 8d02500..e532b2d 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx @@ -118,7 +118,7 @@ export function K1ProvisioningPipeline({ phaseLabel: string; phaseTone: StatusTone; }) { - const { state, pendingAction, scan, connect } = controller; + const { state, pendingAction, scan, connect, verifyConnection } = controller; const [powerConfirmed, setPowerConfirmed] = useState(false); const [selectedDeviceId, setSelectedDeviceId] = useState(""); const [ssid, setSsid] = useState(""); @@ -160,6 +160,11 @@ export function K1ProvisioningPipeline({ () => devices.find((device) => device.device_id === selectedDeviceId), [devices, selectedDeviceId], ); + const canAdoptExistingBridge = connectionMode === "bridge" + && powerConfirmed + && selectedDeviceId.length > 0 + && deviceSummary !== undefined + && !isBusy; const resetProvisioningIntent = () => { provisioningIntentRef.current = null; @@ -190,6 +195,14 @@ export function K1ProvisioningPipeline({ } }; + const submitExistingBridgeAdoption = async () => { + if (!canAdoptExistingBridge) return; + await verifyConnection({ + device_id: selectedDeviceId, + compatibility_attestation: profileSelectionForConnectionMode("bridge"), + }); + }; + return (
@@ -275,6 +288,24 @@ export function K1ProvisioningPipeline({ ? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…" : modeCopy.buttonLabel} + {connectionMode === "bridge" ? ( + <> + +

+ Если K1 уже подключён к этой сети другим способом, Mission Core проверит и примет существующее подключение без изменения настроек Wi‑Fi. +

+ + ) : null}

{modeCopy.safetyNote} {connectionMode === "quick-connect" ? " Это лабораторный путь для уже подготовленного хоста: credential provider должен существовать в системном хранилище заранее. На чистом Mac операция завершится до BLE-записи; браузер, API, журналы и evidence секрета не получают." : " Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха."} diff --git a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts index 420d42c..9c74b36 100644 --- a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts +++ b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts @@ -6,6 +6,7 @@ import { ApiError, xgridsK1Api, openEventSocket, + type ConnectionVerifyRequest, type ConnectRequest, type EventSocketStatus, type OpenApplicationControlSessionRequest, @@ -34,6 +35,7 @@ import { selectMonotonicXgridsState } from "./stateOrdering"; export type PendingAction = | "scan" | "connect" + | "verify" | "control" | "live" | "replay" @@ -191,7 +193,7 @@ function messageFor(error: unknown): string { return "Запрос к локальному сервису устройства завершился ошибкой."; } -function networkProvisionFailureMessage( +export function networkProvisionFailureMessage( operation: XgridsOperation | null | undefined, ): string | null { if (!operation || operation.status !== "failed") return null; @@ -199,6 +201,8 @@ function networkProvisionFailureMessage( if (typeof code !== "string") return null; const messages: Record = { + BleakGATTProtocolError: + "Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.", "network-not-found": "Точка доступа выбранного K1 не найдена. Команда включения точки не повторялась; проверьте питание и состояние K1.", "credential-entry-cancelled": @@ -392,6 +396,12 @@ export function useXgridsK1Runtime(enabled: boolean) { [acceptState, run, state], ); + const verifyConnection = useCallback( + (request?: ConnectionVerifyRequest) => + run("verify", () => xgridsK1Api.verifyConnection(request)), + [run], + ); + const openApplicationControlSession = useCallback( (request: OpenApplicationControlSessionRequest) => run("control", () => xgridsK1Api.openApplicationControlSession(request)), @@ -773,6 +783,7 @@ export function useXgridsK1Runtime(enabled: boolean) { clearError: () => setError(null), scan, connect, + verifyConnection, openApplicationControlSession, enterApplicationWorkspace, closeApplicationControlSession, diff --git a/src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py b/src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py index b8ca8c7..4a48935 100644 --- a/src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py +++ b/src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py @@ -11,7 +11,6 @@ from bleak import BleakClient, BleakScanner from bleak.exc import BleakDeviceNotFoundError, BleakError from k1link.artifacts import utc_now_iso -from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import ( AP_FALLBACK_IPV4, SERVICE_UUID, @@ -128,12 +127,13 @@ async def device_ap_activation_session( try: async with asyncio.timeout(timeout_seconds + 25.0): - device = discovered_device(device_macos_uuid) - if device is None: - device = await BleakScanner.find_device_by_address( - device_macos_uuid, - timeout=min(20.0, timeout_seconds), - ) + # AP activation is a mutation boundary: prove that the selected + # device is advertising now instead of trusting a CoreBluetooth + # handle retained by an earlier UI scan. + device = await BleakScanner.find_device_by_address( + device_macos_uuid, + timeout=min(20.0, timeout_seconds), + ) if device is None: raise BleakDeviceNotFoundError( device_macos_uuid, diff --git a/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py b/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py index c6e7a6f..b8ba0d0 100644 --- a/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py +++ b/src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py @@ -239,12 +239,13 @@ async def provision_wifi_once( try: async with asyncio.timeout(timeout_seconds + 25.0): - device = discovered_device(device_macos_uuid) - if device is None: - device = await BleakScanner.find_device_by_address( - device_macos_uuid, - timeout=min(20.0, timeout_seconds), - ) + # A provisioning write is a mutation boundary: prove that the + # selected device is advertising now instead of trusting a + # CoreBluetooth handle retained by an earlier UI scan. + device = await BleakScanner.find_device_by_address( + device_macos_uuid, + timeout=min(20.0, timeout_seconds), + ) if device is None: raise BleakDeviceNotFoundError( device_macos_uuid, diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index 30f3b93..b4f320d 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -128,6 +128,7 @@ XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.local-network DEFAULT_ACQUISITION_CAMERA_SOURCE: CameraSourceId = "sensor.camera.right" CONTROL_MQTT_PORT = 1883 CONTROL_ENDPOINT_PROBE_TIMEOUT_SECONDS = 1.5 +BLE_DISCOVERY_LEASE_TTL_SECONDS = 60.0 logger = logging.getLogger(__name__) @@ -277,6 +278,28 @@ class ConnectRequest(StrictRequest): return self +class ConnectionVerifyRequest(StrictRequest): + """Refresh one lease or adopt a scanned K1 already on the direct LAN.""" + + device_id: str | None = Field(default=None, min_length=1, max_length=128) + compatibility_attestation: CompatibilityAttestationRequest | None = None + + @model_validator(mode="after") + def validate_adoption_request(self) -> Self: + has_device = self.device_id is not None + has_attestation = self.compatibility_attestation is not None + if has_device != has_attestation: + raise ValueError( + "device_id and compatibility_attestation must be provided together" + ) + if ( + self.compatibility_attestation is not None + and self.compatibility_attestation.topology != "direct-lan" + ): + raise ValueError("connection.verify adoption requires topology=direct-lan") + return self + + class LiveRequest(StrictRequest): project_name: str = Field(min_length=1, max_length=96) host: str | None = Field(default=None, max_length=15) @@ -422,6 +445,8 @@ class XgridsK1CompatibilityService: self._provisioning_active = False self._fingerprint_key = secrets.token_bytes(32) self._devices: list[dict[str, Any]] = [] + self._ble_discovery_generation = 0 + self._ble_device_last_seen_monotonic: dict[str, float] = {} self._selected_device_id: str | None = None self._k1_ip: str | None = None self._connection_mode: ConnectionMode | None = None @@ -494,6 +519,25 @@ class XgridsK1CompatibilityService: allow_device_ap=connection_mode == "quick-connect", ) + def _fresh_ble_devices_locked( + self, + *, + now_monotonic: float | None = None, + ) -> list[dict[str, Any]]: + """Return only candidates leased by the current in-memory scan generation.""" + + now = time.monotonic() if now_monotonic is None else now_monotonic + fresh: list[dict[str, Any]] = [] + for item in self._devices: + device_id = str(item.get("device_id") or "") + last_seen = self._ble_device_last_seen_monotonic.get(device_id) + if last_seen is None: + continue + age_seconds = now - last_seen + if 0.0 <= age_seconds <= BLE_DISCOVERY_LEASE_TTL_SECONDS: + fresh.append(dict(item)) + return fresh + @_serialized_acquisition_access def state(self) -> dict[str, Any]: application_control = self._application_control.snapshot().as_dict() @@ -507,7 +551,8 @@ class XgridsK1CompatibilityService: with self._lock: operation_phase = self._operation_phase operation_message = self._operation_message - devices = list(self._devices) + devices = self._fresh_ble_devices_locked() + discovery_stale = bool(self._devices) and not devices selected_device_id = self._selected_device_id k1_ip = self._k1_ip connection_mode = self._connection_mode @@ -572,6 +617,9 @@ class XgridsK1CompatibilityService: f"Найдено BLE-устройств: {len(devices)}. " f"Совместимых профилей: {likely_count}. Выберите нужное устройство." ) + elif discovery_stale: + phase = "idle" + message = "Результаты Bluetooth-поиска устарели. Выполните поиск ещё раз." else: phase = "idle" message = runtime["message"] @@ -686,6 +734,15 @@ class XgridsK1CompatibilityService: } async def scan_ble(self, duration_seconds: float) -> dict[str, Any]: + with self._lock: + if self._provisioning_active: + raise RuntimeError("нельзя запускать BLE-поиск во время настройки Wi-Fi") + self._ble_discovery_generation += 1 + scan_generation = self._ble_discovery_generation + # Discovery results are an in-memory lease, not a durable catalog. + # A new explicit scan invalidates the previous generation before I/O. + self._devices = [] + self._ble_device_last_seen_monotonic = {} operation, _ = self._operations.begin( ACTION_DISCOVERY_SCAN, deadline_seconds=duration_seconds + 10.0, @@ -710,9 +767,16 @@ class XgridsK1CompatibilityService: } for item in result["devices"] ] + observed_monotonic = time.monotonic() with self._lock: - self._devices = devices - self._operation_message = f"Поиск завершён. Найдено BLE-устройств: {len(devices)}." + if self._ble_discovery_generation == scan_generation: + self._devices = devices + self._ble_device_last_seen_monotonic = { + str(item["device_id"]): observed_monotonic for item in devices + } + self._operation_message = ( + f"Поиск завершён. Найдено BLE-устройств: {len(devices)}." + ) self._operations.transition( operation.operation_id, "succeeded", @@ -721,6 +785,10 @@ class XgridsK1CompatibilityService: result={"candidate_count": len(devices)}, ) except Exception as exc: + with self._lock: + if self._ble_discovery_generation == scan_generation: + self._devices = [] + self._ble_device_last_seen_monotonic = {} self._operations.transition( operation.operation_id, "failed", @@ -731,16 +799,20 @@ class XgridsK1CompatibilityService: raise finally: with self._lock: - self._operation_phase = None + if self._ble_discovery_generation == scan_generation: + self._operation_phase = None return self.state() async def connect(self, request: ConnectRequest) -> dict[str, Any]: - known_ids = {str(item["device_id"]) for item in self.state()["devices"]} + with self._lock: + scanned_devices = self._fresh_ble_devices_locked() + discovery_generation = self._ble_discovery_generation + known_ids = {str(item["device_id"]) for item in scanned_devices} if request.device_id not in known_ids: raise ValueError("сначала найдите и выберите устройство через Bluetooth") quick_connect = request.connection_mode == "quick-connect" selected_device = next( - item for item in self.state()["devices"] if item["device_id"] == request.device_id + item for item in scanned_devices if item["device_id"] == request.device_id ) selected_device_name = str(selected_device.get("name") or "").strip() control_snapshot = self._application_control_session.snapshot() @@ -860,6 +932,18 @@ class XgridsK1CompatibilityService: raise HostWifiProfileError("credential-source-unavailable") with self._lock: + if ( + self._ble_discovery_generation != discovery_generation + or request.device_id + not in { + str(item["device_id"]) + for item in self._fresh_ble_devices_locked() + } + ): + raise ValueError( + "результаты Bluetooth-поиска изменились или устарели; " + "выполните поиск ещё раз" + ) active_acquisition = self._acquisition if ( active_acquisition is not None @@ -1142,21 +1226,41 @@ class XgridsK1CompatibilityService: evidence_refs=(f"evidence-session-{session_dir.name}",), ) except Exception as exc: + operation_error = _operation_error( + exc, + category="transport" if quick_connect else "device", + side_effect_status=("unknown" if network_change_attempted else "none"), + safe_to_retry=not network_change_attempted, + ) self._operations.transition_if_pending( operation.operation_id, "failed", stage_code=f"{operation_stage}-failed", message_code="network.provision.failed", - error=_operation_error( - exc, - category="transport" if quick_connect else "device", - side_effect_status=("unknown" if network_change_attempted else "none"), - safe_to_retry=not network_change_attempted, - ), + error=operation_error, evidence_refs=( (f"evidence-session-{session_dir.name}",) if session_dir is not None else () ), ) + # Never log the request, SSID, password, BLE payload, or exception + # text here: lower transport errors may embed sensitive frame data. + # The structured record is sufficient to correlate the HTTP 502 + # with its operation stage and retry/side-effect safety contract. + logger.error( + "K1 network provisioning failed", + extra={ + "event_code": "k1_network_provision_failed", + "operation_id": operation.operation_id, + "operation_stage": operation_stage, + "connection_mode": request.connection_mode, + "error_category": operation_error["category"], + "error_code": operation_error["code"], + "safe_to_retry": operation_error["safe_to_retry"], + "side_effect_status": operation_error["side_effect_status"], + "network_change_attempted": network_change_attempted, + "automatic_retry": False, + }, + ) raise finally: password = "" @@ -1216,12 +1320,169 @@ class XgridsK1CompatibilityService: payload=segment.payload, ) - def verify_connection(self) -> dict[str, Any]: - """Refresh the session-scoped DHCP address from the read-only BLE status.""" + def verify_connection( + self, + request: ConnectionVerifyRequest | None = None, + ) -> dict[str, Any]: + """Refresh a lease or adopt an externally configured direct-LAN K1.""" - self._refresh_live_lan_address(rediscover=True) + request = request or ConnectionVerifyRequest() + if request.device_id is None: + self._refresh_live_lan_address(rediscover=True) + else: + assert request.compatibility_attestation is not None + self._adopt_existing_lan_connection( + request.device_id, + request.compatibility_attestation, + ) return self.state() + def _adopt_existing_lan_connection( + self, + device_id: str, + compatibility_attestation: CompatibilityAttestationRequest, + ) -> str: + """Create a process lease from BLE status without changing K1 Wi-Fi state.""" + + if compatibility_attestation.topology != "direct-lan": + raise ValueError("read-only K1 adoption supports only direct-lan topology") + with self._lock: + scanned_device = next( + ( + dict(item) + for item in self._fresh_ble_devices_locked() + if str(item.get("device_id")) == device_id + ), + None, + ) + discovery_generation = self._ble_discovery_generation + acquisition = self._acquisition + acquisition_session_lease = self._acquisition_session_lease + provisioning_active = self._provisioning_active + previous_target = self._k1_ip + if scanned_device is None: + raise ValueError("сначала найдите и выберите устройство через Bluetooth") + if scanned_device.get("connectable") is False: + raise ValueError("выбранное Bluetooth-устройство сейчас недоступно для подключения") + if provisioning_active: + raise RuntimeError("нельзя проверять существующую сеть во время настройки Wi-Fi") + if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES: + raise RuntimeError( + "нельзя менять connection lease во время активной acquisition-сессии" + ) + if acquisition_session_lease is not None: + raise RuntimeError("предыдущая evidence-сессия ещё не остановлена и не запечатана") + runtime_state = self.runtime.snapshot() + if runtime_state.get("source_mode") != "idle": + raise RuntimeError("нельзя менять connection lease при активном live/replay источнике") + + if not self._provisioning_gate.acquire(blocking=False): + raise RuntimeError("другая операция настройки или проверки сети уже выполняется") + try: + status_read = asyncio.run( + read_wifi_status_once( + device_id, + timeout_seconds=20.0, + rediscover=True, + ) + ) + if ( + status_read.get("operation") != "single_reviewed_wifi_status_read" + or status_read.get("write_performed") is not False + ): + raise RuntimeError("BLE status read не подтвердил read-only операцию") + observed_target = status_read["status"]["ipv4"] + if observed_target is None or observed_target == AP_FALLBACK_IPV4: + raise RuntimeError("K1 не сообщил актуальный DHCP-адрес общей сети") + target = validate_private_ipv4(observed_target) + if _target_is_local_ipv4(target): + raise RuntimeError("BLE status сообщил адрес, принадлежащий этому компьютеру") + host_route_class = _host_route_class(target) + if host_route_class in {"default-route", "tunnel"}: + raise RuntimeError( + "K1 сообщил адрес другой сети; прямой локальный маршрут отсутствует" + ) + if not _control_endpoint_reachable(target): + raise RuntimeError( + "K1 сообщил адрес общей сети, но MQTT endpoint 1883 недоступен" + ) + + # Retire only local, terminal control ownership after every external + # observation has passed. No scanner command is sent by this call. + self._application_control_session.retire_for_network_change() + observed_at = str(status_read["observed_at_utc"]) + with self._lock: + if self._provisioning_active: + raise RuntimeError("настройка Wi-Fi началась во время проверки сети") + if self._ble_discovery_generation != discovery_generation or not any( + str(item.get("device_id")) == device_id for item in self._devices + ): + raise RuntimeError("список найденных Bluetooth-устройств изменился") + current_acquisition = self._acquisition + if ( + current_acquisition is not None + and current_acquisition.state not in TERMINAL_ACQUISITION_STATES + ): + raise RuntimeError( + "acquisition-сессия началась во время проверки существующей сети" + ) + if self._acquisition_session_lease is not None: + raise RuntimeError( + "evidence-сессия изменилась во время проверки существующей сети" + ) + persistent_device_id = self._device_ids_by_transport_ref.get(device_id) + if persistent_device_id is None: + persistent_device_id = new_device_id() + self._device_ids_by_transport_ref[device_id] = persistent_device_id + self._selected_device_id = device_id + self._k1_ip = target + self._connection_mode = "bridge" + self._device_id = persistent_device_id + self._device_session_id = new_device_session_id() + self._device_session_opened_at = observed_at + self._connection_lease_generation += 1 + self._compatibility_attestation = _attestation_snapshot( + compatibility_attestation + ) + self._device_calibration = unavailable_device_calibration_snapshot( + XGRIDS_K1_COMPATIBILITY_PROFILE_ID + ) + self._connection_verification = { + "status": "adopted", + "lease_state": "reachable", + "lease_generation": self._connection_lease_generation, + "endpoint_validation": "ble-wifi-status-read+mqtt-tcp-connect", + "network_reachability": "reachable", + "host_route_class": host_route_class, + "address_source": "ble-wifi-status-read", + "connection_origin": "external-existing-network", + "admission_source": ACTION_CONNECTION_VERIFY, + "address_changed": previous_target != target, + "previous_address_present": previous_target is not None, + "write_performed": False, + "observed_at": observed_at, + } + self._operation_message = ( + "K1 найден в существующей локальной сети без изменения его настроек." + ) + lease_generation = self._connection_lease_generation + logger.info( + "K1 existing direct-LAN connection adopted without provisioning", + extra={ + "event_code": "k1_existing_lan_connection_adopted", + "lease_generation": lease_generation, + "connection_origin": "external-existing-network", + "address_source": "ble-wifi-status-read", + "host_route_class": host_route_class, + "endpoint_reachable": True, + "device_write_performed": False, + "automatic_retry": False, + }, + ) + return target + finally: + self._provisioning_gate.release() + def _refresh_live_lan_address(self, *, rediscover: bool = False) -> str: with self._lock: selected_device_id = self._selected_device_id @@ -3156,7 +3417,10 @@ class XgridsK1ServicePort(Protocol): def inspect_device(self) -> dict[str, Any]: ... - def verify_connection(self) -> dict[str, Any]: ... + def verify_connection( + self, + request: ConnectionVerifyRequest | None = None, + ) -> dict[str, Any]: ... def read_device_calibration_snapshot(self) -> dict[str, Any]: ... @@ -3282,8 +3546,8 @@ class XgridsK1PluginFacade: connect_request = ConnectRequest.model_validate(payload) return await self.service.connect(connect_request) if action_id == ACTION_CONNECTION_VERIFY: - EmptyRequest.model_validate(payload) - return await asyncio.to_thread(self.service.verify_connection) + verify_request = ConnectionVerifyRequest.model_validate(payload) + return await asyncio.to_thread(self.service.verify_connection, verify_request) if action_id == ACTION_APPLICATION_CONTROL_SHADOW_STATE: EmptyRequest.model_validate(payload) return await asyncio.to_thread(self.service.state) diff --git a/src/k1link/web/runtime_diagnostics.py b/src/k1link/web/runtime_diagnostics.py index 1e61f4e..3bcb602 100644 --- a/src/k1link/web/runtime_diagnostics.py +++ b/src/k1link/web/runtime_diagnostics.py @@ -13,6 +13,11 @@ SCANNER_DIAGNOSTIC_FILE: Final = "scanner-diagnostics.jsonl" _HANDLER_MARKER: Final = "_mission_core_scanner_diagnostics_path" _EXTRA_FIELDS: Final = ( "event_code", + "operation_id", + "operation_stage", + "connection_mode", + "error_category", + "error_code", "reason_code", "failed_phase", "dialogue_stage", @@ -21,6 +26,8 @@ _EXTRA_FIELDS: Final = ( "modeling_command_attempted", "outcome_unknown", "safe_to_retry", + "side_effect_status", + "network_change_attempted", "status_reconciliation", "network_change_admissible", "network_change_reconciliation", diff --git a/tests/test_plugin_runtime.py b/tests/test_plugin_runtime.py index 9db0896..fc317d9 100644 --- a/tests/test_plugin_runtime.py +++ b/tests/test_plugin_runtime.py @@ -20,6 +20,7 @@ from pydantic import ValidationError import k1link.web.device_plugin_composition as plugin_composition from k1link.device_plugins.xgrids_k1.facade import ( + ACTION_CONNECTION_VERIFY, ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ, ACTION_DISCOVERY_SCAN, ACTION_NETWORK_PROVISION, @@ -30,6 +31,7 @@ from k1link.device_plugins.xgrids_k1.facade import ( XGRIDS_K1_PLUGIN_ID, XGRIDS_K1_PLUGIN_VERSION, CompatibilityAttestationRequest, + ConnectionVerifyRequest, ConnectRequest, ViewerSettingsRequest, XgridsK1PluginFacade, @@ -72,6 +74,13 @@ class FakeXgridsService: self.calls.append(("connect", request)) return {"phase": "connected", "k1_ip": "192.168.1.20"} + def verify_connection( + self, + request: ConnectionVerifyRequest | None = None, + ) -> dict[str, Any]: + self.calls.append(("verify", request)) + return {"phase": "connected", "k1_ip": "192.168.1.20"} + def start_live( self, project_name: str, @@ -158,6 +167,58 @@ def test_calibration_snapshot_action_calls_the_read_only_service_method() -> Non assert service.calls == [("calibration", None)] +def test_connection_verify_action_accepts_read_only_adoption_request() -> None: + service = FakeXgridsService() + dispatcher = DevicePluginDispatcher( + [_in_process_runtime(XgridsK1PluginFacade(service))] + ) + + result = asyncio.run( + dispatcher.invoke( + XGRIDS_K1_PLUGIN_ID, + ACTION_CONNECTION_VERIFY, + { + "device_id": "test-ble-transport", + "compatibility_attestation": { + "firmware_version": "3.0.2", + "topology": "direct-lan", + "verification": "live-device-info", + }, + }, + ) + ) + + assert result == {"phase": "connected", "k1_ip": "192.168.1.20"} + assert len(service.calls) == 1 + action, request = service.calls[0] + assert action == "verify" + assert isinstance(request, ConnectionVerifyRequest) + assert request.device_id == "test-ble-transport" + assert request.compatibility_attestation is not None + assert request.compatibility_attestation.topology == "direct-lan" + + +def test_connection_verify_action_keeps_empty_refresh_request_compatible() -> None: + service = FakeXgridsService() + dispatcher = DevicePluginDispatcher( + [_in_process_runtime(XgridsK1PluginFacade(service))] + ) + + asyncio.run( + dispatcher.invoke( + XGRIDS_K1_PLUGIN_ID, + ACTION_CONNECTION_VERIFY, + {}, + ) + ) + + action, request = service.calls[0] + assert action == "verify" + assert isinstance(request, ConnectionVerifyRequest) + assert request.device_id is None + assert request.compatibility_attestation is None + + def test_repository_runtime_composition_exactly_matches_catalog() -> None: repository_root = Path(__file__).resolve().parents[1] environment = load_installed_device_plugins(repository_root) diff --git a/tests/test_viewer_diagnostics_api.py b/tests/test_viewer_diagnostics_api.py index f52f556..91dfc89 100644 --- a/tests/test_viewer_diagnostics_api.py +++ b/tests/test_viewer_diagnostics_api.py @@ -43,11 +43,18 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded( "field control failure", extra={ "event_code": "k1_application_control_session_failed", + "operation_id": "operation-test-01", + "operation_stage": "ble-provisioning-write", + "connection_mode": "bridge", + "error_category": "device", + "error_code": "BleakGATTProtocolError", "reason_code": "mqtt_network_loop_failed", "mqtt_loop_result_code": 7, "mqtt_loop_result_name": "The connection was lost.", "mqtt_loop_phase": "post-publish-drain", "automatic_retry": False, + "side_effect_status": "unknown", + "network_change_attempted": True, "camera_source_id": "sensor.camera.right", "evidence_session_id": "20260728T163450Z_viewer_live", "activation_trigger": "application-control-scanning", @@ -73,10 +80,17 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded( assert stat.S_IMODE(target.parent.stat().st_mode) == 0o700 assert stat.S_IMODE(target.stat().st_mode) == 0o600 assert document["event_code"] == "k1_application_control_session_failed" + assert document["operation_id"] == "operation-test-01" + assert document["operation_stage"] == "ble-provisioning-write" + assert document["connection_mode"] == "bridge" + assert document["error_category"] == "device" + assert document["error_code"] == "BleakGATTProtocolError" assert document["reason_code"] == "mqtt_network_loop_failed" assert document["mqtt_loop_result_code"] == 7 assert document["mqtt_loop_phase"] == "post-publish-drain" assert document["automatic_retry"] is False + assert document["side_effect_status"] == "unknown" + assert document["network_change_attempted"] is True assert document["camera_source_id"] == "sensor.camera.right" assert document["evidence_session_id"] == "20260728T163450Z_viewer_live" assert document["activation_trigger"] == "application-control-scanning" diff --git a/tests/test_wifi_provisioning.py b/tests/test_wifi_provisioning.py index 45bda9a..30c1923 100644 --- a/tests/test_wifi_provisioning.py +++ b/tests/test_wifi_provisioning.py @@ -3,12 +3,14 @@ from types import SimpleNamespace from typing import Any import pytest +from bleak.exc import BleakDeviceNotFoundError import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import ( FRAME_LENGTH, build_wifi_provisioning_frame, parse_wifi_status, + provision_wifi_once, read_wifi_status_once, ) @@ -185,3 +187,41 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle( ) assert result["status"]["ipv4"] == "10.255.254.77" + + +def test_provisioning_write_requires_fresh_rediscovery_before_connecting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stale_handle = object() + rediscovery_calls: list[tuple[str, float]] = [] + client_calls: list[object] = [] + + async def missing_device(address: str, *, timeout: float) -> None: + rediscovery_calls.append((address, timeout)) + return None + + class ForbiddenClient: + def __init__(self, device: object, **_kwargs: object) -> None: + client_calls.append(device) + raise AssertionError("a failed fresh discovery must stop before the BLE write session") + + monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle) + monkeypatch.setattr( + wifi_module.BleakScanner, + "find_device_by_address", + missing_device, + ) + monkeypatch.setattr(wifi_module, "BleakClient", ForbiddenClient) + + with pytest.raises(BleakDeviceNotFoundError): + asyncio.run( + provision_wifi_once( + "synthetic-corebluetooth-uuid", + "LabNet", + "synthetic-password", + timeout_seconds=1.0, + ) + ) + + assert rediscovery_calls == [("synthetic-corebluetooth-uuid", 1.0)] + assert client_calls == [] diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py index f10d7bb..38412d0 100644 --- a/tests/test_xgrids_acquisition_lifecycle.py +++ b/tests/test_xgrids_acquisition_lifecycle.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import json +import logging import threading from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -18,6 +19,7 @@ from k1link.device_plugins.xgrids_k1.facade import ( AbortAcquisitionRequest, CameraPreviewSelectRequest, CompatibilityAttestationRequest, + ConnectionVerifyRequest, ConnectRequest, OpenApplicationControlSessionRequest, OperatorPresenceRequest, @@ -198,7 +200,7 @@ def service_with_fake_runtime( return service, runtime -def _wifi_status_read(ipv4: str) -> dict[str, Any]: +def _wifi_status_read(ipv4: str | None) -> dict[str, Any]: return { "schema_version": 1, "profile_id": "xgrids-k1-fw3-wifi-v1", @@ -222,6 +224,383 @@ def _wifi_status_read(ipv4: str) -> dict[str, Any]: } +def _set_scanned_devices( + service: XgridsK1CompatibilityService, + devices: list[dict[str, Any]], +) -> None: + service._devices = devices # noqa: SLF001 + observed_monotonic = facade_module.time.monotonic() + service._ble_device_last_seen_monotonic = { # noqa: SLF001 + str(item["device_id"]): observed_monotonic for item in devices + } + + +def _set_scanned_k1( + service: XgridsK1CompatibilityService, + *, + device_id: str = "test-ble-transport", +) -> None: + _set_scanned_devices(service, [ + { + "device_id": device_id, + "name": "XGR-K1", + "rssi": -44, + "address": None, + "connectable": True, + "likely_k1": True, + } + ]) + + +def test_ble_discovery_lease_expiry_hides_and_rejects_candidate( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + _set_scanned_k1(service) + service._ble_device_last_seen_monotonic["test-ble-transport"] = ( # noqa: SLF001 + facade_module.time.monotonic() + - facade_module.BLE_DISCOVERY_LEASE_TTL_SECONDS + - 0.001 + ) + + async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]: + raise AssertionError("an expired candidate must not be rediscovered by an action") + + async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]: + raise AssertionError("an expired candidate must not reach the Wi-Fi write boundary") + + monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read) + monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision) + + stale_state = service.state() + assert stale_state["devices"] == [] + assert "устарели" in stale_state["message"] + with pytest.raises(ValueError, match="найдите и выберите"): + service.verify_connection( + ConnectionVerifyRequest( + device_id="test-ble-transport", + compatibility_attestation=ATTESTATION, + ) + ) + with pytest.raises(ValueError, match="найдите и выберите"): + asyncio.run( + service.connect( + ConnectRequest( + device_id="test-ble-transport", + ssid="lab-network", + password=SecretStr(PRIMARY_TEST_CREDENTIAL), + compatibility_attestation=ATTESTATION, + ) + ) + ) + + +def test_new_ble_scan_generation_invalidates_old_candidates_before_io_and_on_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + _set_scanned_k1(service, device_id="old-scan-device") + + async def scenario() -> None: + entered = asyncio.Event() + release = asyncio.Event() + + async def failing_scan(_duration_seconds: float) -> dict[str, Any]: + entered.set() + await release.wait() + raise RuntimeError("synthetic BLE scan failure") + + monkeypatch.setattr(facade_module, "scan", failing_scan) + scan_task = asyncio.create_task(service.scan_ble(6.0)) + await asyncio.wait_for(entered.wait(), timeout=1.0) + assert service.state()["devices"] == [] + release.set() + with pytest.raises(RuntimeError, match="synthetic BLE scan failure"): + await asyncio.wait_for(scan_task, timeout=1.0) + + asyncio.run(scenario()) + + assert service.state()["devices"] == [] + assert service._devices == [] # noqa: SLF001 + assert service._ble_device_last_seen_monotonic == {} # noqa: SLF001 + + +def test_older_ble_scan_cannot_replace_a_newer_generation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + first_entered = asyncio.Event() + release_first = asyncio.Event() + call_count = 0 + + def scan_result(device_id: str) -> dict[str, Any]: + return { + "devices": [ + { + "macos_uuid": device_id, + "name": "XGR-K1", + "local_name": "XGR-K1", + "rssi": -44, + "k1_name_candidate": True, + } + ] + } + + async def overlapping_scan(_duration_seconds: float) -> dict[str, Any]: + nonlocal call_count + call_count += 1 + if call_count == 1: + first_entered.set() + await release_first.wait() + return scan_result("older-generation") + return scan_result("newer-generation") + + async def scenario() -> dict[str, Any]: + monkeypatch.setattr(facade_module, "scan", overlapping_scan) + older_task = asyncio.create_task(service.scan_ble(6.0)) + await asyncio.wait_for(first_entered.wait(), timeout=1.0) + newer_state = await service.scan_ble(6.0) + release_first.set() + await asyncio.wait_for(older_task, timeout=1.0) + return newer_state + + newer_state = asyncio.run(scenario()) + + assert [item["device_id"] for item in newer_state["devices"]] == ["newer-generation"] + assert [item["device_id"] for item in service.state()["devices"]] == [ + "newer-generation" + ] + + +def test_connect_stops_before_ble_write_when_a_new_scan_replaces_its_generation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + _set_scanned_k1(service) + preflight_entered = threading.Event() + release_preflight = threading.Event() + + def delayed_preflight(*_: object, **__: object) -> dict[str, Any]: + preflight_entered.set() + if not release_preflight.wait(timeout=1.0): + raise RuntimeError("test preflight release timed out") + return { + "schema_version": 1, + "adapter": "macOS Keychain", + "available": True, + "profile_enrolled": False, + "credential_source": "exact-firmware-profile", + } + + async def replacement_scan(_duration_seconds: float) -> dict[str, Any]: + return { + "devices": [ + { + "macos_uuid": "replacement-device", + "name": "XGR-NEW", + "local_name": "XGR-NEW", + "rssi": -40, + "k1_name_candidate": True, + } + ] + } + + @asynccontextmanager + async def forbidden_activation( + *_args: object, + **_kwargs: object, + ) -> AsyncIterator[dict[str, Any]]: + raise AssertionError("changed discovery generation must stop before BLE write") + yield {} + + monkeypatch.setattr( + facade_module, + "ensure_wifi_profile_from_credential_source", + delayed_preflight, + ) + monkeypatch.setattr(facade_module, "scan", replacement_scan) + monkeypatch.setattr(facade_module, "device_ap_activation_session", forbidden_activation) + + async def scenario() -> None: + connect_task = asyncio.create_task( + service.connect( + ConnectRequest( + device_id="test-ble-transport", + connection_mode="quick-connect", + compatibility_attestation=QUICK_CONNECT_ATTESTATION, + ) + ) + ) + entered = await asyncio.wait_for( + asyncio.to_thread(preflight_entered.wait, 1.0), + timeout=1.5, + ) + assert entered is True + replacement_state = await service.scan_ble(6.0) + assert [item["device_id"] for item in replacement_state["devices"]] == [ + "replacement-device" + ] + release_preflight.set() + with pytest.raises(ValueError, match="изменились или устарели"): + await asyncio.wait_for(connect_task, timeout=1.0) + + try: + asyncio.run(scenario()) + finally: + release_preflight.set() + + +def test_verify_connection_adopts_scanned_existing_lan_without_device_write( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + _set_scanned_k1(service) + status_reads: list[tuple[str, float, bool]] = [] + + async def fake_status_read( + device_id: str, + *, + timeout_seconds: float, + rediscover: bool, + ) -> dict[str, Any]: + status_reads.append((device_id, timeout_seconds, rediscover)) + return _wifi_status_read("10.255.254.77") + + async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]: + raise AssertionError("connection.verify must never call Wi-Fi provisioning") + + monkeypatch.setattr(facade_module, "read_wifi_status_once", fake_status_read) + monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision) + monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: False) + monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: "direct-or-routed") + monkeypatch.setattr(facade_module, "_control_endpoint_reachable", lambda _target: True) + + state = service.verify_connection( + ConnectionVerifyRequest( + device_id="test-ble-transport", + compatibility_attestation=ATTESTATION, + ) + ) + + assert status_reads == [("test-ble-transport", 20.0, True)] + assert state["selected_device_id"] == "test-ble-transport" + assert state["k1_ip"] == "10.255.254.77" + assert state["connection_mode"] == "bridge" + assert state["device_ref"]["transport_alias"] == "test-ble-transport" + assert state["device_session"]["connectivity"] == "connected" + assert state["compatibility"]["attestation"]["topology"] == "direct-lan" + assert state["connection_verification"] == { + "status": "adopted", + "lease_state": "reachable", + "lease_generation": 1, + "endpoint_validation": "ble-wifi-status-read+mqtt-tcp-connect", + "network_reachability": "reachable", + "host_route_class": "direct-or-routed", + "address_source": "ble-wifi-status-read", + "connection_origin": "external-existing-network", + "admission_source": "connection.verify", + "address_changed": True, + "previous_address_present": False, + "write_performed": False, + "observed_at": "2026-07-20T12:00:00Z", + } + + +def test_verify_connection_adoption_requires_current_scan_candidate( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + + async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]: + raise AssertionError("an unscanned device must not be probed") + + async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]: + raise AssertionError("connection.verify must never call Wi-Fi provisioning") + + monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read) + monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision) + + with pytest.raises(ValueError, match="найдите и выберите"): + service.verify_connection( + ConnectionVerifyRequest( + device_id="not-in-current-scan", + compatibility_attestation=ATTESTATION, + ) + ) + + +@pytest.mark.parametrize( + ("ipv4", "is_local", "route_class", "endpoint_reachable", "message"), + [ + (None, False, "direct-or-routed", True, "не сообщил актуальный DHCP-адрес"), + ("192.168.56.1", False, "device-ap", True, "не сообщил актуальный DHCP-адрес"), + ("10.255.254.77", True, "direct-or-routed", True, "этому компьютеру"), + ("10.255.254.77", False, "tunnel", True, "прямой локальный маршрут"), + ("10.255.254.77", False, "default-route", True, "прямой локальный маршрут"), + ("10.255.254.77", False, "direct-or-routed", False, "1883 недоступен"), + ], +) +def test_verify_connection_adoption_fails_closed_before_establishing_lease( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ipv4: str | None, + is_local: bool, + route_class: str, + endpoint_reachable: bool, + message: str, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + _set_scanned_k1(service) + + async def fake_status_read(*_: object, **__: object) -> dict[str, Any]: + return _wifi_status_read(ipv4) + + async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]: + raise AssertionError("connection.verify must never call Wi-Fi provisioning") + + monkeypatch.setattr(facade_module, "read_wifi_status_once", fake_status_read) + monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision) + monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: is_local) + monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: route_class) + monkeypatch.setattr( + facade_module, + "_control_endpoint_reachable", + lambda _target: endpoint_reachable, + ) + + with pytest.raises(RuntimeError, match=message): + service.verify_connection( + ConnectionVerifyRequest( + device_id="test-ble-transport", + compatibility_attestation=ATTESTATION, + ) + ) + + state = service.state() + assert state["selected_device_id"] is None + assert state["k1_ip"] is None + assert state["connection_mode"] is None + assert state["device_session"] is None + assert state["connection_verification"].get("write_performed") is not True + + +def test_connection_verify_request_requires_exact_bridge_attestation() -> None: + with pytest.raises(ValidationError, match="provided together"): + ConnectionVerifyRequest(device_id="test-ble-transport") + + with pytest.raises(ValidationError, match="topology=direct-lan"): + ConnectionVerifyRequest( + device_id="test-ble-transport", + compatibility_attestation=QUICK_CONNECT_ATTESTATION, + ) + + def test_verify_connection_refreshes_dynamic_dhcp_address_and_rotates_session( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -2273,10 +2652,10 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b tmp_path: Path, ) -> None: service, _ = service_with_fake_runtime(tmp_path) - service._devices = [ # noqa: SLF001 - deliberate white-box concurrency fixture - {"device_id": "k1-a"}, - {"device_id": "k1-b"}, - ] + _set_scanned_devices( + service, + [{"device_id": "k1-a"}, {"device_id": "k1-b"}], + ) boundary_calls: list[tuple[str, str, str]] = [] async def scenario() -> dict[str, Any]: @@ -2346,7 +2725,7 @@ def test_bridge_network_change_retires_terminal_acquisition_and_receiver_error( tmp_path: Path, ) -> None: service, runtime = service_with_fake_runtime(tmp_path) - service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 + _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}]) prepared = service.prepare_acquisition( PrepareAcquisitionRequest( project_name=PROJECT_NAME, @@ -2403,7 +2782,7 @@ def test_bridge_provisioning_reports_host_route_mismatch_without_hiding_device_s tmp_path: Path, ) -> None: service, _ = service_with_fake_runtime(tmp_path) - service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 + _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}]) async def fake_provision(*_: object, **__: object) -> dict[str, Any]: return { @@ -2457,7 +2836,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host( tmp_path: Path, ) -> None: service, _ = service_with_fake_runtime(tmp_path) - service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 + _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}]) activation_calls: list[str] = [] association_calls: list[tuple[Path, str, str]] = [] ble_session_open = False @@ -2574,7 +2953,7 @@ def test_direct_connect_reuses_the_reviewed_ble_provisioning_frame( tmp_path: Path, ) -> None: service, _ = service_with_fake_runtime(tmp_path) - service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 + _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}]) provisioning_calls: list[tuple[str, str, str]] = [] async def fake_provision( @@ -2631,7 +3010,7 @@ def test_quick_connect_missing_credential_provider_stops_before_ap_write( tmp_path: Path, ) -> None: service, _ = service_with_fake_runtime(tmp_path) - service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 + _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}]) service._selected_device_id = "previous-k1" # noqa: SLF001 service._k1_ip = "192.168.1.20" # noqa: SLF001 service._connection_mode = "bridge" # noqa: SLF001 @@ -2688,7 +3067,7 @@ def test_quick_connect_does_not_start_host_wifi_without_ap_ready_flag( tmp_path: Path, ) -> None: service, _ = service_with_fake_runtime(tmp_path) - service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 + _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}]) monkeypatch.setattr( facade_module, "ensure_wifi_profile_from_credential_source", @@ -2749,7 +3128,7 @@ def test_failed_connection_change_revokes_the_previous_route( tmp_path: Path, ) -> None: service, _ = service_with_fake_runtime(tmp_path) - service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001 + _set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}]) service._selected_device_id = "previous-k1" # noqa: SLF001 service._k1_ip = "192.168.1.20" # noqa: SLF001 service._connection_mode = "bridge" # noqa: SLF001 @@ -2819,9 +3198,10 @@ def test_failed_connection_change_revokes_the_previous_route( def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + caplog: pytest.LogCaptureFixture, ) -> None: service, _ = service_with_fake_runtime(tmp_path) - service._devices = [{"device_id": "k1-a"}] # noqa: SLF001 + _set_scanned_devices(service, [{"device_id": "k1-a"}]) async def fake_provision(*_: object, **__: object) -> dict[str, Any]: return { @@ -2835,7 +3215,10 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host( 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="уже принадлежит этому компьютеру"): + with ( + caplog.at_level(logging.ERROR, logger=facade_module.__name__), + pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"), + ): asyncio.run( service.connect( ConnectRequest( @@ -2854,6 +3237,19 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host( assert operation["status"] == "failed" assert operation["error"]["safe_to_retry"] is False assert operation["error"]["side_effect_status"] == "unknown" + failure_log = next( + record + for record in caplog.records + if getattr(record, "event_code", None) == "k1_network_provision_failed" + ) + assert failure_log.operation_stage == "ble-provisioning-write" + assert failure_log.connection_mode == "bridge" + assert failure_log.error_code == "RuntimeError" + assert failure_log.safe_to_retry is False + assert failure_log.side_effect_status == "unknown" + assert failure_log.network_change_attempted is True + assert PRIMARY_TEST_CREDENTIAL not in caplog.text + assert "lab-network" not in caplog.text def test_provisioning_cannot_switch_device_during_active_acquisition( @@ -2861,7 +3257,7 @@ def test_provisioning_cannot_switch_device_during_active_acquisition( tmp_path: Path, ) -> None: service, _ = service_with_fake_runtime(tmp_path) - service._devices = [{"device_id": "k1-a"}] # noqa: SLF001 + _set_scanned_devices(service, [{"device_id": "k1-a"}]) service.prepare_acquisition( PrepareAcquisitionRequest( project_name=PROJECT_NAME, diff --git a/tests/test_xgrids_ap_activation.py b/tests/test_xgrids_ap_activation.py index 0b80ad9..0e8506f 100644 --- a/tests/test_xgrids_ap_activation.py +++ b/tests/test_xgrids_ap_activation.py @@ -1,3 +1,10 @@ +import asyncio + +import pytest +from bleak.exc import BleakDeviceNotFoundError + +import k1link.device_plugins.xgrids_k1.ble.ap_activation as ap_module +import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module from k1link.device_plugins.xgrids_k1.ble.ap_activation import ( COMMAND_OFFSET, ENABLE_AP_COMMAND, @@ -33,3 +40,42 @@ def test_ap_control_mode_without_ready_flag_is_not_ready() -> None: def test_ap_ready_requires_the_reviewed_byte_51_flag() -> None: assert is_ap_ready_status(_status(reserved=1)) + + +def test_ap_activation_requires_fresh_rediscovery_before_connecting( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device_id = "synthetic-corebluetooth-uuid" + stale_handle = object() + rediscovery_calls: list[tuple[str, float]] = [] + client_calls: list[object] = [] + + async def missing_device(address: str, *, timeout: float) -> None: + rediscovery_calls.append((address, timeout)) + return None + + class ForbiddenClient: + def __init__(self, device: object, **_kwargs: object) -> None: + client_calls.append(device) + raise AssertionError("a failed fresh discovery must stop before the BLE write session") + + # Seed the real retained-handle cache so the test fails if the mutating + # path ever regresses to discovered_device(...)-first behavior. + monkeypatch.setitem(scanner_module._runtime_handles, device_id, stale_handle) # noqa: SLF001 + monkeypatch.setattr( + ap_module.BleakScanner, + "find_device_by_address", + missing_device, + ) + monkeypatch.setattr(ap_module, "BleakClient", ForbiddenClient) + + with pytest.raises(BleakDeviceNotFoundError): + asyncio.run( + ap_module.activate_device_ap_once( + device_id, + timeout_seconds=1.0, + ) + ) + + assert rediscovery_calls == [(device_id, 1.0)] + assert client_calls == []