From 1c7dd29d8a9209e771d37b4e8633eab7bca482d1 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 21 Sep 2026 08:45:34 +0300 Subject: [PATCH] chore(node): preserve pre-canonicalization experiment snapshot Historical working copy retained for audit before consolidation into main. The canonicalized plugin architecture and later fixes already live in main; this snapshot is not a release or a request to restore obsolete source layout. --- .../src/components/RerunViewport.tsx | 12 +- .../laboratory/CanonicalResultRerunReplay.tsx | 5 +- .../core/environment/environmentSettings.ts | 6 +- .../src/core/observation/viewerProfile.ts | 21 +- apps/control-station/src/productModel.ts | 12 +- .../src/workspaces/fleet/VehicleSensors.tsx | 8 +- .../test/applicationArchitecture.test.mjs | 2 +- .../test/devicePluginContracts.test.mjs | 18 +- .../devicePluginFrontendBoundary.test.mjs | 22 +- .../test/environmentSettings.test.mjs | 2 +- .../test/k1SupervisorPresentation.test.mjs | 78 +++- .../test/networkProvisioning.test.mjs | 132 +++++++ .../test/observatoryWorkspace.test.mjs | 4 +- .../test/polygonRunArchive.test.mjs | 2 +- .../test/productShellContract.test.mjs | 2 +- .../test/viewerProfile.test.mjs | 13 + apps/node-agent/cmd/node-agent/main.go | 6 + .../internal/node/device_enrollment.go | 323 +++++++++++++++++ .../internal/node/device_enrollment_test.go | 111 ++++++ apps/node-agent/internal/node/pairing.go | 29 +- .../internal/node/pairing_transport.go | 16 + apps/node-agent/internal/node/sensors.go | 61 +++- apps/node-agent/internal/node/server.go | 32 +- .../packaging/50-mission-core-k1.rules | 6 + apps/node-agent/packaging/build.py | 4 +- apps/node-agent/packaging/build_deb.py | 27 +- .../packaging/fetch_driver_bundle.py | 33 +- .../packaging/install-k1-credential | 36 ++ apps/node-agent/packaging/k1-bundle.json | 265 ++++++++++++++ apps/node-agent/packaging/k1_bootstrap.py | 26 ++ apps/node-agent/packaging/k1_prepare.py | 94 +++++ .../packaging/mission-core-k1.service | 38 ++ apps/node-agent/packaging/postinst | 6 + apps/node-agent/packaging/preinst | 11 + apps/node-agent/packaging/prerm | 8 + .../node-agent/packaging/resolve_k1_bundle.py | 60 ++++ apps/node-agent/ui/package-lock.json | 20 +- apps/node-agent/ui/package.json | 2 + apps/node-agent/ui/rerun-runtime.html | 16 + apps/node-agent/ui/src/NodeSensors.tsx | 5 +- apps/node-agent/ui/src/rerunRuntime.ts | 1 + apps/node-agent/ui/vite.config.js | 8 + docs/04_K1_WIFI_PROVISIONING_PROFILE.md | 19 + ...026-09-06-k1-bridge-connection-incident.md | 140 ++++++++ ...026-09-06-k1-live-reference-camera-root.md | 129 +++++++ .../2026-09-06-k1-station-reply-semantics.md | 95 +++++ ...026-09-06-node-k1-bridge-implementation.md | 164 +++++++++ .../sensor-ui/src/DeviceEnrollmentWindow.tsx | 49 +++ packages/sensor-ui/src/K1Detail.tsx | 25 ++ packages/sensor-ui/src/K1LiveSettings.tsx | 28 ++ packages/sensor-ui/src/K1LiveView.tsx | 63 ++++ packages/sensor-ui/src/SensorWorkspace.tsx | 19 +- packages/sensor-ui/src/contracts.ts | 5 +- packages/sensor-ui/src/enrollment.ts | 37 ++ packages/sensor-ui/src/rerunHost.ts | 12 + packages/sensor-ui/src/sensors.css | 4 + .../frontend/src/XgridsK1Connection.tsx | 11 + .../src/components/K1OperatorError.tsx | 6 +- .../src/components/K1ProvisioningPipeline.tsx | 21 +- .../xgrids-k1/frontend/src/configuration.ts | 3 + .../src/networkFailurePresentation.ts | 7 + .../frontend/src/networkProvisioning.ts | 241 +++++++++++++ .../xgrids-k1/frontend/src/presentation.ts | 6 +- .../frontend/src/useXgridsK1Runtime.ts | 337 ++---------------- pyproject.toml | 1 + .../device_plugins/xgrids_k1/ble/scanner.py | 109 +++++- src/k1link/device_plugins/xgrids_k1/camera.py | 15 +- src/k1link/device_plugins/xgrids_k1/facade.py | 54 ++- .../device_plugins/xgrids_k1/linux_host.py | 214 +++++++++++ .../device_plugins/xgrids_k1/node_bridge.py | 248 +++++++++++++ .../device_plugins/xgrids_k1/node_sensor.py | 255 +++++++++++++ .../device_plugins/xgrids_k1/wifi_failure.py | 33 ++ src/k1link/fleet/device_enrollment.py | 173 +++++++++ src/k1link/fleet/registry.py | 6 +- src/k1link/viewer/node_media.py | 170 +++++++++ src/k1link/viewer/node_rerun.py | 144 ++++++++ src/k1link/viewer/rerun_bridge.py | 47 ++- src/k1link/web/fleet_api.py | 49 +++ src/k1link/web/runtime_diagnostics.py | 35 ++ tests/fleet/test_device_enrollment.py | 111 ++++++ tests/test_ble_scanner.py | 127 ++++++- tests/test_k1_wifi_failure.py | 72 ++++ tests/test_node_k1_bridge.py | 204 +++++++++++ tests/test_node_media.py | 102 ++++++ tests/test_viewer_diagnostics_api.py | 49 +++ tests/test_xgrids_acquisition_lifecycle.py | 66 +++- tests/test_xgrids_ble_runtime_arbiter.py | 2 +- tests/test_xgrids_camera_gateway.py | 93 +++++ uv.lock | 130 ++++++- 89 files changed, 5026 insertions(+), 487 deletions(-) create mode 100644 apps/control-station/test/networkProvisioning.test.mjs create mode 100644 apps/node-agent/internal/node/device_enrollment.go create mode 100644 apps/node-agent/internal/node/device_enrollment_test.go create mode 100644 apps/node-agent/packaging/50-mission-core-k1.rules create mode 100644 apps/node-agent/packaging/install-k1-credential create mode 100644 apps/node-agent/packaging/k1-bundle.json create mode 100644 apps/node-agent/packaging/k1_bootstrap.py create mode 100644 apps/node-agent/packaging/k1_prepare.py create mode 100644 apps/node-agent/packaging/mission-core-k1.service create mode 100644 apps/node-agent/packaging/resolve_k1_bundle.py create mode 100644 apps/node-agent/ui/rerun-runtime.html create mode 100644 apps/node-agent/ui/src/rerunRuntime.ts create mode 100644 docs/audits/2026-09-06-k1-bridge-connection-incident.md create mode 100644 docs/audits/2026-09-06-k1-live-reference-camera-root.md create mode 100644 docs/audits/2026-09-06-k1-station-reply-semantics.md create mode 100644 docs/audits/2026-09-06-node-k1-bridge-implementation.md create mode 100644 packages/sensor-ui/src/DeviceEnrollmentWindow.tsx create mode 100644 packages/sensor-ui/src/K1Detail.tsx create mode 100644 packages/sensor-ui/src/K1LiveSettings.tsx create mode 100644 packages/sensor-ui/src/K1LiveView.tsx create mode 100644 packages/sensor-ui/src/enrollment.ts create mode 100644 packages/sensor-ui/src/rerunHost.ts create mode 100644 plugins/xgrids-k1/frontend/src/networkFailurePresentation.ts create mode 100644 plugins/xgrids-k1/frontend/src/networkProvisioning.ts create mode 100644 src/k1link/device_plugins/xgrids_k1/linux_host.py create mode 100644 src/k1link/device_plugins/xgrids_k1/node_bridge.py create mode 100644 src/k1link/device_plugins/xgrids_k1/node_sensor.py create mode 100644 src/k1link/device_plugins/xgrids_k1/wifi_failure.py create mode 100644 src/k1link/fleet/device_enrollment.py create mode 100644 src/k1link/viewer/node_media.py create mode 100644 src/k1link/viewer/node_rerun.py create mode 100644 tests/fleet/test_device_enrollment.py create mode 100644 tests/test_k1_wifi_failure.py create mode 100644 tests/test_node_k1_bridge.py create mode 100644 tests/test_node_media.py diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx index 4857bd8..5038db7 100644 --- a/apps/control-station/src/components/RerunViewport.tsx +++ b/apps/control-station/src/components/RerunViewport.tsx @@ -762,7 +762,15 @@ export async function probeRecordedPerceptionViewerSource( return { sourceUrl: endpoint.href, byteLength: declaredLength }; } -export function RerunViewport({ +export function RerunViewport(props: RerunViewportProps) { + // Even when source URLs coincide, another profile cannot inherit a mounted + // viewer's playback, blueprint channels, recovery state or scene draft. + const identity = props.profile.kind === "laboratory-result" + ? `${props.profile.kind}:${props.profile.resultId}` : props.profile.kind; + return ; +} + +function RerunViewportInstance({ profile, onStatusChange, onSelectionChange, @@ -772,7 +780,7 @@ export function RerunViewport({ onPerceptionLoadChange, onPointColorLoadChange, }: RerunViewportProps) { - const recordedProfile = profile.kind === "recorded-session" ? profile : null; + const recordedProfile = profile.kind === "recorded-session" || profile.kind === "laboratory-result" ? profile : null; const liveProfile = profile.kind === "live-acquisition" ? profile : null; const sourceUrl = profile.sourceUrl; const recordedArtifact = recordedProfile?.artifact ?? null; diff --git a/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx b/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx index 9ee4126..63d102b 100644 --- a/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx +++ b/apps/control-station/src/components/laboratory/CanonicalResultRerunReplay.tsx @@ -34,7 +34,7 @@ import { } from "../laboratory/CanonicalRecordedLabReplay"; import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay"; import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive"; -import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile"; +import { laboratoryResultRerunProfile } from "../../core/observation/viewerProfile"; import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions"; import type { AIViewerLayer } from "../../core/observatory/aiComposition"; import { @@ -305,7 +305,8 @@ export function CanonicalResultRerunReplay({ accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0, }), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]); const semanticVisible = showDDRNet || showEoMT; - const profile = launch ? recordedSessionRerunProfile({ + const profile = launch ? laboratoryResultRerunProfile({ + resultId, sourceUrl: launch.replay.sourceUrl, artifact: { sourceUrl: launch.replay.sourceUrl, diff --git a/apps/control-station/src/core/environment/environmentSettings.ts b/apps/control-station/src/core/environment/environmentSettings.ts index 2c2cd3d..80250f1 100644 --- a/apps/control-station/src/core/environment/environmentSettings.ts +++ b/apps/control-station/src/core/environment/environmentSettings.ts @@ -113,13 +113,13 @@ const defaultQuickActions: Record< EnvironmentSurfaceId, readonly [string | null, string | null] > = { - home: ["spatial-scene", "local-device"], - fleet: ["contour-health", "local-device"], + home: ["spatial-scene", "vehicles"], + fleet: ["vehicles", "contour-health"], observation: ["spatial-scene", "cameras"], missions: ["mission-planner", null], data: ["recordings", "datasets"], system: ["modules", "integrations"], - polygon: ["lab-archive", null], + polygon: ["lab-archive", "local-device"], }; export function defaultEnvironmentSettings(): EnvironmentSettings { diff --git a/apps/control-station/src/core/observation/viewerProfile.ts b/apps/control-station/src/core/observation/viewerProfile.ts index 1abd1e7..91d5210 100644 --- a/apps/control-station/src/core/observation/viewerProfile.ts +++ b/apps/control-station/src/core/observation/viewerProfile.ts @@ -85,6 +85,13 @@ export interface RecordedSessionRerunProfile { lockPerceptionCameraInteraction: boolean; } +/** A LAB owns its result presentation; it does not inherit session-view settings. */ +export interface LaboratoryResultRerunProfile + extends Omit { + kind: "laboratory-result"; + resultId: string; +} + /** * Deprecated comparison-only contract for LAB artifacts that have not yet * been republished as a native Rerun sidecar. It must never be selected by a @@ -101,7 +108,8 @@ export interface LaboratoryRecordedEvidenceViewerProfile { export type RerunViewerProfile = | LiveAcquisitionRerunProfile - | RecordedSessionRerunProfile; + | RecordedSessionRerunProfile + | LaboratoryResultRerunProfile; export type ObservationViewerProfile = | RerunViewerProfile @@ -119,11 +127,18 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({ export function liveAcquisitionRerunProfile( input: Omit, ): LiveAcquisitionRerunProfile { - return { kind: "live-acquisition", clock: "stream_time", ...input }; + return { ...input, kind: "live-acquisition", clock: "stream_time" }; } export function recordedSessionRerunProfile( input: Omit, ): RecordedSessionRerunProfile { - return { kind: "recorded-session", clock: "session_time", ...input }; + return { ...input, kind: "recorded-session", clock: "session_time" }; +} + +export function laboratoryResultRerunProfile( + input: Omit, +): LaboratoryResultRerunProfile { + if (!input.resultId.trim()) throw new Error("LAB result identity is required"); + return { ...input, kind: "laboratory-result", clock: "session_time" }; } diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index 5e6872c..ed57b5d 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -94,7 +94,7 @@ export const roots: RootDefinition[] = [ label: "Парк", title: "Аппараты и устройства", eyebrow: "ПАРК / УСТРОЙСТВА", - description: "Реестр аппаратов, локальное подключение, сенсоры и конфигурации борта.", + description: "Реестр аппаратов, бортовые компьютеры, сенсоры и конфигурации борта.", statement: "Одинаково подключать одиночный стенд, наземную платформу и будущий рой.", accent: "АППАРАТЫ И ПОЛЕЗНАЯ НАГРУЗКА", }, @@ -203,11 +203,11 @@ export const workspaces: WorkspaceDefinition[] = [ }, { id: "local-device", - root: "fleet", - label: "Подключение", - title: "Подключение", - eyebrow: "ПАРК / ТЕКУЩИЙ АДАПТЕР", - description: "Выбор модели, сценарий установленного плагина и запуск доступного потока.", + root: "polygon", + label: "Тестовые устройства", + title: "Тестовые устройства", + eyebrow: "LAB / ТЕСТОВЫЕ УСТРОЙСТВА", + description: "Подключение устройств к компьютеру оператора для испытаний и записи.", icon: "network", kind: "device", groups: [], diff --git a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx index 6bcbdd7..b92c41c 100644 --- a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx +++ b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx @@ -1,13 +1,19 @@ import {useMemo} from 'react'; +import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost'; import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace'; import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts'; import {fleetRequest} from '../../core/fleet/useFleet'; export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){ const transport=useMemo(()=>({ + enrollment:{ + state:()=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment`), + submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment/operations`,'POST',value), + operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment/operations/${encodeURIComponent(id)}`), + }, inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};}, subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();}, submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value), operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`), }),[vehicleID]); - return ; + return ; } diff --git a/apps/control-station/test/applicationArchitecture.test.mjs b/apps/control-station/test/applicationArchitecture.test.mjs index 359ef60..d17addd 100644 --- a/apps/control-station/test/applicationArchitecture.test.mjs +++ b/apps/control-station/test/applicationArchitecture.test.mjs @@ -143,7 +143,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor /advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/, ); assert.equal(sharedReplay.match(/\(hasTgs \? 1 : 0\)/); assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/); for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) { diff --git a/apps/control-station/test/devicePluginContracts.test.mjs b/apps/control-station/test/devicePluginContracts.test.mjs index 136c824..5dde64b 100644 --- a/apps/control-station/test/devicePluginContracts.test.mjs +++ b/apps/control-station/test/devicePluginContracts.test.mjs @@ -1676,7 +1676,7 @@ test("physical-command guidance projects backend policy without exposing reason ); }); -test("restart-recovery guidance describes automatic recovery without protocol ceremony", () => { +test("restart-recovery guidance offers an explicit read-only recovery", () => { const deniedFresh = { connection_policy: { schema_version: "missioncore.xgrids-k1-connection-policy/v1", @@ -1712,7 +1712,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce ); assert.deepEqual(durableGuidance, { reason: "В свежем Bluetooth-поиске не найден K1, связанный с незавершённой записью.", - nextAction: "Дождитесь автоматического восстановления сохранённого подключения K1.", + nextAction: "Нажмите «Переподключиться», чтобы проверить сохранённое подключение K1.", }); assert.doesNotMatch( `${durableGuidance.reason} ${durableGuidance.nextAction}`, @@ -1725,7 +1725,7 @@ test("restart-recovery guidance describes automatic recovery without protocol ce deniedFresh, "observe-fresh-device-network", ).nextAction, - "Дождитесь автоматического восстановления связи с тем же K1.", + "Нажмите «Переподключиться», чтобы проверить связь с тем же K1.", ); }); @@ -3461,7 +3461,7 @@ test("each terminal explicit provisioning click starts a fresh operation identit assert.match( connectRecovery, - /failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/, + /observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/, ); assert.match( connectRecovery, @@ -4009,7 +4009,7 @@ test("failed network-profile writes close the UI session with a fresh explicit n assert.equal( message, - "Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.", + "Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.", ); assert.doesNotMatch(message, /Bleak|GATT|ATT/i); @@ -4024,8 +4024,8 @@ test("failed network-profile writes close the UI session with a fresh explicit n }); assert.match(attMessage, /ATT 4 INVALID_PDU/); assert.match(attMessage, /Автоматический повтор команды K1 не отправлялся/); - assert.match(attMessage, /Сессия подключения в интерфейсе сброшена/); - assert.match(attMessage, /новый поиск Bluetooth, выберите K1/); + assert.match(attMessage, /Проверьте состояние K1 через «Переподключиться»/); + assert.match(attMessage, /новое подключение через поиск Bluetooth/); const preWriteAttMessage = networkProvisionFailureMessage({ action: "network.provision", @@ -4059,7 +4059,7 @@ test("post-dispatch ambiguity resets the UI session without an automatic retry", assert.equal( message, - "После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.", + "После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.", ); assert.doesNotMatch(message, /ручн|read-only|защитный барьер/i); }); @@ -4247,7 +4247,7 @@ test("host Wi-Fi failures close the attempt and require a fresh explicit connect assert.equal( operationTimeout, - "Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.", + "Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth.", ); assert.doesNotMatch(operationTimeout, /получите пароль|пароль отсутствует/i); assert.match(missingNetwork, /K1 принял команду Quick Connect/); diff --git a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs index 6bdb1ee..08d2add 100644 --- a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs +++ b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs @@ -218,8 +218,8 @@ test("K1 connection surface enforces one scan, local selection, and one Apply", ); assert.match(provisioning, /scanSecondsRemaining/); assert.match(provisioning, /setInterval\(updateCountdown, 250\)/); - assert.match(provisioning, /Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с/); - assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*6/); + assert.match(provisioning, /Поиск Bluetooth · до \{scanSecondsRemaining \?\? BLE_DISCOVERY_TIMEOUT_SECONDS\} с/); + assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/); assert.doesNotMatch(provisioning, /K1 уже доступен|Сетевой адрес K1 доступен/); assert.doesNotMatch( provisioning, @@ -284,7 +284,7 @@ test("K1 click-owned actions are fenced without hidden frontend continuations", provisioning.indexOf("const verifyAppliedNetwork"), ); assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1); - assert.match(search, /durationSeconds:\s*6/); + assert.match(search, /durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/); assert.equal((apply.match(/await connect\(/g) ?? []).length, 1); assert.doesNotMatch( apply, @@ -680,7 +680,7 @@ test("automatic K1 live start opens the selected delivered camera despite an old assert.match(layout, /source\.capabilities\.overlay/); }); -test("K1 connect errors reset the UI session without exposing reconciliation ceremony", () => { +test("K1 connect errors preserve the result and expose explicit recovery", () => { const runtime = readFileSync( join(pluginFrontendRoot, "useXgridsK1Runtime.ts"), "utf8", @@ -691,15 +691,17 @@ test("K1 connect errors reset the UI session without exposing reconciliation cer assert.notEqual(connectEnd, -1); const connectFlow = runtime.slice(connectStart, connectEnd); - assert.match(connectFlow, /operationByIdempotencyKey\(/); - assert.match(connectFlow, /failedOperation\?\.status === "succeeded"/); - assert.match(connectFlow, /resetConnectSessionMessage\(/); + assert.match(connectFlow, /observeProvisioningRequest\(/); + assert.match(connectFlow, /operation\?\.status !== "succeeded"/); + assert.match(connectFlow, /networkProvisionFailureMessage\(/); assert.doesNotMatch( connectFlow, /требует ручной проверки|измените параметры только после проверки устройства|Сохранён тот же ключ/, ); - assert.match(runtime, /Сессия подключения в интерфейсе сброшена/); - assert.match(runtime, /новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз/); + const networkFlow = readFileSync(join(pluginFrontendRoot, "networkProvisioning.ts"), "utf8"); + assert.match(networkFlow, /Проверьте состояние K1 через «Переподключиться»/); + assert.match(networkFlow, /новое подключение через поиск Bluetooth/); + assert.equal((networkFlow.match(/await context\.send\(/g) ?? []).length, 1); const verifyStart = runtime.indexOf("const verifyConnection = useCallback("); const verifyEnd = runtime.indexOf("const probeConfiguredEndpoint = useCallback(", verifyStart); const verifyFlow = runtime.slice(verifyStart, verifyEnd); @@ -729,7 +731,7 @@ test("K1 provisioning keeps the operator draft separate from the backend lease", assert.match(selection, /requestExplicitProvisioning\(device\.device_id/); assert.equal((apply.match(/await connect\(/g) ?? []).length, 1); assert.doesNotMatch(apply, /scanWithResult\(|verifyConnection\(|candidateRefresh/); - assert.match(provisioning, /scanWithResult\(\{ durationSeconds: 6 \}\)/); + assert.match(provisioning, /scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/); assert.match( provisioning, /onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/, diff --git a/apps/control-station/test/environmentSettings.test.mjs b/apps/control-station/test/environmentSettings.test.mjs index 144c3e5..5f7b3ca 100644 --- a/apps/control-station/test/environmentSettings.test.mjs +++ b/apps/control-station/test/environmentSettings.test.mjs @@ -65,7 +65,7 @@ test("default environment exposes every current product page", () => { "system", "polygon", ]); - assert.equal(defaults.pages.fleet.primaryWorkspaceId, "contour-health"); + assert.equal(defaults.pages.fleet.primaryWorkspaceId, "vehicles"); assert.equal(defaults.pages.observation.primaryWorkspaceId, "spatial-scene"); }); diff --git a/apps/control-station/test/k1SupervisorPresentation.test.mjs b/apps/control-station/test/k1SupervisorPresentation.test.mjs index 4c17290..0d42914 100644 --- a/apps/control-station/test/k1SupervisorPresentation.test.mjs +++ b/apps/control-station/test/k1SupervisorPresentation.test.mjs @@ -250,7 +250,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I "const chooseAnother", ); - assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*6/); + assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*BLE_DISCOVERY_TIMEOUT_SECONDS/); assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1); assert.doesNotMatch(search, /\b(?:connect|verifyConnection|submitConnect)\s*\(/); @@ -295,7 +295,7 @@ test("K1 one-intent source contract makes mode reset explicit and keeps device I } assert.equal((source.match(/buttonLabel:\s*"Применить"/g) ?? []).length, 3); assert.match(source, /устарел|stale/i); - assert.match(source, /неизвест|outcome-unknown|safe_to_retry/i); + assert.match(source, /network_outcome_unknown|safe_to_retry/i); }); test("exact network-applied REST proof completes frontend Apply before control is ready", () => { @@ -377,7 +377,7 @@ test("exact network-applied REST proof completes frontend Apply before control i ); assert.match( connectFlow, - /failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/, + /observeProvisioningRequest\([\s\S]*?observedState = observation\.state[\s\S]*?const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\(/, ); assert.doesNotMatch( connectFlow, @@ -1359,7 +1359,7 @@ function actionByLabel(node, label) { return actionByLabel(node.props.children, label); } -function renderProvisioningWithAttempt(props, presentation) { +function renderProvisioningWithAttempt(props, presentation, afterSearch = false) { function AttemptHarness() { const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; @@ -1367,6 +1367,12 @@ function renderProvisioningWithAttempt(props, presentation) { assert.equal(typeof dispatcher?.useState, "function"); const originalUseState = dispatcher.useState; dispatcher.useState = (initialState) => { + if (afterSearch && initialState === emptySearchPresentation) { + const state = props.controller.state; + return [{ sequence: 1, snapshotRuntimeId: state.snapshot_runtime_id, + connectionMode: props.desiredMode, desiredModeRevision: state.desired_connection_mode_revision, + active: false, completedDiscoveryGeneration: state.ble_discovery_generation }, () => undefined]; + } if (initialState === emptyProvisioningAttemptPresentation) { return [presentation, () => undefined]; } @@ -2487,10 +2493,11 @@ test("toolbar reset in one runtime rejects late Scan A and admits exactly one Sc } }); -test("local connection navigation and selector copy use process nouns", () => { +test("operator-local connection belongs to LAB and retains all test modes", () => { const localConnection = workspaces.find((workspace) => workspace.id === "local-device"); - assert.equal(localConnection?.label, "Подключение"); - assert.equal(localConnection?.title, "Подключение"); + assert.equal(localConnection?.root, "polygon"); + assert.equal(localConnection?.label, "Тестовые устройства"); + assert.equal(localConnection?.title, "Тестовые устройства"); assert.equal(model().displayName, "XGRIDS LixelKity K1"); assert.deepEqual( connectionModeOptions.map(({ label }) => label), @@ -7185,7 +7192,7 @@ test("a current-authority Bluetooth scan owns Step 01 and no network step", () = }); assert.match(markup, /01<\/span>/); - assert.match(markup, /Поиск Bluetooth · 6 с/); + assert.match(markup, /Поиск Bluetooth · до 20 с/); assert.equal( (markup.match(/class="nodedc-activity-indicator"/g) ?? []).length, 1, @@ -7478,7 +7485,7 @@ test("applied-network recovery spends the old intent and admits only explicit se search, /const recoveryScanAttempt = appliedRecoveryEscape[\s\S]*?canScanAppliedRecovery[\s\S]*?unresolvedAppliedAttempt/, ); - assert.match(search, /scanResult = await scanWithResult\(\{ durationSeconds: 6 \}\)/); + assert.match(search, /scanResult = await scanWithResult\(\{ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS \}\)/); assert.match( search, /if \(scanResult\.succeeded && recoveryAttemptKey\) \{\s*setEscapedAppliedAttemptKey\(recoveryAttemptKey\)/, @@ -7708,3 +7715,56 @@ test("contour health never promotes selection or replay metrics to live authorit }); assert.equal(replay.aiActive, false); }); + + +test("the recovery panel explains the reviewed K1 network-not-found reply", () => { + const state = terminalConnectionRecoveryState(); + state.connection_attempt.public_error_code = "k1-wifi-network-not-found"; + const markup = renderToStaticMarkup(createElement(K1OperatorError, { + attempt: state.connection_attempt, onRefresh() {}, onClear() {}, + })); + assert.match(markup, /K1 не смог найти указанную сеть Wi‑Fi/); + assert.match(markup, /точное название сети/); + assert.doesNotMatch(markup, /ошибкой Bluetooth|INVALID_PDU/); +}); + +test("a reviewed Wi-Fi rejection offers explicit network setup for the same K1", () => { + const state = terminalConnectionRecoveryState(); + state.connection_attempt.public_error_code = "k1-wifi-network-not-found"; + const controller = { + ...provisioningController(state), error: "private transport error", + errorCorrelation: { + action: "connect", runtimeId: state.snapshot_runtime_id, leaseGeneration: 0, + connectionAttemptId: state.connection_attempt.attempt_id, + }, + getConnectionRecoveryObservationTarget: () => recommendedConnectionRecoveryObservationTarget(state), + }; + const markup = renderProvisioningWithAttempt({ controller, desiredMode: "bridge" }, + provisioningAttemptPresentation({ snapshotRuntimeId: state.snapshot_runtime_id, + attemptId: state.connection_attempt.attempt_id, localPhase: "failed" }), true); + assert.equal(buttonMarkupWithText(markup, "Указать сеть Wi‑Fi заново").length, 1); + assert.doesNotMatch(markup, /Подключить новый K1|private transport error/); +}); + +test("a failed Connect after completed Bluetooth search exposes recovery instead of a locked form", () => { + const state = terminalConnectionRecoveryState(); + state.ble_discovery_generation = 6; + state.connection_attempt.public_error_code = "BleakGATTProtocolError"; + state.connection_attempt.stage = "gatt-write-failed"; + const controller = { + ...provisioningController(state), + error: "private transport exception must stay hidden", + errorCorrelation: { + action: "connect", runtimeId: state.snapshot_runtime_id, leaseGeneration: 0, + connectionAttemptId: state.connection_attempt.attempt_id, + }, + getConnectionRecoveryObservationTarget: () => recommendedConnectionRecoveryObservationTarget(state), + }; + const markup = renderProvisioningWithAttempt({ controller, desiredMode: "bridge" }, + provisioningAttemptPresentation({ snapshotRuntimeId: state.snapshot_runtime_id, + attemptId: state.connection_attempt.attempt_id, localPhase: "failed" }), true); + assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 1); + assert.equal(buttonMarkupWithText(markup, "Подключить новый K1").length, 1); + assert.match(markup, /K1 ответил ошибкой Bluetooth/); + assert.doesNotMatch(markup, /Подключение не выполнено|дождитесь, пока система|private transport exception|Пароль передан/); +}); diff --git a/apps/control-station/test/networkProvisioning.test.mjs b/apps/control-station/test/networkProvisioning.test.mjs new file mode 100644 index 0000000..2640c1b --- /dev/null +++ b/apps/control-station/test/networkProvisioning.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { before, after, test } from "node:test"; +import { createServer } from "vite"; + +let server; +let observeProvisioningRequest; +let networkProvisionFailureMessage; +let ApiError; +before(async () => { + server = await createServer({ appType: "custom", logLevel: "silent", server: { middlewareMode: true } }); + ({ observeProvisioningRequest, networkProvisionFailureMessage } = await server.ssrLoadModule("@xgrids-k1/frontend/networkProvisioning.ts")); + ({ ApiError } = await server.ssrLoadModule("@xgrids-k1/frontend/api.ts")); +}); +after(async () => { await server?.close(); }); + +test("reviewed station errors explain Wi-Fi recovery without claiming a Bluetooth failure", () => { + for (const [code, pattern] of [ + ["k1-wifi-network-not-found", /точное название сети/], + ["k1-wifi-credentials-required", /введите пароль нужной сети заново/], + ]) { + const message = networkProvisionFailureMessage({ status: "failed", error: { code } }); + assert.match(message, pattern); + assert.doesNotMatch(message, /INVALID_PDU|ошибкой Bluetooth|Переподключиться/); + } + assert.match(networkProvisionFailureMessage({ status: "failed", error: { + code: "BleakGATTProtocolError", ble_att_error_code: 4, ble_att_error_name: "INVALID_PDU", + } }), /INVALID_PDU/); + assert.match(networkProvisionFailureMessage({ status: "failed", error: { + code: "network-not-found", + } }), /macOS/); +}); + +const request = { device_id: "synthetic-k1", connection_mode: "bridge", idempotency_key: "explicit-one" }; +function snapshot(status, revision = 2) { + return { + snapshot_runtime_id: "runtime-one", + snapshot_runtime_started_at_utc: "2026-09-06T00:00:00Z", + snapshot_revision: revision, + operations: status ? [{ + operation_id: "op-one", action: "network.provision", idempotency_key: request.idempotency_key, + status, error: status === "failed" ? { + code: "BleakGATTProtocolError", ble_att_error_code: 4, ble_att_error_name: "INVALID_PDU", + device_write_attempted: true, device_write_confirmed: false, + safe_to_retry: false, side_effect_status: "unknown", + } : null, + }] : [], + }; +} +function context(overrides = {}) { + let current = snapshot(null, 1); + const calls = []; + return { + calls, + initialState: current, + send: async (input) => { calls.push(["send", input]); throw new ApiError("transport", 502); }, + readState: async () => { calls.push(["read"]); return snapshot("failed"); }, + acceptState: (incoming) => { if (incoming.snapshot_revision >= current.snapshot_revision) current = incoming; }, + currentState: () => current, + assertCurrent() {}, + ...overrides, + }; +} + +test("HTTP 502 retains the exact failed snapshot and ATT facts after one submission", async () => { + const ctx = context(); + const result = await observeProvisioningRequest(request, ctx); + assert.deepEqual(ctx.calls.map(([kind]) => kind), ["send", "read"]); + assert.equal(ctx.calls[0][1], request); + assert.equal(result.operation.operation_id, "op-one"); + assert.equal(result.operation.error.ble_att_error_name, "INVALID_PDU"); + assert.equal(result.state.operations[0], result.operation); + assert.equal(result.transportError.status, 502); +}); + +test("an existing failed or successful intent is never resubmitted", async () => { + for (const status of ["failed", "succeeded"]) { + const ctx = context({ initialState: snapshot(status) }); + const result = await observeProvisioningRequest(request, ctx); + assert.equal(result.operation.status, status); + assert.equal(ctx.calls.length, 0); + } +}); + +test("lost HTTP response can observe journal success without another command", async () => { + const ctx = context({ readState: async () => snapshot("succeeded") }); + const result = await observeProvisioningRequest(request, ctx); + assert.equal(result.operation.status, "succeeded"); + assert.equal(ctx.calls.filter(([kind]) => kind === "send").length, 1); +}); + +test("an admitted pending intent is followed only by bounded local state reads", async () => { + let now = 0; + const ctx = context({ + initialState: snapshot("running"), + settlementOptions: { now: () => now, wait: async (ms) => { now += ms; } }, + }); + const result = await observeProvisioningRequest(request, ctx); + assert.equal(result.operation.status, "failed"); + assert.deepEqual(ctx.calls.map(([kind]) => kind), ["read"]); +}); + +test("newer WebSocket failure outranks a stale running REST result", async () => { + const newer = snapshot("failed", 7); + const ctx = context({ + send: async () => snapshot("running", 5), + currentState: () => newer, + }); + const result = await observeProvisioningRequest(request, ctx); + assert.equal(result.state, newer); + assert.equal(result.operation.status, "failed"); + assert.equal(ctx.calls.length, 0); +}); + +test("superseding an intent after dispatch stops continuation without replay", async () => { + let active = true; + const ctx = context({ + send: async () => { active = false; throw new ApiError("lost", 502); }, + assertCurrent: () => { if (!active) throw new Error("superseded"); }, + }); + await assert.rejects(observeProvisioningRequest(request, ctx), /superseded/); + assert.equal(ctx.calls.length, 0); +}); + +test("an unrelated journal row cannot confirm the current command", async () => { + const unrelated = snapshot("succeeded"); + unrelated.operations[0].idempotency_key = "another-intent"; + const ctx = context({ readState: async () => unrelated }); + const result = await observeProvisioningRequest(request, ctx); + assert.equal(result.operation, null); + assert.equal(result.transportError.status, 502); + assert.equal(ctx.calls.length, 1); +}); diff --git a/apps/control-station/test/observatoryWorkspace.test.mjs b/apps/control-station/test/observatoryWorkspace.test.mjs index ba50dc8..b1d0a3a 100644 --- a/apps/control-station/test/observatoryWorkspace.test.mjs +++ b/apps/control-station/test/observatoryWorkspace.test.mjs @@ -27,7 +27,7 @@ async function read(relativePath) { test("Observatory is the third independent Polygon workspace", () => { assert.deepEqual( productModel.workspacesForRoot("polygon").map(({ id }) => id), - ["lab-archive", "simulations", "observatory"], + ["lab-archive", "simulations", "observatory", "local-device"], ); assert.deepEqual( productModel.workspaceById("observatory"), @@ -138,7 +138,7 @@ test("Observatory mounts the one shared canonical replay only after explicit adm /RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/, ); assert.equal(sharedReplay.match(/ id), - ["lab-archive", "simulations", "observatory"], + ["lab-archive", "simulations", "observatory", "local-device"], ); assert.equal( workspacesForRoot("system").some(({ id }) => id === "polygon-run"), diff --git a/apps/control-station/test/productShellContract.test.mjs b/apps/control-station/test/productShellContract.test.mjs index d3d8266..e3f86a8 100644 --- a/apps/control-station/test/productShellContract.test.mjs +++ b/apps/control-station/test/productShellContract.test.mjs @@ -29,7 +29,7 @@ test("top navigation has no Center and Park owns contour health first", () => { assert.equal(productModel.workspaceById("contour-health")?.root, "fleet"); assert.deepEqual( productModel.workspacesForRoot("polygon").map(({ id }) => id), - ["lab-archive", "simulations", "observatory"], + ["lab-archive", "simulations", "observatory", "local-device"], ); assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive"); assert.deepEqual( diff --git a/apps/control-station/test/viewerProfile.test.mjs b/apps/control-station/test/viewerProfile.test.mjs index bb3fef8..17a4ea1 100644 --- a/apps/control-station/test/viewerProfile.test.mjs +++ b/apps/control-station/test/viewerProfile.test.mjs @@ -7,6 +7,7 @@ let server; let LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE; let liveAcquisitionRerunProfile; let recordedSessionRerunProfile; +let laboratoryResultRerunProfile; before(async () => { server = await createServer({ @@ -18,6 +19,7 @@ before(async () => { LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, liveAcquisitionRerunProfile, recordedSessionRerunProfile, + laboratoryResultRerunProfile, } = await server.ssrLoadModule("/src/core/observation/viewerProfile.ts")); }); @@ -86,3 +88,14 @@ test("the old LAB transport is fenced as explicit legacy comparison only", () => }); assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true); }); + +test("LAB has its own immutable result identity and discriminator", () => { + const input = {resultId:"E38-result-1",sourceUrl:"/shared.rrd",kind:"recorded-session",clock:"stream_time"}; + const lab = laboratoryResultRerunProfile(input); + assert.equal(lab.kind,"laboratory-result"); + assert.equal(lab.clock,"session_time"); + assert.equal(lab.resultId,"E38-result-1"); + assert.equal(recordedSessionRerunProfile({...input}).kind,"recorded-session"); + assert.equal(liveAcquisitionRerunProfile({...input}).kind,"live-acquisition"); + assert.throws(()=>laboratoryResultRerunProfile({...input,resultId:" "})); +}); diff --git a/apps/node-agent/cmd/node-agent/main.go b/apps/node-agent/cmd/node-agent/main.go index 68fa9f2..d9ecfd1 100644 --- a/apps/node-agent/cmd/node-agent/main.go +++ b/apps/node-agent/cmd/node-agent/main.go @@ -80,6 +80,12 @@ func run() error { return err } pairing.Sensors = app.Sensors + app.DeviceEnrollment, err = node.OpenDeviceEnrollment(*dir, nodeID) + if err != nil { + return err + } + pairing.DeviceEnrollment = app.DeviceEnrollment + app.Sensors.NetworkDevices = app.DeviceEnrollment app.Access = &node.AccessStore{Path: filepath.Join(*dir, "ssh-keys.json"), Users: func() []string { return node.LocalAdmins("/") }} if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil { return err diff --git a/apps/node-agent/internal/node/device_enrollment.go b/apps/node-agent/internal/node/device_enrollment.go new file mode 100644 index 0000000..d95ca08 --- /dev/null +++ b/apps/node-agent/internal/node/device_enrollment.go @@ -0,0 +1,323 @@ +package node + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "path/filepath" + "sync" + "time" +) + +type EnrollmentCommand struct { + ID string `json:"operation_id"` + NodeID string `json:"node_id"` + RuntimeID string `json:"runtime_id"` + Action string `json:"action"` + Deadline string `json:"deadline_at"` + Parameters map[string]any `json:"parameters"` +} + +// This is the complete journal schema. No command parameters or credentials. +type EnrollmentOperation struct { + ID string `json:"operation_id"` + NodeID string `json:"node_id"` + RuntimeID string `json:"runtime_id"` + Action string `json:"action"` + State string `json:"state"` + Error string `json:"error,omitempty"` + Result map[string]any `json:"result,omitempty"` + Remote bool `json:"remote,omitempty"` + Updated int64 `json:"updated_at"` +} + +type DeviceEnrollment struct { + mu sync.Mutex + root string + nodeID string + client *http.Client + operations map[string]*EnrollmentOperation +} + +func OpenDeviceEnrollment(root, nodeID string) (*DeviceEnrollment, error) { + dir := filepath.Join(root, "device-enrollment") + if e := os.MkdirAll(dir, 0700); e != nil { + return nil, e + } + d := &DeviceEnrollment{root: dir, nodeID: nodeID, operations: map[string]*EnrollmentOperation{}} + d.client = &http.Client{Timeout: 185 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-k1/driver.sock") + }}} + files, _ := filepath.Glob(filepath.Join(dir, "op_*.json")) + for _, path := range files { + data, e := os.ReadFile(path) + if e != nil { + return nil, e + } + var op EnrollmentOperation + if json.Unmarshal(data, &op) != nil || !operationID.MatchString(op.ID) { + return nil, errors.New("invalid enrollment journal") + } + if op.State == "running" { + op.State = "unknown" + op.Error = "БК перезапущен. Обновите состояние K1 перед новой командой." + } + d.operations[op.ID] = &op + } + return d, nil +} + +func (d *DeviceEnrollment) call(ctx context.Context, path string, command any) (map[string]any, error) { + var body io.Reader + method := "GET" + if command != nil { + data, e := json.Marshal(command) + if e != nil { + return nil, e + } + body = bytes.NewReader(data) + method = "POST" + } + request, e := http.NewRequestWithContext(ctx, method, "http://k1"+path, body) + if e != nil { + return nil, e + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Node-Id", d.nodeID) + response, e := d.client.Do(request) + if e != nil { + return nil, errors.New("Служба K1 на БК недоступна.") + } + defer response.Body.Close() + var value map[string]any + if json.NewDecoder(io.LimitReader(response.Body, 65536)).Decode(&value) != nil || response.StatusCode != 200 { + return nil, errors.New("Действие K1 не подтверждено. Обновите состояние устройства.") + } + return value, nil +} + +func (d *DeviceEnrollment) Status() map[string]any { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + value, e := d.call(ctx, "/status", nil) + if e != nil { + value = map[string]any{"available": false} + } + value["node_id"] = d.nodeID + value["name"], _ = os.Hostname() + value["fresh"] = true + return value +} + +func (c EnrollmentCommand) valid(nodeID string) bool { + deadline, e := time.Parse(time.RFC3339Nano, c.Deadline) + if e != nil || !deadline.After(time.Now()) || time.Until(deadline) > 180*time.Second || c.NodeID != nodeID || !operationID.MatchString(c.ID) || c.RuntimeID == "" || len(c.RuntimeID) > 128 { + return false + } + keys := map[string]bool{} + switch c.Action { + case "scan", "networks": + case "connect", "verify": + keys = map[string]bool{"device_id": true, "discovery_generation": true, "mode_revision": true} + id, ok := c.Parameters["device_id"].(string) + if !ok || len(id) < 1 || len(id) > 128 { + return false + } + for _, key := range []string{"discovery_generation", "mode_revision"} { + v, ok := c.Parameters[key].(float64) + if !ok || v < 0 || v != float64(int64(v)) { + return false + } + } + if c.Action == "connect" { + keys["ssid"] = true + keys["password"] = true + for key, limit := range map[string]int{"ssid": 32, "password": 64} { + v, ok := c.Parameters[key].(string) + if !ok || len(v) < 1 || len(v) > limit { + return false + } + } + } + default: + return false + } + if len(keys) != len(c.Parameters) { + return false + } + for key := range c.Parameters { + if !keys[key] { + return false + } + } + return true +} + +func copyEnrollment(value *EnrollmentOperation) *EnrollmentOperation { + if value == nil { + return nil + } + data, _ := json.Marshal(value) + var out EnrollmentOperation + _ = json.Unmarshal(data, &out) + return &out +} + +func (d *DeviceEnrollment) Submit(c EnrollmentCommand, remote bool) (*EnrollmentOperation, error) { + if !c.valid(d.nodeID) { + return nil, errors.New("Проверьте БК, выбранное устройство и параметры сети.") + } + d.mu.Lock() + if old := d.operations[c.ID]; old != nil { + out := copyEnrollment(old) + d.mu.Unlock() + return out, nil + } + if len(d.operations) >= 2000 { + for id, value := range d.operations { + if value.State != "running" && time.Now().Unix()-value.Updated > 86400 { + if os.Remove(filepath.Join(d.root, id+".json")) == nil { + delete(d.operations, id) + } + } + } + if len(d.operations) >= 2000 { + d.mu.Unlock() + return nil, errors.New("Журнал подключений заполнен. Повторите позже.") + } + } + for _, v := range d.operations { + if v.State == "running" { + d.mu.Unlock() + return nil, errors.New("Дождитесь текущего действия K1.") + } + } + d.mu.Unlock() + if d.Status()["runtime_id"] != c.RuntimeID { + return nil, errors.New("Сеанс K1 изменился. Обновите устройства.") + } + d.mu.Lock() + // Status can yield; repeat admission under the lock before the durable write. + if old := d.operations[c.ID]; old != nil { + out := copyEnrollment(old) + d.mu.Unlock() + return out, nil + } + for _, v := range d.operations { + if v.State == "running" { + d.mu.Unlock() + return nil, errors.New("Дождитесь текущего действия K1.") + } + } + op := &EnrollmentOperation{ID: c.ID, NodeID: c.NodeID, RuntimeID: c.RuntimeID, Action: c.Action, State: "running", Remote: remote, Updated: time.Now().Unix()} + if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil { + d.mu.Unlock() + return nil, e + } + d.operations[c.ID] = op + out := copyEnrollment(op) + d.mu.Unlock() + go d.execute(c) + return out, nil +} + +func (d *DeviceEnrollment) execute(c EnrollmentCommand) { + deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline) + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + result, e := d.call(ctx, "/operation", c) + // Dropping references is best-effort lifetime reduction, not a claim that + // Go can zero every immutable JSON/string copy. No secret is journalled. + delete(c.Parameters, "password") + d.mu.Lock() + defer d.mu.Unlock() + op := d.operations[c.ID] + op.Updated = time.Now().Unix() + op.State = "complete" + op.Result = result + if e != nil { + op.State = "unknown" + op.Error = "Действие K1 не подтверждено. Обновите состояние; повторная команда автоматически не отправляется." + } + if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil { + op.State = "unknown" + op.Error = "Результат не сохранён. Обновите состояние K1." + } +} + +func (d *DeviceEnrollment) Get(id string) *EnrollmentOperation { + d.mu.Lock() + defer d.mu.Unlock() + return copyEnrollment(d.operations[id]) +} +func (d *DeviceEnrollment) RemoteResults() []*EnrollmentOperation { + d.mu.Lock() + defer d.mu.Unlock() + out := []*EnrollmentOperation{} + for _, op := range d.operations { + if op.Remote { + out = append(out, copyEnrollment(op)) + if len(out) == 8 { + break + } + } + } + return out +} +func (d *DeviceEnrollment) Acknowledge(ids []string) { + d.mu.Lock() + defer d.mu.Unlock() + for _, id := range ids { + if op := d.operations[id]; op != nil && op.State != "running" { + op.Remote = false + _ = savePrivateJSON(filepath.Join(d.root, id+".json"), op) + } + } +} + +func (d *DeviceEnrollment) Routes(mux *http.ServeMux, server *Server) { + mux.HandleFunc("GET /api/devices/enrollment", func(w http.ResponseWriter, r *http.Request) { + if !server.authorized(w, r) { + return + } + v := d.Status() + _, name := server.Store.Public() + v["name"] = name + reply(w, 200, v) + }) + mux.HandleFunc("POST /api/devices/enrollment/operations", func(w http.ResponseWriter, r *http.Request) { + if !server.authorized(w, r) { + return + } + r.Body = http.MaxBytesReader(w, r.Body, 16384) + var c EnrollmentCommand + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if r.Header.Get("Content-Type") != "application/json" || decoder.Decode(&c) != nil { + reply(w, 400, map[string]string{"error": "Некорректный запрос подключения"}) + return + } + op, e := d.Submit(c, false) + if e != nil { + reply(w, 409, map[string]string{"error": e.Error()}) + return + } + reply(w, 202, op) + }) + mux.HandleFunc("GET /api/devices/enrollment/operations/{id}", func(w http.ResponseWriter, r *http.Request) { + if !server.authorized(w, r) { + return + } + op := d.Get(r.PathValue("id")) + if op == nil { + reply(w, 200, map[string]string{"state": "unknown", "error": "Результат недоступен. Обновите состояние K1."}) + return + } + reply(w, 200, op) + }) +} diff --git a/apps/node-agent/internal/node/device_enrollment_test.go b/apps/node-agent/internal/node/device_enrollment_test.go new file mode 100644 index 0000000..e9bd339 --- /dev/null +++ b/apps/node-agent/internal/node/device_enrollment_test.go @@ -0,0 +1,111 @@ +package node + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +type enrollmentHTTP func(*http.Request) (*http.Response, error) + +func (f enrollmentHTTP) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func enrollmentRequest() EnrollmentCommand { + return EnrollmentCommand{ID: "op_" + strings.Repeat("a", 32), NodeID: "node-test", RuntimeID: "runtime-test", Action: "connect", Deadline: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Parameters: map[string]any{"device_id": "test-ble", "mode_revision": float64(0), "discovery_generation": float64(1), "ssid": "test-network", "password": token()}} +} + +func TestEnrollmentJournalsNoCredentialAndDispatchesOnce(t *testing.T) { + root := t.TempDir() + d, e := OpenDeviceEnrollment(root, "node-test") + if e != nil { + t.Fatal(e) + } + var posts atomic.Int32 + d.client = &http.Client{Transport: enrollmentHTTP(func(r *http.Request) (*http.Response, error) { + body := `{"available":true,"runtime_id":"runtime-test"}` + if r.Method == "POST" { + posts.Add(1) + body = `{"available":true,"connected":true}` + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + c := enrollmentRequest() + secret := c.Parameters["password"].(string) + if _, e = d.Submit(c, true); e != nil { + t.Fatal(e) + } + deadline := time.Now().Add(time.Second) + for d.Get(c.ID).State == "running" && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if d.Get(c.ID).State != "complete" { + t.Fatal("operation did not complete") + } + // A repeated delivery owns no new physical intent, even with a replaced secret. + c.Parameters["password"] = token() + if _, e = d.Submit(c, true); e != nil { + t.Fatal(e) + } + if posts.Load() != 1 { + t.Fatal("duplicate dispatch") + } + journal, e := os.ReadFile(filepath.Join(root, "device-enrollment", c.ID+".json")) + if e != nil { + t.Fatal(e) + } + results, _ := json.Marshal(d.RemoteResults()) + if bytes.Contains(journal, []byte(secret)) || bytes.Contains(results, []byte(secret)) || bytes.Contains(journal, []byte("parameters")) { + t.Fatal("credential/parameters entered journal or results") + } +} + +func TestEnrollmentRestartDoesNotReplayRunningIntent(t *testing.T) { + root := t.TempDir() + d, e := OpenDeviceEnrollment(root, "node-test") + if e != nil { + t.Fatal(e) + } + c := enrollmentRequest() + op := EnrollmentOperation{ID: c.ID, NodeID: c.NodeID, RuntimeID: c.RuntimeID, Action: c.Action, State: "running", Remote: true} + if e := savePrivateJSON(filepath.Join(d.root, c.ID+".json"), op); e != nil { + t.Fatal(e) + } + restarted, e := OpenDeviceEnrollment(root, "node-test") + if e != nil { + t.Fatal(e) + } + if restarted.Get(c.ID).State != "unknown" { + t.Fatal("restart did not fence unknown result") + } + restarted.client = &http.Client{Transport: enrollmentHTTP(func(*http.Request) (*http.Response, error) { t.Fatal("restart contacted hardware"); return nil, nil })} + result, e := restarted.Submit(c, true) + if e != nil || result.State != "unknown" { + t.Fatal("repeated intent did not retain unknown outcome") + } +} + +func TestEnrollmentRejectsAnotherNodeExpiredAndHostMutation(t *testing.T) { + c := enrollmentRequest() + if !c.valid("node-test") { + t.Fatal("valid intent rejected") + } + if c.valid("another-node") { + t.Fatal("wrong node admitted") + } + c.Parameters["allow_host_wifi_switch"] = true + if c.valid("node-test") { + t.Fatal("host association admitted") + } + delete(c.Parameters, "allow_host_wifi_switch") + c.Deadline = time.Now().Add(-time.Second).Format(time.RFC3339Nano) + if c.valid("node-test") { + t.Fatal("expired operation admitted") + } +} diff --git a/apps/node-agent/internal/node/pairing.go b/apps/node-agent/internal/node/pairing.go index d188b74..e490a7f 100644 --- a/apps/node-agent/internal/node/pairing.go +++ b/apps/node-agent/internal/node/pairing.go @@ -39,20 +39,21 @@ type PairState struct { Revocations []CoreBinding `json:"revocations,omitempty"` } type Pairing struct { - Sensors *Sensors - mu sync.Mutex - path string - store *Store - state PairState - now func() time.Time - inventory func() Inventory - version string - lastSeen int64 - connection string - listenError string - failureWindow int64 - failures int - clients map[string]*http.Client + Sensors *Sensors + DeviceEnrollment *DeviceEnrollment + mu sync.Mutex + path string + store *Store + state PairState + now func() time.Time + inventory func() Inventory + version string + lastSeen int64 + connection string + listenError string + failureWindow int64 + failures int + clients map[string]*http.Client } func OpenPairing(store *Store, dir, version string, inventory func() Inventory) (*Pairing, error) { diff --git a/apps/node-agent/internal/node/pairing_transport.go b/apps/node-agent/internal/node/pairing_transport.go index e9348ab..f1a09bf 100644 --- a/apps/node-agent/internal/node/pairing_transport.go +++ b/apps/node-agent/internal/node/pairing_transport.go @@ -282,12 +282,28 @@ func (p *Pairing) channel(ctx context.Context) { payload["sensor_state"] = inv payload["sensor_results"] = p.Sensors.RemoteResults() } + if p.DeviceEnrollment != nil { + payload["device_enrollment"] = p.DeviceEnrollment.Status() + payload["enrollment_results"] = p.DeviceEnrollment.RemoteResults() + } result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload) p.mu.Lock() if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID { if e == nil && status == 200 { p.connection = "online" p.lastSeen = p.now().Unix() + if p.DeviceEnrollment != nil { + var ack []string + if json.Unmarshal(result["enrollment_acknowledgements"], &ack) == nil { + p.DeviceEnrollment.Acknowledge(ack) + } + var commands []EnrollmentCommand + if json.Unmarshal(result["enrollment_commands"], &commands) == nil { + for _, c := range commands { + _, _ = p.DeviceEnrollment.Submit(c, true) + } + } + } if p.Sensors != nil { var ack []string if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil { diff --git a/apps/node-agent/internal/node/sensors.go b/apps/node-agent/internal/node/sensors.go index 1cf1100..0e99d1c 100644 --- a/apps/node-agent/internal/node/sensors.go +++ b/apps/node-agent/internal/node/sensors.go @@ -45,19 +45,20 @@ type SensorOperation struct { Updated int64 `json:"updated_at"` } type Sensors struct { - events sensorEvents - mu sync.Mutex - prepareMu sync.Mutex - root string - nodeID string - instance string - client *http.Client - operations map[string]*SensorOperation - names map[string]string - initialized map[string]bool + events sensorEvents + mu sync.Mutex + prepareMu sync.Mutex + root string + nodeID string + instance string + client *http.Client + NetworkDevices *DeviceEnrollment + operations map[string]*SensorOperation + names map[string]string + initialized map[string]bool } -var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`) +var sensorID = regexp.MustCompile(`^(rsd455|k1)_[0-9a-f]{32}$`) var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`) func OpenSensors(root, nodeID string) (*Sensors, error) { @@ -168,6 +169,32 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) { func (s *Sensors) Inventory() map[string]any { items := []any{} seen := map[string]bool{} + if s.NetworkDevices != nil { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + result, e := s.NetworkDevices.call(ctx, "/inventory", nil) + cancel() + if e == nil { + if found, ok := result["items"].([]any); ok { + for _, raw := range found { + item, ok := raw.(map[string]any) + if !ok { + continue + } + id, _ := item["id"].(string) + if !strings.HasPrefix(id, "k1_") || !sensorID.MatchString(id) { + continue + } + s.mu.Lock() + if name := s.names[id]; name != "" { + item["name"] = name + } + s.mu.Unlock() + seen[id] = true + items = append(items, item) + } + } + } + } if result, e := s.driver("/inventory", nil); e == nil { if found, ok := result["items"].([]any); ok { for _, v := range found { @@ -259,6 +286,9 @@ func sensorViewAction(action string) bool { } func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) { + if strings.HasPrefix(c.Session.DeviceID, "k1_") && (c.Action == "prepare" || c.Action == "replay") { + return nil, errors.New("Эта операция не поддерживается K1.") + } if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 { return nil, errors.New("Некорректная команда устройства.") } @@ -344,7 +374,14 @@ func (s *Sensors) execute(c SensorCommand) { } } else { var v map[string]any - v, err = s.driver("/operation", c) + if strings.HasPrefix(c.Session.DeviceID, "k1_") && s.NetworkDevices != nil { + deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline) + ctx, cancel := context.WithDeadline(context.Background(), deadline) + v, err = s.NetworkDevices.call(ctx, "/sensor-operation", c) + cancel() + } else { + v, err = s.driver("/operation", c) + } uncertain = err != nil || v["state"] == "unknown" if err == nil { if v["state"] == "complete" { diff --git a/apps/node-agent/internal/node/server.go b/apps/node-agent/internal/node/server.go index c56e04f..819ddca 100644 --- a/apps/node-agent/internal/node/server.go +++ b/apps/node-agent/internal/node/server.go @@ -13,20 +13,21 @@ import ( ) type Server struct { - Store *Store - Pairing *Pairing - Sensors *Sensors - Assets fs.FS - Origin string - Version string - Inventory func() Inventory - Access *AccessStore - Tailscale func() TailscaleStatus - Environment func() EnvironmentStatus - mu sync.Mutex - logins map[string]time.Time - sessions map[string]time.Time - Now func() time.Time + Store *Store + Pairing *Pairing + Sensors *Sensors + DeviceEnrollment *DeviceEnrollment + Assets fs.FS + Origin string + Version string + Inventory func() Inventory + Access *AccessStore + Tailscale func() TailscaleStatus + Environment func() EnvironmentStatus + mu sync.Mutex + logins map[string]time.Time + sessions map[string]time.Time + Now func() time.Time } func token() string { @@ -82,6 +83,9 @@ func (s *Server) Handler() http.Handler { if s.Sensors != nil { s.Sensors.Routes(mux, s) } + if s.DeviceEnrollment != nil { + s.DeviceEnrollment.Routes(mux, s) + } if s.Pairing != nil { s.Pairing.localRoutes(mux, s) } diff --git a/apps/node-agent/packaging/50-mission-core-k1.rules b/apps/node-agent/packaging/50-mission-core-k1.rules new file mode 100644 index 0000000..a16bc55 --- /dev/null +++ b/apps/node-agent/packaging/50-mission-core-k1.rules @@ -0,0 +1,6 @@ +// Only WLAN discovery. No host association/profile modification authority. +polkit.addRule(function(action, subject) { + if (subject.user === "mission-core-k1" && action.id === "org.freedesktop.NetworkManager.wifi.scan") { + return polkit.Result.YES; + } +}); diff --git a/apps/node-agent/packaging/build.py b/apps/node-agent/packaging/build.py index 71984fc..ff09a55 100644 --- a/apps/node-agent/packaging/build.py +++ b/apps/node-agent/packaging/build.py @@ -11,7 +11,7 @@ import sys from build_deb import build, VERSION, BRAND_SHA256 ROOT = Path(__file__).resolve().parents[1] -DG_COMMIT = "999864e5b0a81555823cfa1ea6e8cf8a417c37f1" +DG_COMMIT = "1bdfc6c24072d38cc1068086ea271c444f2524ad" def guideline_sources(): @@ -32,6 +32,8 @@ def provenance(): "base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(), "design_guideline_commit": DG_COMMIT, "design_guideline_files": guideline_sources(), + "shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()}, + "k1_runtime_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "src/k1link").rglob("*")) if p.is_file() and p.suffix in (".py", ".json")}, "toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files} diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 1d0138f..bbccd8c 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -13,7 +13,7 @@ import tarfile ROOT = Path(__file__).resolve().parents[1] -VERSION = "0.6.11" +VERSION = "0.7.0" BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af" @@ -65,7 +65,7 @@ Architecture: amd64 Maintainer: NODE.DC local build Section: admin Priority: optional -Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme +Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, bluez, network-manager, iproute2, ffmpeg Description: Mission Core onboard computer configuration Local graphical setup, host inventory, SSH access and persistent node identity. """.encode() @@ -107,6 +107,29 @@ Description: Mission Core onboard computer configuration files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644)) for path in (ROOT / "sensors").glob("*.py"): files.append(("usr/lib/mission-core-node/sensors/" + path.name, path.read_bytes(), 0o644)) + for name in ("k1_prepare.py", "k1_bootstrap.py"): + files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644)) + files.append(("usr/lib/mission-core-node/install-k1-credential", (p / "install-k1-credential").read_bytes(), 0o755)) + files.append(("usr/lib/systemd/system/mission-core-k1.service", (p / "mission-core-k1.service").read_bytes(), 0o644)) + files.append(("usr/share/polkit-1/rules.d/50-mission-core-k1.rules", (p / "50-mission-core-k1.rules").read_bytes(), 0o644)) + k1_bundle = json.loads((p / "k1-bundle.json").read_text()) + files.append(("usr/share/mission-core-node/k1/bundle.json", (p / "k1-bundle.json").read_bytes(), 0o644)) + for item in k1_bundle["wheels"]: + data = (ROOT / "build/k1-wheels" / item["name"]).read_bytes() + if hashlib.sha256(data).hexdigest() != item["sha256"]: + raise ValueError("K1 bundle hash mismatch") + files.append(("usr/share/mission-core-node/k1/" + item["name"], data, 0o644)) + repository = ROOT.parents[1] + # Reuse the admitted plugin runtime and the transport-neutral renderer. + # No separate Core web service is started on the board. + for path in (repository / "src/k1link").rglob("*"): + if path.is_file() and path.suffix in (".py", ".json"): + files.append(("usr/lib/mission-core-node/k1/src/k1link/" + str(path.relative_to(repository / "src/k1link")), path.read_bytes(), 0o644)) + for relative in ("plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/plugin.manifest.json", + "config/observatory-equipment-models.json", + "config/observatory-recorded-capture-profiles.json", + "plugins/xgrids-k1/profiles/fw-3.0.2/local-network.v2.json"): + files.append(("usr/lib/mission-core-node/k1/" + relative, (repository / relative).read_bytes(), 0o644)) sdk = ROOT.parents[1] / "packages/plugin-sdk/python/missioncore_plugin_sdk" for path in sdk.rglob("*.py"): files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644)) diff --git a/apps/node-agent/packaging/fetch_driver_bundle.py b/apps/node-agent/packaging/fetch_driver_bundle.py index a55fa23..be66b17 100644 --- a/apps/node-agent/packaging/fetch_driver_bundle.py +++ b/apps/node-agent/packaging/fetch_driver_bundle.py @@ -1,13 +1,18 @@ """Engineering build input, never run on an operator board. Exact PyPI hashes only.""" import hashlib +import argparse import json +import time from pathlib import Path -from urllib.request import urlopen +from urllib.request import Request, urlopen root = Path(__file__).resolve().parents[1] -manifest = json.loads((root / "packaging/realsense-bundle.json").read_text()) -output = root / "build/realsense-wheels" +parser = argparse.ArgumentParser() +parser.add_argument("--model", choices=("realsense", "k1"), default="realsense") +model = parser.parse_args().model +manifest = json.loads((root / f"packaging/{model}-bundle.json").read_text()) +output = root / f"build/{model}-wheels" output.mkdir(parents=True, exist_ok=True) for item in manifest["wheels"]: target = output / item["name"] @@ -23,8 +28,24 @@ for item in manifest["wheels"]: ) if not source["url"].startswith("https://files.pythonhosted.org/"): raise ValueError("Unexpected package origin") - with urlopen(source["url"], timeout=120) as response: - data = response.read(item["bytes"] + 1) + partial = target.with_suffix(target.suffix + ".partial") + for attempt in range(4): + offset = partial.stat().st_size if partial.exists() else 0 + request = Request(source["url"], headers={"Range": f"bytes={offset}-"} if offset else {}) + try: + with urlopen(request, timeout=60) as response: + if offset and response.status != 206: + raise RuntimeError("Package server did not honor resume range") + with partial.open("ab" if offset else "wb") as stream: + while chunk := response.read(1024 * 1024): + stream.write(chunk) + break + except (OSError, TimeoutError): + if attempt == 3: + raise + time.sleep(2) + data = partial.read_bytes() if len(data) != item["bytes"] or hashlib.sha256(data).hexdigest() != item["sha256"]: raise ValueError("Driver checksum mismatch") - target.write_bytes(data) + partial.replace(target) + print(json.dumps({"model": model, "wheel": item["name"], "bytes": len(data)}), flush=True) diff --git a/apps/node-agent/packaging/install-k1-credential b/apps/node-agent/packaging/install-k1-credential new file mode 100644 index 0000000..137501b --- /dev/null +++ b/apps/node-agent/packaging/install-k1-credential @@ -0,0 +1,36 @@ +#!/usr/bin/python3 -I +"""Administrator-only import of the exact application key from protected stdin.""" +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +if os.geteuid() != 0 or len(sys.argv) != 1: + raise SystemExit("Root stdin import required") +secret = bytearray(sys.stdin.buffer.read(1025).strip()) +try: + if len(secret) != 36 or any(v < 33 or v > 126 for v in secret): + raise SystemExit("Credential does not match the reviewed K1 profile") + root = Path("/etc/credstore.encrypted") + root.mkdir(mode=0o700, exist_ok=True) + if root.is_symlink() or root.stat().st_uid != 0 or root.stat().st_mode & 0o022: + raise SystemExit("Unsafe credential store") + path = root / "k1-application" + if path.is_symlink() or path.exists(): + raise SystemExit("K1 credential already installed; explicit rotation required") + with tempfile.TemporaryDirectory(prefix=".k1-", dir=root) as directory: + staged = Path(directory) / "encrypted" + completed = subprocess.run( + ["/usr/bin/systemd-creds", "encrypt", "--name=k1-application", "--with-key=host", "-", str(staged)], + input=secret, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, + ) + if completed.returncode: + raise SystemExit("K1 credential import failed") + staged.chmod(0o600) + with staged.open("rb") as stream: + os.fsync(stream.fileno()) + # Atomic publication without overwriting a concurrently installed key. + os.link(staged, path) +finally: + secret[:] = b"\0" * len(secret) diff --git a/apps/node-agent/packaging/k1-bundle.json b/apps/node-agent/packaging/k1-bundle.json new file mode 100644 index 0000000..c2b5a83 --- /dev/null +++ b/apps/node-agent/packaging/k1-bundle.json @@ -0,0 +1,265 @@ +{ + "schema": "missioncore.node.driver-bundle/v1", + "model_id": "xgrids.k1", + "revision": "c7ed0bba39f757afdac176a8", + "python": "3.12", + "platform": "linux-amd64", + "lock_sha256": "551c8ccdc44bc3724328dd1e316c81d20e63d2e4dcd97148377d6cacef479246", + "wheels": [ + { + "name": "aioice-0.10.2-py3-none-any.whl", + "sha256": "14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf", + "bytes": 24875 + }, + { + "name": "aiortc-1.14.0-py3-none-any.whl", + "sha256": "4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e", + "bytes": 93183 + }, + { + "name": "annotated_doc-0.0.4-py3-none-any.whl", + "sha256": "571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", + "bytes": 5303 + }, + { + "name": "annotated_types-0.7.0-py3-none-any.whl", + "sha256": "1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", + "bytes": 13643 + }, + { + "name": "anyio-4.14.2-py3-none-any.whl", + "sha256": "9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", + "bytes": 125813 + }, + { + "name": "attrs-26.1.0-py3-none-any.whl", + "sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", + "bytes": 67548 + }, + { + "name": "av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", + "sha256": "7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", + "bytes": 41174337 + }, + { + "name": "bleak-3.0.2-py3-none-any.whl", + "sha256": "39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", + "bytes": 146490 + }, + { + "name": "certifi-2026.7.22-py3-none-any.whl", + "sha256": "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", + "bytes": 136983 + }, + { + "name": "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", + "bytes": 221822 + }, + { + "name": "click-8.4.2-py3-none-any.whl", + "sha256": "e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", + "bytes": 119243 + }, + { + "name": "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", + "sha256": "42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", + "bytes": 4459756 + }, + { + "name": "dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9", + "bytes": 852687 + }, + { + "name": "dnspython-2.8.0-py3-none-any.whl", + "sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", + "bytes": 331094 + }, + { + "name": "fastapi-0.139.0-py3-none-any.whl", + "sha256": "cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", + "bytes": 130339 + }, + { + "name": "foxglove_sdk-0.25.3-cp310-abi3-manylinux_2_28_x86_64.whl", + "sha256": "bcc894b88188d8169973cfbb1370300f671760adea9d6e9447e5a03b2289527d", + "bytes": 19220466 + }, + { + "name": "google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", + "sha256": "14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", + "bytes": 33364 + }, + { + "name": "h11-0.16.0-py3-none-any.whl", + "sha256": "63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", + "bytes": 37515 + }, + { + "name": "httpcore-1.0.9-py3-none-any.whl", + "sha256": "2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", + "bytes": 78784 + }, + { + "name": "httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", + "sha256": "b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", + "bytes": 523851 + }, + { + "name": "httpx-0.28.1-py3-none-any.whl", + "sha256": "d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", + "bytes": 73517 + }, + { + "name": "idna-3.18-py3-none-any.whl", + "sha256": "7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", + "bytes": 65455 + }, + { + "name": "ifaddr-0.2.0-py3-none-any.whl", + "sha256": "085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", + "bytes": 12314 + }, + { + "name": "lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", + "bytes": 1368249 + }, + { + "name": "markdown_it_py-4.2.0-py3-none-any.whl", + "sha256": "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", + "bytes": 91687 + }, + { + "name": "mdurl-0.1.2-py3-none-any.whl", + "sha256": "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "bytes": 9979 + }, + { + "name": "numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", + "bytes": 16672469 + }, + { + "name": "paho_mqtt-2.1.0-py3-none-any.whl", + "sha256": "6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", + "bytes": 67219 + }, + { + "name": "pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", + "bytes": 6940830 + }, + { + "name": "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", + "bytes": 155560 + }, + { + "name": "pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", + "sha256": "5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", + "bytes": 50088993 + }, + { + "name": "pycparser-3.0-py3-none-any.whl", + "sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", + "bytes": 48172 + }, + { + "name": "pydantic-2.13.4-py3-none-any.whl", + "sha256": "45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", + "bytes": 472262 + }, + { + "name": "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", + "bytes": 2094516 + }, + { + "name": "pyee-14.0.0-py3-none-any.whl", + "sha256": "3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d", + "bytes": 15553 + }, + { + "name": "pygments-2.20.0-py3-none-any.whl", + "sha256": "81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", + "bytes": 1231151 + }, + { + "name": "pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", + "bytes": 2434534 + }, + { + "name": "pyopenssl-26.2.0-py3-none-any.whl", + "sha256": "4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", + "bytes": 55823 + }, + { + "name": "python_dotenv-1.2.2-py3-none-any.whl", + "sha256": "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", + "bytes": 22101 + }, + { + "name": "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", + "bytes": 807870 + }, + { + "name": "rerun_sdk-0.36.3-cp310-abi3-manylinux_2_28_x86_64.whl", + "sha256": "287059b7154bf3881f5b32035f5772d0556d55a0a894650fb74a2605fb39afbe", + "bytes": 163018185 + }, + { + "name": "rich-14.3.4-py3-none-any.whl", + "sha256": "07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", + "bytes": 310480 + }, + { + "name": "shellingham-1.5.4-py2.py3-none-any.whl", + "sha256": "7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", + "bytes": 9755 + }, + { + "name": "starlette-1.3.1-py3-none-any.whl", + "sha256": "c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", + "bytes": 73632 + }, + { + "name": "typer-0.26.8-py3-none-any.whl", + "sha256": "3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", + "bytes": 122564 + }, + { + "name": "typing_extensions-4.16.0-py3-none-any.whl", + "sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", + "bytes": 45571 + }, + { + "name": "typing_inspection-0.4.2-py3-none-any.whl", + "sha256": "4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", + "bytes": 14611 + }, + { + "name": "uvicorn-0.51.0-py3-none-any.whl", + "sha256": "5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", + "bytes": 73219 + }, + { + "name": "uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", + "bytes": 4426307 + }, + { + "name": "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", + "bytes": 456398 + }, + { + "name": "websockets-16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", + "sha256": "35f41979c8623df9bd30d949d82010a8fda5c56ff12cd8508a5b7272b6d4b53a", + "bytes": 187345 + } + ] +} diff --git a/apps/node-agent/packaging/k1_bootstrap.py b/apps/node-agent/packaging/k1_bootstrap.py new file mode 100644 index 0000000..eddb271 --- /dev/null +++ b/apps/node-agent/packaging/k1_bootstrap.py @@ -0,0 +1,26 @@ +"""Fixed root-owned import path; no user site, environment path or import hooks.""" + +import os +import sys +from pathlib import Path + +root = Path("/var/lib/mission-core-k1-runtime") +reference = root / "active.path" +runtime = Path(reference.read_text().strip()) +if ( + reference.is_symlink() + or runtime.is_symlink() + or runtime.parent != root + or not runtime.name.isalnum() + or runtime.stat().st_uid != 0 + or runtime.stat().st_mode & 0o022 +): + raise RuntimeError("Unsafe K1 runtime") +sys.path[:0] = [ + str(runtime), str(runtime / "rerun_sdk"), + "/usr/lib/mission-core-node/k1/src", "/usr/lib/mission-core-node/sdk", +] +os.environ["MISSIONCORE_DATA_DIR"] = "/var/lib/mission-core-k1" +from k1link.device_plugins.xgrids_k1.node_bridge import main + +main() diff --git a/apps/node-agent/packaging/k1_prepare.py b/apps/node-agent/packaging/k1_prepare.py new file mode 100644 index 0000000..0b8db58 --- /dev/null +++ b/apps/node-agent/packaging/k1_prepare.py @@ -0,0 +1,94 @@ +"""Install only the bundled, hash-pinned Ubuntu K1 runtime; no network I/O.""" + +import hashlib +import json +import os +import shutil +import sys +import tempfile +import zipfile +from pathlib import Path, PurePosixPath + +SHARE = Path("/usr/share/mission-core-node/k1") +ROOT = Path("/var/lib/mission-core-k1-runtime") + + +def members(archive): + for info in archive.infolist(): + path = PurePosixPath(info.filename) + if ( + path.is_absolute() + or ".." in path.parts + or (info.external_attr >> 16) & 0o170000 == 0o120000 + or ".data" in path.parts + ): + raise RuntimeError("Unsafe K1 runtime archive") + # Rerun's pinned wheel declares this one static package directory. + # We do not execute .pth files; bootstrap adds the exact directory. + if info.filename.endswith(".pth") and not ( + info.filename == "rerun_sdk.pth" and archive.read(info) == b"rerun_sdk\n" + ): + raise RuntimeError("Unreviewed K1 Python path hook") + # Distribution script/data relocation must be handled deliberately, + # never interpreted as an install hook by the operator's Python. + if any(part.endswith(".data") for part in path.parts): + raise RuntimeError("K1 wheel requires unsupported relocation") + yield info + + +def prepare(): + if os.geteuid() != 0 or os.uname().machine != "x86_64" or sys.version_info[:2] != (3, 12): + raise RuntimeError("K1 runtime requires privileged Ubuntu amd64 Python 3.12 installation") + release = Path("/etc/os-release").read_text() + if "ID=ubuntu" not in release or 'VERSION_ID="24.04"' not in release: + raise RuntimeError("K1 runtime requires Ubuntu 24.04") + manifest = json.loads((SHARE / "bundle.json").read_text()) + revision = manifest["revision"] + if not revision.isalnum(): + raise RuntimeError("Invalid K1 runtime revision") + ROOT.mkdir(mode=0o755, exist_ok=True) + if ROOT.is_symlink() or ROOT.stat().st_uid != 0 or ROOT.stat().st_mode & 0o022: + raise RuntimeError("Unsafe K1 runtime root") + target = ROOT / revision + if target.is_symlink() or (ROOT / "active.path").is_symlink(): + raise RuntimeError("Unsafe K1 runtime reference") + for item in manifest["wheels"]: + path = SHARE / item["name"] + if ( + path.name != item["name"] + or path.is_symlink() + or hashlib.sha256(path.read_bytes()).hexdigest() != item["sha256"] + ): + raise RuntimeError("K1 runtime checksum mismatch") + if not target.exists(): + stage = Path(tempfile.mkdtemp(prefix=".k1-", dir=ROOT)) + try: + for item in manifest["wheels"]: + with zipfile.ZipFile(SHARE / item["name"]) as archive: + archive.extractall(stage, members=members(archive)) + for path in stage.rglob("*"): + path.chmod(0o755 if path.is_dir() else 0o644) + stage.chmod(0o755) + stage.rename(target) + finally: + if stage.exists(): + shutil.rmtree(stage) + for item in manifest["wheels"]: + with zipfile.ZipFile(SHARE / item["name"]) as archive: + for info in members(archive): + path = target / info.filename + if path.is_symlink() or ( + not info.is_dir() and path.read_bytes() != archive.read(info) + ): + raise RuntimeError("Installed K1 runtime differs from bundled wheel") + fd, name = tempfile.mkstemp(prefix=".active-", dir=ROOT) + with os.fdopen(fd, "w") as stream: + os.fchmod(stream.fileno(), 0o644) + stream.write(str(target)) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, ROOT / "active.path") + + +if __name__ == "__main__": + prepare() diff --git a/apps/node-agent/packaging/mission-core-k1.service b/apps/node-agent/packaging/mission-core-k1.service new file mode 100644 index 0000000..a077f42 --- /dev/null +++ b/apps/node-agent/packaging/mission-core-k1.service @@ -0,0 +1,38 @@ +[Unit] +Description=Mission Core Node K1 Bridge and acquisition +After=bluetooth.service NetworkManager.service +Wants=bluetooth.service NetworkManager.service + +[Service] +Type=simple +User=mission-core-k1 +Group=mission-core-node +SupplementaryGroups=bluetooth +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/k1_bootstrap.py +StateDirectory=mission-core-k1 +StateDirectoryMode=0700 +RuntimeDirectory=mission-core-k1 +RuntimeDirectoryMode=0750 +LoadCredentialEncrypted=k1-application +UMask=0007 +Environment=OMP_NUM_THREADS=2 OPENBLAS_NUM_THREADS=2 +Restart=on-failure +RestartSec=3 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +CapabilityBoundingSet= +LockPersonality=yes +TasksMax=128 +MemoryMax=2G +LimitNOFILE=2048 + +[Install] +WantedBy=multi-user.target diff --git a/apps/node-agent/packaging/postinst b/apps/node-agent/packaging/postinst index 1bb0274..cacd52a 100644 --- a/apps/node-agent/packaging/postinst +++ b/apps/node-agent/packaging/postinst @@ -8,6 +8,10 @@ case "$1" in if ! getent passwd mission-core-sensors >/dev/null; then adduser --system --group --home /var/lib/mission-core-sensors --no-create-home --disabled-login mission-core-sensors fi + if ! getent passwd mission-core-k1 >/dev/null; then + adduser --system --home /var/lib/mission-core-k1 --no-create-home --disabled-login --ingroup mission-core-node mission-core-k1 + fi + /usr/bin/python3 -I /usr/lib/mission-core-node/k1_prepare.py # Only bootstrap required to open the GUI. Operational configuration is a # versioned job started by «Настройка окружения → Сконфигурировать». if [ -d /run/systemd/system ]; then @@ -15,6 +19,8 @@ case "$1" in systemctl enable mission-core-node.service systemctl restart mission-core-node.service systemctl try-restart mission-core-realsense.service + systemctl enable mission-core-k1.service + systemctl restart mission-core-k1.service fi ;; esac diff --git a/apps/node-agent/packaging/preinst b/apps/node-agent/packaging/preinst index b49cb8d..cff2e9e 100644 --- a/apps/node-agent/packaging/preinst +++ b/apps/node-agent/packaging/preinst @@ -8,6 +8,12 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then exit 1 fi fi + if [ -S /run/mission-core-k1/driver.sock ]; then + if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("k1",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-k1/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then + echo "Mission Core Node: завершите подключение или запись K1 перед обновлением." >&2 + exit 1 + fi + fi mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true) case "$mc_node_device_job" in active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;; @@ -20,6 +26,11 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then exit 1 ;; esac + # End the admitted idle worker before dpkg replaces its Python modules. + # New UI commands now fail unavailable instead of racing the package copy. + if [ -f /usr/lib/systemd/system/mission-core-k1.service ]; then + systemctl stop mission-core-k1.service + fi fi . /etc/os-release if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then diff --git a/apps/node-agent/packaging/prerm b/apps/node-agent/packaging/prerm index 08d783d..da15f13 100644 --- a/apps/node-agent/packaging/prerm +++ b/apps/node-agent/packaging/prerm @@ -7,6 +7,12 @@ if [ -d /run/systemd/system ]; then exit 1 fi fi + if [ -S /run/mission-core-k1/driver.sock ]; then + if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("k1",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-k1/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then + echo "Mission Core Node: завершите подключение или запись K1 перед обновлением." >&2 + exit 1 + fi + fi mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true) case "$mc_node_device_job" in active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;; @@ -40,6 +46,8 @@ case "$1" in /usr/sbin/sshd -t systemctl try-reload-or-restart ssh.service fi + systemctl stop mission-core-k1.service + systemctl disable mission-core-k1.service || true systemctl stop mission-core-realsense.service systemctl disable mission-core-realsense.service || true systemctl stop mission-core-node.service diff --git a/apps/node-agent/packaging/resolve_k1_bundle.py b/apps/node-agent/packaging/resolve_k1_bundle.py new file mode 100644 index 0000000..f5828df --- /dev/null +++ b/apps/node-agent/packaging/resolve_k1_bundle.py @@ -0,0 +1,60 @@ +"""Resolve Linux wheel inputs from the frozen monorepo lock, without downloading.""" +import hashlib +import json +import subprocess +import tomllib +from pathlib import Path + +from packaging.markers import default_environment +from packaging.requirements import Requirement +from packaging.tags import compatible_tags, cpython_tags +from packaging.utils import canonicalize_name, parse_wheel_filename + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = ROOT.parents[1] + + +def resolve(): + requirements = ROOT / "build/k1-requirements.txt" + requirements.parent.mkdir(exist_ok=True) + subprocess.run(["uv", "export", "--frozen", "--extra", "node-device-media", "--no-dev", + "--no-emit-project", "--no-emit-package", "missioncore-plugin-sdk", "--no-hashes", + "--output-file", str(requirements)], cwd=REPOSITORY, check=True, stdout=subprocess.DEVNULL) + environment = default_environment() + environment.update(sys_platform="linux", platform_system="Linux", platform_machine="x86_64", + python_version="3.12", python_full_version="3.12.3", implementation_name="cpython", + platform_python_implementation="CPython") + platforms = [f"manylinux_2_{n}_x86_64" for n in range(39, 16, -1)] + ["manylinux2014_x86_64", "linux_x86_64"] + tags = list(cpython_tags((3, 12), platforms=platforms)) + list(compatible_tags((3, 12), interpreter="cp312", platforms=platforms)) + ranks = {tag: i for i, tag in enumerate(tags)} + lock_data = (REPOSITORY / "uv.lock").read_bytes() + lock = tomllib.loads(lock_data.decode()) + items = [] + for line in requirements.read_text().splitlines(): + if not line or line.lstrip().startswith("#"): + continue + requirement = Requirement(line) + if requirement.marker and not requirement.marker.evaluate(environment): + continue + name = canonicalize_name(requirement.name) + package = next(v for v in lock["package"] if canonicalize_name(v["name"]) == name and v["version"] in requirement.specifier) + candidates = [] + for wheel in package.get("wheels", []): + filename = wheel["url"].split("/")[-1] + _, _, _, wheel_tags = parse_wheel_filename(filename) + matches = [ranks[tag] for tag in wheel_tags if tag in ranks] + if matches: + candidates.append((min(matches), filename, wheel)) + if not candidates: + raise RuntimeError("No reviewed Linux wheel: " + name) + _, filename, wheel = min(candidates) + items.append({"name": filename, "sha256": wheel["hash"].removeprefix("sha256:"), "bytes": wheel["size"]}) + revision = hashlib.sha256(json.dumps(items, sort_keys=True).encode()).hexdigest()[:24] + manifest = {"schema": "missioncore.node.driver-bundle/v1", "model_id": "xgrids.k1", "revision": revision, + "python": "3.12", "platform": "linux-amd64", "lock_sha256": hashlib.sha256(lock_data).hexdigest(), "wheels": items} + (ROOT / "packaging/k1-bundle.json").write_text(json.dumps(manifest, indent=2) + "\n") + print(json.dumps({"wheels": len(items), "bytes": sum(v["bytes"] for v in items), "revision": revision})) + + +if __name__ == "__main__": + resolve() diff --git a/apps/node-agent/ui/package-lock.json b/apps/node-agent/ui/package-lock.json index 6c318f5..a4c22df 100644 --- a/apps/node-agent/ui/package-lock.json +++ b/apps/node-agent/ui/package-lock.json @@ -11,6 +11,7 @@ "@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", "@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", + "@rerun-io/web-viewer": "0.36.3", "react": "19.1.0", "react-dom": "19.1.0" }, @@ -18,7 +19,8 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "typescript": "^5.8.3", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vite-plugin-wasm": "^3.6.0" } }, "../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": { @@ -524,6 +526,12 @@ "resolved": "../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "link": true }, + "node_modules/@rerun-io/web-viewer": { + "version": "0.36.3", + "resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz", + "integrity": "sha512-LMGnsxRmY5UwiGras2dZrMnEYkow5Xr4v+1hAUSspXWPPiilMqoz9G77jo8Ps/deAaX81TnOq123DFO8iX/Ulw==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", @@ -1239,6 +1247,16 @@ "optional": true } } + }, + "node_modules/vite-plugin-wasm": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz", + "integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } } } } diff --git a/apps/node-agent/ui/package.json b/apps/node-agent/ui/package.json index 1c77b70..a1d20c7 100644 --- a/apps/node-agent/ui/package.json +++ b/apps/node-agent/ui/package.json @@ -9,6 +9,7 @@ "build": "tsc --noEmit && vite build" }, "dependencies": { + "@rerun-io/web-viewer": "0.36.3", "@nodedc/ui-react": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-core": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/tokens": "file:../../../../NODEDC_DESIGN_GUIDELINE/packages/tokens", @@ -16,6 +17,7 @@ "react-dom": "19.1.0" }, "devDependencies": { + "vite-plugin-wasm": "^3.6.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "typescript": "^5.8.3", diff --git a/apps/node-agent/ui/rerun-runtime.html b/apps/node-agent/ui/rerun-runtime.html new file mode 100644 index 0000000..08963b8 --- /dev/null +++ b/apps/node-agent/ui/rerun-runtime.html @@ -0,0 +1,16 @@ + + + + + + Живой просмотр K1 + + + +
+ + + diff --git a/apps/node-agent/ui/src/NodeSensors.tsx b/apps/node-agent/ui/src/NodeSensors.tsx index 26294e1..8842b3a 100644 --- a/apps/node-agent/ui/src/NodeSensors.tsx +++ b/apps/node-agent/ui/src/NodeSensors.tsx @@ -1,5 +1,6 @@ +import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost'; import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace'; import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts'; import {request} from './api'; -const transport:SensorTransport={subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))}; -export function NodeSensors(){return ;} +const transport:SensorTransport={enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))}; +export function NodeSensors(){return ;} diff --git a/apps/node-agent/ui/src/rerunRuntime.ts b/apps/node-agent/ui/src/rerunRuntime.ts new file mode 100644 index 0000000..2753a41 --- /dev/null +++ b/apps/node-agent/ui/src/rerunRuntime.ts @@ -0,0 +1 @@ +import "../../../control-station/src/components/rerun/isolatedRerunEntry"; diff --git a/apps/node-agent/ui/vite.config.js b/apps/node-agent/ui/vite.config.js index 6c16b4f..17c5972 100644 --- a/apps/node-agent/ui/vite.config.js +++ b/apps/node-agent/ui/vite.config.js @@ -1,9 +1,17 @@ import { defineConfig } from "vite"; +import { fileURLToPath } from "node:url"; +import wasm from "vite-plugin-wasm"; export default defineConfig({ + plugins: [wasm()], // Shared sensor TSX lives outside this app tsconfig; use the same JSX runtime. esbuild: { jsx: "automatic" }, // Design Guideline packages are linked during development. Their own React // must never become a second hook dispatcher in the portable production bundle. resolve: { dedupe: ["react", "react-dom", "@nodedc/ui-react"] }, + optimizeDeps: { exclude: ["@rerun-io/web-viewer"] }, + build: { target: "esnext", rollupOptions: { input: { + app: fileURLToPath(new URL("./index.html", import.meta.url)), + rerun: fileURLToPath(new URL("./rerun-runtime.html", import.meta.url)), + } } }, }); diff --git a/docs/04_K1_WIFI_PROVISIONING_PROFILE.md b/docs/04_K1_WIFI_PROVISIONING_PROFILE.md index ce193bb..aa9f5d3 100644 --- a/docs/04_K1_WIFI_PROVISIONING_PROFILE.md +++ b/docs/04_K1_WIFI_PROVISIONING_PROFILE.md @@ -171,6 +171,25 @@ mode. It never fragments or retries the payload automatically. ## Expected transition and evidence of acceptance +### Firmware station error replies + +Offline review of the exact 3.0.2 `lixel_nman` callback found that station +`wifi_connect` returns are passed directly to the 7f01 GATT write result: +4 denotes its SSID-not-found paths and 6 denotes its credentials-required path. +Generic Bluetooth libraries label the same values INVALID_PDU and +REQUEST_NOT_SUPPORTED. Preserve those raw values but do not discard the +reviewed device interpretation. The evidence and disassembly addresses are in +[the callback audit](audits/2026-09-06-k1-station-reply-semantics.md). + +Apply this interpretation only to the selected exact firmware station profile +and its annotated 99-byte write-with-response failure. It does not apply to +Quick Connect, a failed baseline read or arbitrary ATT errors. It grants no +retry, network-state confirmation or ledger resolution. The firmware performs +Wi-Fi work before completing this callback; a delayed reply alone is not proof +that the BLE connection request was the slow stage. + +### Acceptance observations + A completed GATT write only proves transport completion. It does not prove that the K1 joined Wi-Fi or began beaconing. The application polls `7f02`; the observed response frame contains a fixed-width text slot, an address slot, a diff --git a/docs/audits/2026-09-06-k1-bridge-connection-incident.md b/docs/audits/2026-09-06-k1-bridge-connection-incident.md new file mode 100644 index 0000000..95c6f9d --- /dev/null +++ b/docs/audits/2026-09-06-k1-bridge-connection-incident.md @@ -0,0 +1,140 @@ +# K1 Bridge: provisioning recovery and first-scan discovery + +Scope: the operator-local K1 connection in LAB on canonical Core `8000`. +The onboard installation is a separate pending acceptance task. + +The later [firmware callback audit](2026-09-06-k1-station-reply-semantics.md) +explains the two fresh 19:22/19:23 UTC failures and supersedes the earlier +generic ATT interpretation below with a bounded FW 3.0.2 station diagnosis. + +## Evidence and diagnosis + +Three explicit Bridge attempts returned HTTP 502 on 2026-09-06 at +18:04:50, 18:05:13 and 18:05:43 UTC. All failed at `gatt-write` with +`BleakGATTProtocolError`, ATT 4 `INVALID_PDU`. Each attempted one 99-byte +write-with-response; acknowledgement and a joined Wi-Fi address were absent. +The characteristic advertised `read, write`; the observed write-without-response +capacity was 253 bytes. This capacity alone does not prove a successful ATT write. + +A separately admitted, exact-target Bridge `connection.verify` at 18:15 UTC +read K1 state without a provisioning write or host Wi-Fi switch. K1 answered, +but supplied no shared-LAN address (`connection-verify-address-unavailable`). +A preceding request was rejected at input validation because of the diagnostic +operation identifier format; it performed no device I/O. + +The same ATT error exists in August's private logs. The accepted August Bridge +record uses the same Bleak 3.0.2, write mode, frame length and capacity. Successful +historical writes also include an empty network baseline, so an empty baseline +does not justify adding an AP-enable command or rejecting Bridge in advance. +The exact reason for the peripheral's rejection remains unproved; a wrong Wi-Fi +password, frame-format regression or MTU failure must not be asserted from this +error alone. No alternate transport mode, frame, retry, START or STOP was sent. + +Private evidence is retained under the ignored incident directory, with UTC and +monotonic timestamps, redacted events and SHA-256 artifact index. Credentials and +raw packet contents are absent from this report and source changes. + +## Software causes and refactor + +- The Connect hook had three overlapping result/error branches. Its failed + HTTP path accepted the server snapshot but returned `observedState: null` to + the form; the form therefore lost the exact operation and its ATT diagnosis. +- A completed Bluetooth search suppressed the recovery surface even after the + subsequent, click-owned Connect failed. The disabled credential form instead + promised a future safe continuation despite there being no running operation. +- Recovery guidance claimed an automatic recovery or a completed UI reset that + had not occurred. The lead status fell back to idle despite a terminal failure. +- Durable JSON logging omitted the write-mode, frame-size and GATT-property + fields already supplied by the BLE implementation. + +`networkProvisioning.ts` now owns the single submission, bounded observation of +that exact idempotency key, monotonic snapshot selection and reviewed failure +copy. An existing operation is only observed, never resubmitted. The runtime +hook retains exact applied-network/control-authority checks and React action +ownership. The form renders the returned operation and allows explicit recovery +after its failed attempt even when its preceding search is complete. It still +clears the credential immediately upon submission. + +The frozen BLE frames and canonical MQTT dialogue are unchanged. Ops reference: +MISSIONCOR-3, “Mission Core. Lixel K1 / XGRIDS Integration”, and +`docs/lab/002_LIXELGO_IPHONE_LOCAL_PROTOCOL_20260716.redacted.md`. +That iPhone capture begins at the IP stack and does not contain Bluetooth HCI. +The reviewed BLE profile remains `docs/04_K1_WIFI_PROVISIONING_PROFILE.md`. + +## Validation and remaining acceptance + +Focused frontend: 227 passed, including new behavioral cases for HTTP 502, +existing-key non-replay, lost-response success, pending settlement, stale REST +versus newer WebSocket, superseded intent and unrelated journal rows. The +rendered regression covers completed search followed by failed Connect and +requires enabled recovery choices instead of the trapped form. + +Full frontend suite: 772 passed sequentially; TypeScript and final production build passed. Application architecture: 4 passed. + +BLE and persistent-diagnostic tests: 34 passed. Canonical guardrail: four +immutable LixelGO captures verified in their original checkout, frozen +protocol/Bridge contour unchanged, 32 synthetic sentinels passed. The convenience +script initially failed because raw captures are deliberately absent from the +active worktree; its original-location integrity check passed without copying +or changing the captures. Ruff and diff checks passed. + +A later runtime journal records an explicit successful Bridge write at 18:42 UTC, +followed by application-control acceptance, canonical START and the first point +frame, then a confirmed STOP at 18:44 UTC. These actions occurred while the +assistant was editing/testing discovery; they were not dispatched by this +investigation. The success followed the recovery-UI refactor and preceded the +new discovery implementation. It proves a subsequent successful physical +connection, not the cause or permanent resolution of the intermittent ATT error. +Do not automatically replay the earlier failed attempts. + +## First-scan miss + +The operator reported that an already active K1 is absent from the first search +and appears on the second. This was reproduced on the original six-second +implementation: 18:32 UTC returned four devices and zero K1 candidates; a second +explicit scan at 18:35 UTC returned five devices including the expected K1. +These observations do not distinguish radio advertisement latency from macOS +state and do not establish that the camera was powered off. + +Discovery now opens one native Bleak scanner context. It listens for an initial +six-second window and, if no K1 name has appeared, continues the same context +up to a twenty-second bound, stopping on a later K1 candidate. There is no hidden +second scan, GATT connection, provisioning retry or cache-based candidate +promotion. Name matching only ends discovery; compatibility and connection +still require their separate evidence. The owner arbiter, generation revocation, +native-handle capture and cancellation cleanup remain in place. A caller's +explicit shorter duration is respected. + +The shared frontend request/countdown, backend default and Node Bridge source +use the twenty-second bound. Completed operations and private structured logs +now include scanner startup, total elapsed time, first-candidate time and whether +the initial window was extended. They contain no Wi-Fi credentials. + +After the new canonical process started, the first explicit UI search at +18:51 UTC found the expected K1. Native startup took 766 ms, first K1 detection +2023 ms from scanner construction, and total discovery 6768 ms. Extension was +not needed. The browser showed the fresh candidate and the arbiter returned idle. +This is one successful process-restart test; the browser cache was not cleared. +The operator requested a separate test after browser-cache clearing. That +acceptance is pending: the available in-app browser automation exposes no cache +clearing capability, and the clear-browsing-data keyboard shortcut had no effect. +The operator was asked to clear it; no cache clearing is claimed. + +Additional discovery validation: 73 backend tests passed (scanner, owner arbiter, +persistent diagnostics and Node Bridge), seven facade discovery tests passed, +138 focused frontend tests passed, and architecture checks passed. TypeScript +and production build passed. Final focused discovery checks, Ruff, whitespace +and the frozen protocol/Bridge comparison passed. The previously completed full +772-test frontend pass applies to the recovery refactor; only affected suites +were rerun for the subsequent discovery change. + +Canonical Core remains on port 8000; no listener exists on 8765. Onboard source +is synchronized separately; this investigation does not install or rebuild the +pending Node package. + +## Ops publication + +The existing Ops canon was read. Automatic approval review rejected the attempted +card update because consultation was authorized but publication of internal +technical details was not. No Ops card was changed; this local report is the +reviewable result pending explicit publication authorization. diff --git a/docs/audits/2026-09-06-k1-live-reference-camera-root.md b/docs/audits/2026-09-06-k1-live-reference-camera-root.md new file mode 100644 index 0000000..fad5052 --- /dev/null +++ b/docs/audits/2026-09-06-k1-live-reference-camera-root.md @@ -0,0 +1,129 @@ +# K1 live reference comparison and missing camera + +Scope: operator-local K1 Bridge data acquisition on canonical Mission Core 8000. +No new physical START, STOP, provisioning or RTSP request was sent during this +investigation. The comparison uses the existing 6 September capture. + +## Authoritative references found in Ops + +MISSIONCOR-3, “Mission Core. Lixel K1 / XGRIDS Integration”, block “Внутренние +эталоны Mission Core” and its comment dated 22 August 2026 identify: + +- Fast reference A: eaad9de, 20260822T105904Z_viewer_live. START to calibration + under 5 seconds; calibration 21 seconds; after calibration cloud 2 seconds, + right camera 4 seconds; STOP to physical onset 1 second. +- Recovery reference B: 1001a31, 20260822T130323Z_viewer_live. START 14 seconds; + calibration 22 seconds; cloud +1 second, camera +8 seconds; STOP onset 7 seconds. + +The linked canonical report is +[Lab 010](../lab/010_K1_MISSION_CORE_INTERNAL_LIVE_BASELINE_20260822.redacted.md). +These are measured internal references for one K1 FW 3.0.2 and Bridge/direct LAN, +not a transferable SLA or a standalone Rerun preset. A used a short physical +ledger; B retained mature recovery history. Source quality was not reduced. + +MISSIONCOR-66, “Mission Core. Канон интеграции Rerun”, and MISSIONCOR-74, +“Additional Core · Переносимая кастомизация Rerun”, define the separate +presentation contracts: + +| Surface | Current profile | Clock and media | +| --- | --- | --- | +| Live acquisition | live-acquisition | stream_time; upstream live Rerun; right camera through the independent RTSP → durable fMP4 → MSE path | +| Saved Sessions / Data | recorded-session | session_time; progressive recorded admission; RecordedFmp4Player follows the shared playback clock | +| Canonical LAB result | laboratory-result | session_time; result-specific settings; merged RRD with native AssetVideo/VideoFrameReference; separate presentation gate | + +The live receiver does not inherit the LAB full-readiness gate. Changing point +size, accumulation or blueprint cannot repair a camera producer that never +started. The current live display settings include accumulation 47 seconds, +point size 1 and height/viridis; no evidence identifies those visual values as +part of the fast reference, so they were not arbitrarily reset. + +### Settings isolation limitation found during the audit + +Distinct profile kinds, clocks and media admission do not prove complete +settings isolation. App.tsx still owns one sceneSettings/displayDraft pair for +live and Data, and useWorkspaceLayoutProfile() loads and saves one +observation.spatial profile containing scene settings. The normal settings +committer suppresses backend writes while recorded replay is presented, but it +still updates the common in-memory settings. The persisted layout restore/apply +path also has no profile-kind namespace. LAB uses its own resultId-scoped draft +and durable view profile. + +Therefore this audit confirms distinct presentation contracts and LAB settings +ownership, not full live/Data settings isolation. No shared settings, layout, +Rerun renderer or replay code was changed for this camera repair. The focused +profile tests below do not cover the remaining live/Data settings coupling. +Separating that storage and state requires its own transition/race and browser +regression checks; it must not be folded into a camera-path fix implicitly. + +## Observed 6 September failure + +Existing session 20260906T184240Z_viewer_live: + +| Metric | Fast A | Recovery B | Recent run | +| --- | ---: | ---: | ---: | +| MQTT callback → publication p50 | 23.839 ms | 83.934 ms | 93.151 ms | +| MQTT callback → publication p95 | 41.541 ms | 223.589 ms | 208.989 ms | +| Preview drops | 0 | 70 | 58 | +| Point decode errors | 0 | 0 | 0 | +| Camera archive | complete | complete | absent | + +Run lengths differ; drop counts are not normalized performance rates. Device +calibration onset and first visible pixels were not independently measured in +the recent run, so the historical operator timings are not falsely compared to +backend timestamps. The recent run published 1,028,061 points in 386 PCL frames. + +At 18:43:15 UTC the browser admitted a Rerun store; this alone is not proof of +visible point pixels. Between 18:43:20 and 18:43:50 the backend logged 22 failed +post-authoritative-PCL camera activations. First-PCL admission took 4–34 ms. +No camera producer activation success or camera media artifact exists in this +session. The private formatter discarded exception details, preventing recovery +of each historical exception stack from those records. + +## Reproduced storage defect and bounded repair + +The running checkout is separate from MISSIONCORE_DATA_DIR. Acquisition uses +resolve_missioncore_evidence_dir(), but XgridsK1CameraGateway previously confined +session paths to repository_root. The actual session is outside the checkout. +An offline call using the real existing session directory deterministically +raised “camera recording root must stay inside the repository” before authority +reservation, FFmpeg preparation or network I/O. Camera remained idle, matching +the observed pre-producer failure. This mismatch necessarily blocks recording +at the configured path even though the original exception stacks were lost. + +The gateway now receives an explicit evidence_root from the existing service +composition. It confines both acquisition-owned and selected-preview recording +to that root after resolving paths. It rejects sibling directories and symlink +escapes. The source checkout remains the FFmpeg-binary lookup root; the fallback +for standalone gateway callers preserves their existing repository confinement. +No RTSP arguments, video quality, stream choice, camera producer lifecycle, +START/STOP, MQTT dialogue, Rerun blueprint or LAB/archive viewer policy changed. + +Private exception diagnostics now retain only the exception class and final +filename/line/function. They omit exception text, locals, source lines and +absolute paths. This makes future failures attributable without leaking data. + +## Validation and remaining physical acceptance + +- Camera gateway suite: 42 passed, including seven new external-root, + composition-wiring and path-confinement cases. Synthetic FFmpeg produced + and archived media in the configured external directory. +- Camera acquisition lifecycle: 37 passed. +- Persistent diagnostics suite: 8 passed, including exception-location redaction. +- Focused frontend profile, environment, LAB view profile and recorded-camera + journal checks: 14 passed. These are contract-level checks, not physical + playback acceptance or proof of complete settings isolation. +- Mypy on camera.py and runtime_diagnostics.py: passed. +- Ruff and git diff --check: passed. +- Protocol, BLE provisioning/AP, physical ledger/coordinator, MQTT, + connection supervisor, runtime and archive remain identical to c041a56. +- The broad frozen-contour guard also includes camera.py, so it now deliberately + detects this narrow camera storage change. Its baseline was not advanced or + weakened. This is not a claim that the full freeze check passes unchanged. + +The canonical idle service was refreshed and its replacement was confirmed +ready on port 8000 at 19:10:22 UTC; no frontend rebuild was required. A new +operator-started physical run is still +needed to verify right-camera appearance, durable media and browser playback. +The existing failed session is preserved and is not retroactively repaired. + +Ops was consulted read-only. This local report was not published to a card. diff --git a/docs/audits/2026-09-06-k1-station-reply-semantics.md b/docs/audits/2026-09-06-k1-station-reply-semantics.md new file mode 100644 index 0000000..b7ad82f --- /dev/null +++ b/docs/audits/2026-09-06-k1-station-reply-semantics.md @@ -0,0 +1,95 @@ +# K1 station replies: misleading ATT error and fresh failures + +Scope: the two operator-started Bridge attempts on Core 8000 following the +operator-reported browser cache clear. No device write, START/STOP, Wi-Fi switch, +subnet scan or firmware execution was performed by this investigation. + +## Fresh evidence + +| Attempt, UTC | K1 first advertisement | Complete scan | Connect operation | Reply | +| --- | ---: | ---: | ---: | --- | +| 19:22:25 | 1.308 s | 6.366 s | 51.399 s | ATT 4 INVALID_PDU | +| 19:23:55 | 2.208 s | 6.023 s | 7.587 s | ATT 4 INVALID_PDU | + +Both attempts reached the validated GATT contract, baseline status read and +one 99-byte write-with-response. Neither write was acknowledged as successful. +The observed command capacity was 253 bytes. Empty passwords are rejected +before this path; presence does not prove correctness. Individual connect and +write durations were not recorded, so total latency is not falsely attributed +entirely to the Wi-Fi operation or entirely to CoreBluetooth. + +Private snapshots, operation identities and the disassembly are retained under +the ignored `.runtime/k1-connect-incident-20260906/fresh-1922/` directory with +0600 files, UTC/monotonic timestamps and a SHA-256 artifact index. + +## Firmware-level explanation + +The reviewed official FW 3.0.2 artifact is the immutable source documented in +[Lab 004](../lab/004_K1_FW302_AP_CREDENTIAL_PROVIDER_20260720.redacted.md). +Its extracted `lixel_nman` executable is 323,464 bytes, SHA-256 +`aead745e4e0073ae99e84e851e5560161f2d560d5804f21e5268116efbb1dc42`, +ELF build ID `205fb546e44667ca0e74159a7672269a81b754f5`. + +Bounded offline AArch64 disassembly established: + +1. At 0x14588 the service registers characteristic 7f01, with write callback + 0x17e48 selected at 0x14598. +2. Its station branch calls `wifi_connect` at 0x18034 → 0x15440. The return + value is retained in w28 at 0x18038 and passed as the write-result error at + 0x17fc4 → 0x291a8. +3. `wifi_connect` returns 4 in its network-not-found branches: 0x16190 and + 0x16a2c. Adjacent diagnostics refer explicitly to the SSID not being found. +4. The branch matching NetworkManager's credentials-required output returns + 6 at 0x1641c. This describes an unsuccessful credential path, not proof + of the exact incorrect character or how the form was populated. + +Thus the device's application return codes collide with generic ATT names: +4 is displayed as INVALID_PDU and 6 as REQUEST_NOT_SUPPORTED. The existing +Mission Core message discarded the reviewed device meaning. Neither changing +the 99-byte layout nor switching write mode follows from these observations. +The callback performs Wi-Fi work before returning, so its response is not merely +an instantaneous transport acknowledgment. + +This is an interpretation under the selected exact firmware profile. It is not +a universal ATT error mapping, proof of the live firmware before DeviceInfo, +or confirmation that the network configuration remained unchanged. The actual +SSID/radio condition still needs a corrected operator-run connection test. +The old screenshot displays a network name whose exact spelling was queried; +this report does not assume that spelling is a typo or assume K1 can see it. + +## Bounded correction + +`wifi_failure.py` owns the pure profile-scoped classification. The service uses +it only for Bridge/Direct, selected FW 3.0.2, an annotated gatt-write failure, +a dispatched/unconfirmed 99-byte write-with-response and BleakGATTProtocolError. +It retains the original exception code and raw ATT facts. Quick Connect, +baseline-read failures, other firmware and unrelated errors retain their +original diagnostics. Retry flags, mutation ledger and ambiguity remain intact. + +The frontend shares the station guidance between the form and recovery panel. +It offers “Указать сеть Wi-Fi заново” through the existing explicit reset and +scan flow. The pending stage explains K1 Wi-Fi connection. It does not populate, +store, inspect or change the password. The GATT helper, packet bytes, MQTT, +physical command control and all Rerun profiles are unchanged in this increment. + +## Validation + +- 19 focused backend cases passed: 16 classification boundary cases, two + service-level reply cases and the existing ambiguous-write recovery case. + They verify one submission, retained raw codes, failed/unconfirmed state and + an unresolved ledger rather than promoting an error to success. +- 24 classification/diagnostic cases passed; mypy and Ruff passed. +- 775 frontend tests passed, including rendered station guidance and the + explicit same-device network setup action. Architecture: 4 passed. +- Production typecheck and build passed. The replacement canonical Core was + confirmed ready at 2026-09-06T19:41:10.665304+00:00. +- Browser check: the real Core 8000 Test Devices page loaded; normal and expanded + sizes worked. The page was left open. No BLE action was clicked. The new error + messages were covered by rendered tests, not represented as a new hardware run. +- Hardware Wi-Fi acceptance remains pending the exact target SSID and an + operator-started attempt. macOS networksetup did not provide the current SSID; + its output was not treated as proof that the host network is down. + +Ops MISSIONCOR-3 was read through the direct MCP. Its iPhone network capture +starts at IP and does not contain Bluetooth HCI; it cannot establish the meaning +of this GATT callback. No Ops card was written. diff --git a/docs/audits/2026-09-06-node-k1-bridge-implementation.md b/docs/audits/2026-09-06-node-k1-bridge-implementation.md new file mode 100644 index 0000000..ea7a346 --- /dev/null +++ b/docs/audits/2026-09-06-node-k1-bridge-implementation.md @@ -0,0 +1,164 @@ +# K1 Bridge on the onboard computer + +Authority: the owner's 2026-09-06 request and two annotated Fleet screenshots. +The earlier Node context is historical evidence, not a new instruction. X4 work +is paused while its battery charges. Hardware acceptance below remains pending +until the owner powers on K1 and supplies the target WLAN in the application. + +## Product surface decision + +The operator occasionally adds a wireless sensor to one selected onboard +computer. The primary entity is that computer's device inventory. Discovery, +network observations, credentials and device control belong to that computer; +the operator's Mission Core is a paired remote console. Node's local console +uses the same form and backend contract. + +Selected composition: a plus beside refresh in the device inventory opens the +canonical modal Window, titled «Подключение устройства к БК». The target board +is visible before discovery or credentials. K1 is selected from an explicit +scan on that board. The operator chooses a board-observed WLAN or enters an +SSID, then submits one Bridge operation. Success requires observed device +connectivity; dispatch alone is never shown as connected. + +An independent Fleet connection workspace was considered: it separates the +action from its board and can imply operator-local radios. A new primary root +was also unnecessary. The owner explicitly selected the inventory plus and +authorized moving the existing operator-local connection workspace into LAB, +with all its current modes and functions, under «Тестовые устройства». +The stable workspace ID is retained for saved navigation. + +This is domain content in admitted list/detail and modal compositions. Reuse +`Window`, `WindowFooterActions`, `TextField` (including password), `Select`, +`Button`, `IconButton`, `ResourceRow`, `SettingsCard`, `StatusBadge`, +`ActivityIndicator`, `ToastStack`; icons `plus`, `refresh`, `network`, `camera`, +`eye`, `settings`, `close`, `play`, `stop`. These exist in the sibling Design +Guideline registries. No new generic visual entity is introduced. + +State grammar: board unavailable; service unavailable; ready to scan; +scanning; no candidates; candidate selected; network list unavailable with +manual entry; ready to connect; connecting; observed connected; failed before +write; outcome unknown after possible write. A stale discovery/runtime or +changed board invalidates the form. Closing the modal clears its password. +Closing after dispatch does not claim cancellation of a physical operation. + +## Execution and security boundaries + +Only Bridge is admitted on Node. Reuse the reviewed firmware 3.0.2 profile and +99-byte 7f01 operation with 7f02 observation and exact endpoint verification. +No Quick Connect, host association, AP enable, subnet scan or firmware action +is exposed by the Node adapter. BLE notifications, where used by the existing +profile, can entail the standard temporary CCCD subscription write. + +Use the existing authenticated Node/Core channel. A device-enrollment command +targets the paired node and a worker runtime/discovery generation, even before +a device session exists. Wi-Fi credentials must not enter the existing durable +sensor-command journal, Fleet database, error text, evidence or argv. Pending +secret payloads are short-lived memory only. A restart, expired command or +uncertain dispatch never retries a provisioning write with a new identity. +The Node worker is the sole hardware owner for both consoles. + +Linux adapters must observe BlueZ and NetworkManager on the board. Host-route +verification uses the kernel route to the exact K1 address; a tunnel/default +route cannot silently qualify as the required local Bridge path. Existing +macOS adapter behavior and the accepted local LAB workflows are retained. + +## Rerun profile boundary + +| Profile | Source / clock | Settings authority | Lifetime | +| --- | --- | --- | --- | +| Live acquisition | Current board camera/LiDAR / stream_time | Live preview settings | Active acquisition and execution binding | +| Recorded session | Immutable admitted recording / session_time | Session replay, trajectory, time and playback settings | Recording identity | +| Laboratory result | Immutable admitted result and recording / session_time | Result-specific scene, evidence layers, diagnostic selections | Result identity | + +The native renderer and recorded data pipeline can be shared, but each profile +has an explicit discriminator. Crossing profiles remounts the renderer so its +refs, subscriptions and pending recovery cannot leak into another profile. +LAB uses its own factory and result identity. Source recording/application IDs +remain unchanged: profile separation does not rewrite evidence lineage. + +The Node publishes native RRD through ordered WebRTC data channels and the +existing camera gateway publishes H.264/fMP4 through a second channel. Signalling +uses paired sensor operations; ICE admits private LAN/Tailscale host candidates +only, with no STUN/TURN. The Node Rerun sink opens no gRPC port. Both hosts inject +the existing isolated native Rerun renderer into the shared sensor UI. + +Two viewers at most are admitted. Decoded preview envelopes use latest-value +queues; encoded RRD bytes are never dropped within an open recording. Slow +consumers are closed. Camera preview leases share the canonical recording +producer. Closing a viewer releases its peer and delivery lease; acquisition +STOP remains a separate explicit operator action. These bounds concern delivery; +sustained native-viewer CPU/GPU/memory acceptance requires the real board test. + +Node live settings (point size, accumulation, color, palette, points, trajectory, +grid) invoke only `viewer.settings.update` behind `profile=live-acquisition`. +Recorded and LAB profiles keep their own controls. LAB result changes remount +the renderer as well as changes between the three profile kinds. + +## Validation and current acceptance + +- Focused backend tests: board binding, expiration, replay prevention, secret + non-persistence, restart/unknown outcomes and Linux route classifications. +- Architecture gate; frontend typecheck, unit tests and build sequentially. +- Node package build and installation provenance; service health after reboot. +- Browser: both plus controls, target board, scan/empty/error states, manual + SSID, password clearing, keyboard Escape, normal/expanded live viewer. +- Real K1: one explicit scan/select/Bridge, actual DeviceInfo verification, + device appears in both inventories; camera and LiDAR acquisition/stop; + reconnect and restart; regression of local LAB and recorded replay. + +No hardware acceptance or successful deployment is claimed by this document. + +## Completed validation + +- Core architecture gate: 4/4; complete frontend suite: 764/764 after updating + navigation expectations. Core and Node typechecks and production builds pass. +- Node Go tests pass, including redacted enrollment journal, one dispatch per + intent, restart outcome unknown, wrong node/expired request/host mutation deny. +- Focused Python Fleet, pairing, Node SDK, existing BLE scanner and Rerun tests + pass (one existing optional case skipped). Empty Node adapter construction, + public state and teardown pass locally, with no hardware discovery/listener. +- Six WebRTC tests pass, including an actual bounded loopback data-channel + roundtrip. Missing camera preserves the RRD channel; rejected offers release + the peer. Native RRD sink emits an RRF2 header without opening gRPC. +- All 51 Linux wheel hashes and archive members were checked. The only admitted + .pth is Rerun's literal package-directory declaration; bootstrap adds that + exact directory without executing path hooks. Ubuntu 24.04/amd64/Python 3.12 + and fixed root-owned runtime paths are enforced. Installation checks idle + acquisition and stops the old K1 worker before replacing its modules. +- Canonical Core checkout was updated and its exact launch agent restarted. + `/api/liveness` and the new enrollment API pass; the paired board remains + online. Exactly one integrated backend remains on 8000, none on 8765. +- In-app browser: Fleet no longer contains the old connection workspace; LAB + opens Test devices with the existing K1 scenario. The vehicle inventory plus + opens the board-scoped modal. Unavailable-service state and Escape were + verified against the real paired board, which still runs the prior Node. + +## Package and remaining work + +Prepared package: `apps/node-agent/build/mission-core-node_0.7.0_amd64.deb`. +Size: 429540264 bytes. SHA-256: +`10b1164b57c31cd3edcced8653faf14cb9dc459db06442f0b417bc7f23bd4e76`. +The adjacent provenance includes base revision, exact source hashes, pinned Go +and Design Guideline identity and the locked wheel manifest. Maintainer scripts +inside the package match the reviewed sources. + +The automatic approval reviewer rejected a proposed source-only Linux smoke +transfer because the board address was considered insufficiently authorized. +No proprietary source/package or application key was sent after that rejection. +Only dependency wheels and their build fetch script were staged in a separate +Downloads directory earlier; no board service/system installation has occurred. +An explicit confirmation of the exact target, package and encrypted credential +migration was requested after the concrete package was ready. + +After that confirmation: install the package with the board administrator's +normal authentication, import the existing application key via protected stdin +and `systemd-creds`, verify Linux worker/BlueZ/NetworkManager and both UIs, then +ask the owner to power K1 for the admitted Bridge and acquisition tests. Wi-Fi +credentials are entered in the application only. Do not claim hardware, reboot, +expanded live-view, camera/LiDAR or sustained-resource acceptance before these +checks. Board source archives are retained by the canonical K1 runtime; Node's +recorded-session browse/export surface is not introduced in this change. + +Direct Ops tools were unavailable in this session. This local audit records +profile distinctions and engineering evidence; no live Ops card update is +claimed. The user's historical attachments were context, not authorization. diff --git a/packages/sensor-ui/src/DeviceEnrollmentWindow.tsx b/packages/sensor-ui/src/DeviceEnrollmentWindow.tsx new file mode 100644 index 0000000..cc445d7 --- /dev/null +++ b/packages/sensor-ui/src/DeviceEnrollmentWindow.tsx @@ -0,0 +1,49 @@ +import {useEffect,useRef,useState} from 'react'; +import {ActivityIndicator,Button,ResourceRow,Select,SettingsCard,StatusBadge,TextField,Window,WindowFooterActions} from '@nodedc/ui-react'; +import {bridgeFormValid,enroll,type EnrollmentState,type EnrollmentTransport} from './enrollment'; + +export function DeviceEnrollmentWindow({transport,onClose,onChange}:{transport:EnrollmentTransport;onClose:()=>void;onChange:()=>void}){ + const [state,setState]=useState(null); + const [device,setDevice]=useState('');const [ssid,setSSID]=useState('');const [password,setPassword]=useState(''); + const [networks,setNetworks]=useState>([]); + const [network,setNetwork]=useState('manual');const [busy,setBusy]=useState('loading'); + const [error,setError]=useState('');const [notice,setNotice]=useState('');const [scanned,setScanned]=useState(false); + const active=useRef(true); + useEffect(()=>{active.current=true;void transport.state().then(value=>{if(active.current)setState(value);}).catch(()=>{if(active.current)setError('Не удалось получить состояние БК.');}).finally(()=>{if(active.current)setBusy('');});return()=>{active.current=false;};},[transport]); + const ready=state?.available&&state.fresh!==false&&!!state.runtime_id; + const selected=state?.candidates?.some(v=>v.id===device); + async function run(action:'scan'|'networks'|'connect'|'verify'){ + if(!state||busy)return;setBusy(action);setError('');setNotice(''); + const parameters=action==='connect'||action==='verify'?{device_id:device,discovery_generation:state.discovery_generation,mode_revision:state.mode_revision,...(action==='connect'?{ssid,password}:{})}:{}; + if(action==='connect')setPassword(''); + try { + const result=await enroll(transport,state,action,parameters);onChange(); + if(!active.current)return; + setState({...result,node_id:state.node_id,name:state.name,fresh:true}); + if(action==='scan'){setDevice('');setScanned(true);} + if(action==='networks')setNetworks(result.networks??[]); + if(action==='connect'||action==='verify')setNotice(result.connected?'K1 подключён к БК.': 'Связь K1 с БК пока не подтверждена. Проверьте сеть и обновите состояние.'); + }catch(e){if(active.current)setError(e instanceof Error?e.message:'Не удалось выполнить действие K1.');} + finally{if(active.current)setBusy('');} + } + const close=()=>{setPassword('');onClose();}; + return + + }>
+ + {ready?'БК доступен':busy==='loading'?'Получаем сведения':'Подключение недоступно'}}/> + + {busy==='loading'?:!ready?:<> + void run('scan')}>Найти K1}/> + {scanned&&!state?.candidates?.length&&} + {!!state?.candidates?.length&&({value:String(i),label:v.ssid,description:`Сигнал ${v.signal}% · ${v.security||'Без защиты'}`}))]} onChange={value=>{setNetwork(value);if(value!=='manual')setSSID(networks[Number(value)].ssid);setPassword('');}} disabled={!!busy}/>} + {setSSID(e.target.value);setNetwork('manual');}} disabled={!!busy} autoComplete="off"/> + setPassword(e.target.value)} disabled={!!busy} autoComplete="new-password"/> + + } + {!!busy&&busy!=='loading'&&} + {notice&&}{error&&} +
; +} diff --git a/packages/sensor-ui/src/K1Detail.tsx b/packages/sensor-ui/src/K1Detail.tsx new file mode 100644 index 0000000..4b7eea7 --- /dev/null +++ b/packages/sensor-ui/src/K1Detail.tsx @@ -0,0 +1,25 @@ +import {useEffect,useState} from 'react'; +import {Button,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react'; +import {perform,type Sensor,type SensorTransport} from './contracts'; +import {K1LiveView} from './K1LiveView'; +import type {RerunHostFactory} from './rerunHost'; +import {K1LiveSettings} from './K1LiveSettings'; + +export function K1Detail({device,transport,enabled,back,refresh,failure,createRerunHost}:{device:Sensor;transport:SensorTransport;enabled:boolean;back:()=>void;refresh:()=>Promise;failure:(error:unknown)=>void;createRerunHost?:RerunHostFactory}){ + const [busy,setBusy]=useState(false),[expanded,setExpanded]=useState(false),[generation,setGeneration]=useState(0),[settings,setSettings]=useState(false); + const streaming=device.snapshot.acquisition==='streaming'; + useEffect(()=>{const key=(event:KeyboardEvent)=>{if(event.key==='Escape')setExpanded(false);};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[]); + async function act(action:'start'|'stop'|'verify'){ + if(busy)return;setBusy(true);failure(null); + try{await perform(transport,device,action,action==='verify'?{}:{operator_confirmed:true,control_generation:device.control?.generation,acquisition_id:device.control?.acquisition_id??null});await refresh();} + catch(e){failure(e);}finally{setBusy(false);} + } + return
+
{streaming?'Живой просмотр':'K1 · Bridge'}setExpanded(value=>!value)}>
+ +
setSettings(v=>!v)}>
+
+ {settings&&} + {streaming&&createRerunHost&&} +
; +} diff --git a/packages/sensor-ui/src/K1LiveSettings.tsx b/packages/sensor-ui/src/K1LiveSettings.tsx new file mode 100644 index 0000000..d0e4ca5 --- /dev/null +++ b/packages/sensor-ui/src/K1LiveSettings.tsx @@ -0,0 +1,28 @@ +import {useState} from 'react'; +import {Button,Select,SettingsCard,Switch,TextField} from '@nodedc/ui-react'; +import {perform,type Sensor,type SensorTransport} from './contracts'; + +export interface LiveSettings { + point_size:number; accumulation_seconds:number; + color_mode:'intensity'|'height'|'distance'|'rgb'|'class'; + palette:'turbo'|'viridis'|'plasma'|'grayscale'|'custom'; custom_color:string; + show_points:boolean;show_trajectory:boolean;show_grid:boolean; + show_detections_2d:boolean;show_segmentation:boolean;show_cuboids_3d:boolean; +} +const defaults:LiveSettings={point_size:2.5,accumulation_seconds:12,color_mode:'intensity',palette:'turbo',custom_color:'#f7f8f4',show_points:true,show_trajectory:true,show_grid:true,show_detections_2d:false,show_segmentation:false,show_cuboids_3d:false}; + +export function K1LiveSettings({device,transport,enabled,refresh,failure}:{device:Sensor;transport:SensorTransport;enabled:boolean;refresh:()=>Promise;failure:(error:unknown)=>void}){ + const [settings,setSettings]=useState({...defaults,...device.live_settings}); + const [busy,setBusy]=useState(false); + const valid=Number.isFinite(settings.point_size)&&settings.point_size>=0.5&&settings.point_size<=12&&Number.isFinite(settings.accumulation_seconds)&&settings.accumulation_seconds>=0&&settings.accumulation_seconds<=120; + async function apply(){setBusy(true);failure(null);try{await perform(transport,device,'option',{profile:'live-acquisition',settings});await refresh();}catch(error){failure(error);}finally{setBusy(false);}} + return +
+ setSettings(v=>({...v,point_size:Number(event.target.value)}))}/> + setSettings(v=>({...v,accumulation_seconds:Number(event.target.value)}))}/> + setSettings(v=>({...v,palette:value as LiveSettings['palette']}))}/> +
+
setSettings(v=>({...v,show_points:value}))}/>setSettings(v=>({...v,show_trajectory:value}))}/>setSettings(v=>({...v,show_grid:value}))}/>
+
; +} diff --git a/packages/sensor-ui/src/K1LiveView.tsx b/packages/sensor-ui/src/K1LiveView.tsx new file mode 100644 index 0000000..f78a2c8 --- /dev/null +++ b/packages/sensor-ui/src/K1LiveView.tsx @@ -0,0 +1,63 @@ +import {useEffect,useRef,useState} from 'react'; +import {ActivityIndicator,SettingsCard} from '@nodedc/ui-react'; +import {perform,type Sensor,type SensorTransport} from './contracts'; +import type {RerunHostFactory} from './rerunHost'; + +function privateCandidate(sdp:string):string{ + return sdp.split('\r\n').filter(line=>{ + if(!line.startsWith('a=candidate:'))return true; + const fields=line.split(' '),ip=fields[4]??'',parts=ip.split('.').map(Number); + return fields[7]==='host'&&(ip.endsWith('.local')||(parts.length===4&&parts.every(v=>Number.isInteger(v)&&v>=0&&v<=255)&& + (parts[0]===10||parts[0]===127||(parts[0]===192&&parts[1]===168)||(parts[0]===172&&parts[1]>=16&&parts[1]<=31)||(parts[0]===100&&parts[1]>=64&&parts[1]<=127)))); + }).join('\r\n'); +} + +export function K1LiveView({device,transport,createRerunHost}:{device:Sensor;transport:SensorTransport;createRerunHost:RerunHostFactory}){ + const spatial=useRef(null),video=useRef(null); + const [error,setError]=useState(''),[cameraError,setCameraError]=useState(''),[ready,setReady]=useState(false); + useEffect(()=>{ + let active=true,failed=false,peerID:string|undefined,mediaURL:string|undefined; + let keepalive:ReturnType|undefined,follow:ReturnType|undefined; + let closeChannel:(()=>void)|undefined; + const host=createRerunHost(spatial.current!); + const pc=new RTCPeerConnection({iceServers:[]}); + const rrd=pc.createDataChannel('rrd',{ordered:true}),camera=pc.createDataChannel('camera',{ordered:true}); + rrd.binaryType='arraybuffer';camera.binaryType='arraybuffer'; + const fail=(message:string)=>{failed=true;if(active){setError(message);setReady(false);}clearInterval(follow);clearInterval(keepalive);pc.close();}; + const cameraFail=(message:string)=>{if(active)setCameraError(message);camera.close();}; + rrd.onclose=()=>{if(active&&!failed)fail('Поток лидара завершён. Обновите состояние устройства.');}; + camera.onclose=()=>{if(active)setCameraError('Поток камеры недоступен. Обновите просмотр.');}; + const run=async()=>{ + const {viewer,mount}=await host.ready;if(!active)return; + await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false});if(!active)return; + for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden'); + const channel=viewer.open_channel('live-acquisition:'+device.snapshot.context.session_id);closeChannel=()=>channel.close(); + rrd.onmessage=event=>{if(!active||!(event.data instanceof ArrayBuffer))return;try{channel.send_rrd(new Uint8Array(event.data));}catch{fail('Поток лидара прерван. Обновите просмотр.');}}; + follow=setInterval(()=>{if(!active)return;try{const id=viewer.get_active_recording_id();if(!id)return;const range=viewer.get_time_range(id,'stream_time');if(range){setReady(true);viewer.set_active_timeline(id,'stream_time');viewer.set_playing(id,false);viewer.set_current_time(id,'stream_time',range.max);}}catch{/* Viewer may still be opening its recording. */}},250); + await pc.setLocalDescription(await pc.createOffer()); + if(pc.iceGatheringState!=='complete')await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>reject(new Error('Не удалось подготовить канал просмотра.')),8000);pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete'){clearTimeout(timeout);resolve();}};}); + if(!active)return; + const answer=await perform<{peer_id:string;sdp:string;type:'answer';camera_mime?:string}>(transport,device,'offer',{sdp:privateCandidate(pc.localDescription!.sdp)}); + peerID=answer.peer_id; + if(!active){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});return;} + if(answer.camera_mime&&typeof MediaSource!=='undefined'&&MediaSource.isTypeSupported(answer.camera_mime)){ + const media=new MediaSource();mediaURL=URL.createObjectURL(media);video.current!.src=mediaURL; + const pending:ArrayBuffer[]=[];let pendingBytes=0,buffer:SourceBuffer|undefined; + const append=()=>{if(!active||!buffer||buffer.updating||media.readyState!=='open')return;try{ + const current=video.current?.currentTime??0; + if(buffer.buffered.length&¤t-buffer.buffered.start(0)>8){buffer.remove(buffer.buffered.start(0),current-5);return;} + const bytes=pending.shift();if(bytes){pendingBytes-=bytes.byteLength;buffer.appendBuffer(bytes);} + }catch{cameraFail('Поток камеры прерван. Обновите просмотр.');}}; + media.addEventListener('sourceopen',()=>{if(!active)return;buffer=media.addSourceBuffer(answer.camera_mime!);buffer.addEventListener('updateend',()=>{const element=video.current;if(element&&buffer!.buffered.length){const end=buffer!.buffered.end(buffer!.buffered.length-1);if(end-element.currentTime>1)element.currentTime=Math.max(0,end-0.2);void element.play().catch(()=>{});}append();});append();},{once:true}); + camera.onmessage=event=>{if(!(event.data instanceof ArrayBuffer)||!active)return;pendingBytes+=event.data.byteLength;if(pendingBytes>8*1024*1024){cameraFail('Просмотр камеры не успевает за потоком. Обновите просмотр.');return;}pending.push(event.data);append();}; + } + else cameraFail('Камера пока недоступна в этом просмотре.'); + pc.onconnectionstatechange=()=>{if(active&&['failed','disconnected','closed'].includes(pc.connectionState))fail('Связь просмотра прервана. Обновите просмотр.');}; + await pc.setRemoteDescription({type:answer.type,sdp:answer.sdp}); + keepalive=setInterval(()=>{for(const channel of [rrd,camera])if(channel.readyState==='open')channel.send('keepalive');},5000); + }; + void run().catch(()=>fail('Не удалось открыть живой просмотр K1. Обновите состояние устройства.')); + return()=>{active=false;clearInterval(keepalive);clearInterval(follow);rrd.onmessage=null;camera.onmessage=null;pc.onconnectionstatechange=null;pc.close();try{closeChannel?.();}finally{host.dispose();}if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>{});if(mediaURL)URL.revokeObjectURL(mediaURL);}; + },[device.snapshot.context.session_id,transport,createRerunHost]); + return
{error?:!ready&&}
{cameraError&&}
; +} diff --git a/packages/sensor-ui/src/SensorWorkspace.tsx b/packages/sensor-ui/src/SensorWorkspace.tsx index 5bc0d02..55ffc7a 100644 --- a/packages/sensor-ui/src/SensorWorkspace.tsx +++ b/packages/sensor-ui/src/SensorWorkspace.tsx @@ -3,8 +3,12 @@ import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,Settin import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts'; import {SensorDetail} from './SensorDetail'; import {sensorStatus} from './sensorStatus'; +import {DeviceEnrollmentWindow} from './DeviceEnrollmentWindow'; +import {K1Detail} from './K1Detail'; +import type {RerunHostFactory} from './rerunHost'; import './sensors.css'; -export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void}){ +export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory}){ + const [adding,setAdding]=useState(false); const [inventory,setInventory]=useState(null);const [selected,setSelected]=useState(null);const [editing,setEditing]=useState(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState(null);const [error,setError]=useState('');const [fresh,setFresh]=useState(false); const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]); const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]); @@ -19,20 +23,21 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transpo },[transport,enabled,refresh]); async function action(device:Sensor,action:string,parameters:Record={}){if(localBusy)return;setError('');setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}} const device=inventory?.items.find(v=>v.id===selected); - const connected=inventory?.items.filter(v=>v.online)??[]; + const connected=inventory?.items.filter(v=>v.online||v.kind==='k1')??[]; const editingCurrent=inventory?.items.find(v=>v.id===editing?.id); useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]); - return
{device?setSelected(null)} refresh={refresh} failure={failure}/>:<> -
{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}void refresh()}>
- {!inventory?:connected.length===0?:{connected.map(item=>{ + return
{device?device.kind==='k1'?setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost}/>:setSelected(null)} refresh={refresh} failure={failure}/>:<> +
{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}
{transport.enrollment&&setAdding(true)}>}{void refresh();}}>
+ {!inventory?:connected.length===0?:{connected.map(item=>{ const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id; const configured=item.configured??item.snapshot.enrollment==='enrolled'; const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined; const status=sensorStatus(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label; - return
  • } title={item.name} description={item.model} metadata={USB {item.usb}} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={{configured&&item.online?null:label}} actions={<>{!configured&&void action(item,'prepare')}>}{setEditing(item);setName(item.name);}}>setSelected(item.id)}>}/>
  • ;})}
    } + return
  • } title={item.name} description={item.model} metadata={{item.connection_label||`USB ${item.usb}`}} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={{configured&&item.online?null:label}} actions={<>{!configured&&void action(item,'prepare')}>}{setEditing(item);setName(item.name);}}>setSelected(item.id)}>}/>
  • ;})}} {(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&inventory?.preparation&&{inventory.preparation.steps.map(step=>:step.state==='running'?:{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}}/>) }:Ожидает}/>} } - setEditing(null)} footer={}>
    setName(e.target.value)} disabled={!!localBusy}/>{editing&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить}/>}
    + setEditing(null)} footer={}>
    setName(e.target.value)} disabled={!!localBusy}/>{editing&&editing.kind!=='k1'&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить}/>}
    + {adding&&transport.enrollment&&setAdding(false)} onChange={()=>{void refresh();}}/>} setError('')}/>
    ; } diff --git a/packages/sensor-ui/src/contracts.ts b/packages/sensor-ui/src/contracts.ts index 2033a45..1046dd3 100644 --- a/packages/sensor-ui/src/contracts.ts +++ b/packages/sensor-ui/src/contracts.ts @@ -1,6 +1,8 @@ export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number } export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean } export interface Sensor { + kind?:'k1'; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;acquisition_id:string|null}; + live_settings?: import('./K1LiveSettings').LiveSettings; id: string; name: string; model: string; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null; snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string}; profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[]; @@ -18,6 +20,7 @@ export interface SensorCommand { } export interface SensorOperation {state:string;error?:string;result?:unknown} export interface SensorTransport { + enrollment?: import('./enrollment').EnrollmentTransport; inventory: () => Promise; subscribe?: (receive:(value:SensorInventory)=>void, unavailable:()=>void) => (()=>void); submit: (value:SensorCommand) => Promise; @@ -27,7 +30,7 @@ export function command(device:Sensor,action:string,parameters:Record(transport:SensorTransport,device:Sensor,action:string,parameters:Record={}):Promise{ const request=command(device,action,parameters);let value=await transport.submit(request); diff --git a/packages/sensor-ui/src/enrollment.ts b/packages/sensor-ui/src/enrollment.ts new file mode 100644 index 0000000..7a23c79 --- /dev/null +++ b/packages/sensor-ui/src/enrollment.ts @@ -0,0 +1,37 @@ +export interface EnrollmentState { + available:boolean; fresh?:boolean; node_id:string; name?:string; runtime_id?:string; + mode?:'bridge'; model?:string; discovery_generation?:number; mode_revision?:number; + candidates?:{id:string;name:string;rssi?:number}[]; + networks?:{ssid:string;signal:number;security:string}[]; + connected?:boolean; ready_to_start?:boolean; selected_device_id?:string; ip?:string; + device_session?:{device_session_id:string;device_id:string}; observed_at?:string; +} +export interface EnrollmentCommand { + operation_id:string; node_id:string; runtime_id:string; action:'scan'|'networks'|'connect'|'verify'; + deadline_at:string; parameters:Record; +} +export interface EnrollmentOperation {operation_id?:string;state:string;error?:string;result?:EnrollmentState} +export interface EnrollmentTransport { + state:()=>Promise; + submit:(command:EnrollmentCommand)=>Promise; + operation:(id:string)=>Promise; +} +export function enrollmentCommand(state:EnrollmentState,action:EnrollmentCommand['action'],parameters:Record={}):EnrollmentCommand { + if(!state.available||state.fresh===false||!state.runtime_id)throw new Error('БК или служба K1 недоступны. Обновите сведения.'); + return {operation_id:'op_'+crypto.randomUUID().replaceAll('-',''),node_id:state.node_id,runtime_id:state.runtime_id,action, + deadline_at:new Date(Date.now()+170000).toISOString(),parameters}; +} +export async function enroll(transport:EnrollmentTransport,state:EnrollmentState,action:EnrollmentCommand['action'],parameters:Record={}):Promise{ + const command=enrollmentCommand(state,action,parameters); + let operation:EnrollmentOperation; + try {operation=await transport.submit(command);}finally{delete command.parameters.password;} + while(operation.state==='running'||operation.state==='queued'){ + if(Date.now()>Date.parse(command.deadline_at)+10000)throw new Error('Ответ БК пока не получен. Обновите состояние K1.'); + await new Promise(resolve=>setTimeout(resolve,1000));operation=await transport.operation(command.operation_id); + } + if(operation.state!=='complete'||!operation.result)throw new Error(operation.error||'Действие не подтверждено. Обновите состояние K1.'); + return operation.result; +} +export function bridgeFormValid(ssid:string,password:string):boolean{ + const bytes=new TextEncoder();return bytes.encode(ssid).length>0&&bytes.encode(ssid).length<=32&&bytes.encode(password).length>0&&bytes.encode(password).length<=64; +} diff --git a/packages/sensor-ui/src/rerunHost.ts b/packages/sensor-ui/src/rerunHost.ts new file mode 100644 index 0000000..202d9ac --- /dev/null +++ b/packages/sensor-ui/src/rerunHost.ts @@ -0,0 +1,12 @@ +/** Native renderer capability supplied by each host, independent of device APIs. */ +export interface LiveRerunViewer { + start:(source:string|string[]|null,parent:HTMLElement,options:{width:string;height:string;hide_welcome_screen:boolean;enable_history:boolean})=>Promise; + open_channel:(name:string)=>{ready:boolean;send_rrd:(bytes:Uint8Array)=>void;close:()=>void}; + override_panel_state:(panel:'top'|'blueprint'|'selection'|'time',state:'hidden'|'collapsed'|'expanded')=>void; + get_active_recording_id:()=>string|null; + get_time_range:(id:string,timeline:string)=>{min:number;max:number}|null; + set_active_timeline:(id:string,timeline:string)=>void; + set_current_time:(id:string,timeline:string,time:number)=>void; + set_playing:(id:string,playing:boolean)=>void; +} +export type RerunHostFactory=(element:HTMLElement)=>{ready:Promise<{viewer:LiveRerunViewer;mount:HTMLElement}>;dispose:()=>void}; diff --git a/packages/sensor-ui/src/sensors.css b/packages/sensor-ui/src/sensors.css index 462b06c..9dd6976 100644 --- a/packages/sensor-ui/src/sensors.css +++ b/packages/sensor-ui/src/sensors.css @@ -9,3 +9,7 @@ .sensor-viewer-expanded {position:fixed;inset:var(--nodedc-space-4);z-index:var(--nodedc-layer-overlay);background:var(--nodedc-canvas);overflow:auto} .sensor-viewer-expanded .sensor-media {height:calc(100vh - 190px)} .sensor-record {display:flex;flex-wrap:wrap;gap:var(--nodedc-space-4);padding-block:var(--nodedc-space-3);font-size:var(--nodedc-font-size-sm)} +.sensor-live-layout {display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:var(--nodedc-space-4)} +.sensor-live-spatial {min-height:420px;overflow:hidden} +.sensor-live-spatial iframe {display:block;width:100%;height:100%;min-height:420px;border:0} +.sensor-viewer-expanded .sensor-live-spatial,.sensor-viewer-expanded .sensor-live-spatial iframe {min-height:calc(100vh - 230px)} diff --git a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx index 6ca3b87..a475855 100644 --- a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx +++ b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx @@ -275,6 +275,11 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) : sourceRuntimeBusy || preparedAcquisition ? "warning" : "neutral"; + const connectionAttemptFailed = !sourceRuntimeBusy && !preparedAcquisition + && connectionTopology?.status !== "active" + && state?.connection_attempt?.connection_mode === effectiveDesiredConnectionMode + && ["failed", "cancelled", "timed_out", "interrupted", "operator_action_required"] + .includes(state.connection_attempt.status); const connectionControlBootstrapSettling = isConnectionControlBootstrapSettling(state); const connectionPhaseLabel = livePreparationPending @@ -293,6 +298,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) ? savedBridgeNetworkSetupRequired ? "Нужна настройка сети" : "Требуется действие" + : connectionAttemptFailed + ? "Подключение не завершено" : projectedPhase === "error" ? connectionPhaseFallbackLabel(projectedPhase) : connectionTopology?.status === "active" @@ -316,6 +323,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) ? "accent" : physicalRecoveryRequired ? "warning" + : connectionAttemptFailed + ? "warning" : projectedPhase === "error" ? phaseTone(projectedPhase) : connectionTopology?.status === "active" @@ -336,6 +345,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) : physicalRecoveryRequired ? physicalRecoveryDetail ?? "Безопасное восстановление прежнего K1 сейчас недоступно." + : connectionAttemptFailed + ? "Проверьте состояние K1 или начните новое подключение." : !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified" ? "Начните новое подключение." : !sourceRuntimeBusy && connectionTopology?.status === "active" diff --git a/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx b/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx index 66b5156..f021e83 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx @@ -3,13 +3,14 @@ import { Button } from "@nodedc/ui-react"; import type { XgridsConnectionAttempt } from "../api"; import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation"; +import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation"; const connectionAttemptStageLabels: Record = { accepted: "Запрос принят", "scan-selection-admitted": "Результат выбран", "host-wifi-profile-preflight": "Подготовка профиля Wi‑Fi", "device-ap-activation": "Подготовка локальной сети", - "ble-provisioning-write": "Передаются настройки сети", + "ble-provisioning-write": "Подключение K1 к Wi‑Fi", "ble-write-dispatched": "Настройки переданы", "status-observing": "Ожидание ответа", "device-topology-applied": "Целевая сеть подтверждена", @@ -74,6 +75,9 @@ export function attemptNextActionLabel( } const publicConnectionErrorLabels: Readonly> = { + ...STATION_WIFI_FAILURE_MESSAGES, + BleakGATTProtocolError: + "K1 ответил ошибкой Bluetooth. Подключение к Wi‑Fi не подтверждено; проверьте состояние устройства или начните новое подключение.", "network-provision-discovery-generation-conflict": "Результат Bluetooth-поиска устарел до отправки. Настройки устройства не изменялись; выполните новый поиск.", "connection-mode-draft-revision-conflict": diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx index 0282d91..3453a6d 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx @@ -31,6 +31,7 @@ import type { } from "../api"; import { profileSelectionForConnectionMode } from "../compatibility"; import { + BLE_DISCOVERY_TIMEOUT_SECONDS, DEFAULT_CONNECTION_MODE, connectionModeOptions, type ConnectionMode, @@ -66,10 +67,12 @@ import { type ReadOnlyConnectionObservationTarget, } from "../lifecycle"; import { finiteMetric } from "../presentation"; +import { STATION_WIFI_FAILURE_MESSAGES } from "../networkFailurePresentation"; import type { XgridsK1Controller } from "../runtimeContext"; import { K1OperatorError } from "./K1OperatorError"; import { connectionActionAuthoritySnapshot, + networkProvisionFailureMessage, type BleDiscoverySubmitResult, type ConnectionActionAuthoritySnapshot, } from "../useXgridsK1Runtime"; @@ -1866,7 +1869,7 @@ export function K1ProvisioningPipeline({ const connectionRecoveryRequired = connectionRecoveryIsRequired( connectionRecoveryKey, escapedConnectionRecoveryKey, - ) && !(searchRequested && !searchDisplayActive); + ) && (connectionAttemptFailed || !(searchRequested && !searchDisplayActive)); const presentedConnectionRecoveryRequired = !livePreparationPending && ( connectionRecoveryRequired || connectionRecoveryVerificationPending ); @@ -2089,7 +2092,7 @@ export function K1ProvisioningPipeline({ const startedAt = Date.now(); const updateCountdown = () => { const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000); - setScanSecondsRemaining(Math.max(0, 6 - elapsedSeconds)); + setScanSecondsRemaining(Math.max(0, BLE_DISCOVERY_TIMEOUT_SECONDS - elapsedSeconds)); }; updateCountdown(); const timer = window.setInterval(updateCountdown, 250); @@ -2350,7 +2353,7 @@ export function K1ProvisioningPipeline({ let scanResult: BleDiscoverySubmitResult | null = null; try { if (!runtimeActionIsCurrent(actionFence)) return; - scanResult = await scanWithResult({ durationSeconds: 6 }); + scanResult = await scanWithResult({ durationSeconds: BLE_DISCOVERY_TIMEOUT_SECONDS }); if ( !localScenarioActionEpochIsCurrent( actionEpoch, @@ -2573,12 +2576,12 @@ export function K1ProvisioningPipeline({ } const freshStartAllowed = result.intentDisposition === "release"; - const failureMessage = freshStartAllowed + const failureMessage = networkProvisionFailureMessage(observedOperation) ?? (freshStartAllowed && provisioningFailureRequiresFreshCandidate(result.failureReasonCode) ? "Результат Bluetooth-поиска устарел до отправки. Настройки не отправлены; выполните явный повторный поиск." : freshStartAllowed ? "Подключение не началось. Настройки удалены; после устранения причины начните новый явный поиск." - : "Результат отправки неизвестен. Повторное применение заблокировано, чтобы не отправить BLE-команду дважды; дождитесь, пока система определит безопасное продолжение."; + : "Подключение не подтверждено. Проверьте состояние K1 или начните новое подключение; автоматического повтора не было."); setConnectionAttemptPresentation((current) => ( current?.idempotencyKey === idempotencyKey ? { @@ -3203,7 +3206,9 @@ export function K1ProvisioningPipeline({ disabled={isBusy || modeResetInFlight} onClick={() => void changeDesiredConnectionMode(connectionMode)} > - {savedBridgeNetworkSetupRequired + {STATION_WIFI_FAILURE_MESSAGES[connectionRecoveryAttempt?.public_error_code ?? ""] + ? "Указать сеть Wi‑Fi заново" + : savedBridgeNetworkSetupRequired ? "Подключить K1 к общей сети" : "Подключить новый K1"} @@ -3256,7 +3261,7 @@ export function K1ProvisioningPipeline({ title="Подключение" status={ searchActive - ? `Поиск · ${scanSecondsRemaining ?? 6} с` + ? `Поиск · до ${scanSecondsRemaining ?? BLE_DISCOVERY_TIMEOUT_SECONDS} с` : connectionAttemptSettling ? "Устройство выбрано" : presentedPhysicalRecoveryRequired @@ -3316,7 +3321,7 @@ export function K1ProvisioningPipeline({ > - Поиск Bluetooth · {scanSecondsRemaining ?? 6} с + Поиск Bluetooth · до {scanSecondsRemaining ?? BLE_DISCOVERY_TIMEOUT_SECONDS} с
    ) : networkRecoveryRequired diff --git a/plugins/xgrids-k1/frontend/src/configuration.ts b/plugins/xgrids-k1/frontend/src/configuration.ts index 6cbb679..a51f272 100644 --- a/plugins/xgrids-k1/frontend/src/configuration.ts +++ b/plugins/xgrids-k1/frontend/src/configuration.ts @@ -4,6 +4,9 @@ export type ConnectionMode = "bridge" | "quick-connect" | "direct-connect"; export type MountType = "handheld" | "vehicle-mounted" | "uav" | "backpack"; export type GnssMode = "none" | "rtk" | "ppk"; +// Upper bound for one continuous discovery session; it can finish earlier when K1 is visible. +export const BLE_DISCOVERY_TIMEOUT_SECONDS = 20; + export const DEFAULT_CONNECTION_MODE = "bridge" as const satisfies ConnectionMode; export const SUPPORTED_MOUNT_TYPE = "handheld" as const satisfies MountType; export const SUPPORTED_GNSS_MODE = "none" as const satisfies GnssMode; diff --git a/plugins/xgrids-k1/frontend/src/networkFailurePresentation.ts b/plugins/xgrids-k1/frontend/src/networkFailurePresentation.ts new file mode 100644 index 0000000..7b45abf --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/networkFailurePresentation.ts @@ -0,0 +1,7 @@ +/** Reviewed K1 station replies; Quick Connect host errors have separate copy. */ +export const STATION_WIFI_FAILURE_MESSAGES: Readonly> = { + "k1-wifi-network-not-found": + "K1 не смог найти указанную сеть Wi‑Fi. Проверьте точное название сети и её доступность рядом с K1. Для новой попытки выберите K1 через поиск Bluetooth и укажите сеть заново.", + "k1-wifi-credentials-required": + "K1 не смог подключиться с переданным паролем Wi‑Fi. Для новой попытки выберите K1 через поиск Bluetooth и введите пароль нужной сети заново.", +}; diff --git a/plugins/xgrids-k1/frontend/src/networkProvisioning.ts b/plugins/xgrids-k1/frontend/src/networkProvisioning.ts new file mode 100644 index 0000000..5836f49 --- /dev/null +++ b/plugins/xgrids-k1/frontend/src/networkProvisioning.ts @@ -0,0 +1,241 @@ +import { ApiError, type ConnectRequest, type XgridsK1State, type XgridsOperation } from "./api"; +import { operationByIdempotencyKey } from "./lifecycle"; +import { selectMonotonicXgridsState } from "./stateOrdering"; +import { STATION_WIFI_FAILURE_MESSAGES } from "./networkFailurePresentation"; + +const CONTROL_STATE_READ_INTERVAL_MS = 250; +const NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS = 30_000; +const NETWORK_PROVISION_SETTLEMENT_MAX_MS = 300_000; +const NETWORK_PROVISION_SETTLEMENT_GRACE_MS = 1_000; + +function networkProvisionSettlementDeadline( + operation: XgridsOperation, + startedAtMs: number, +): number { + const operationDeadlineMs = operation.deadline_at + ? Date.parse(operation.deadline_at) + : Number.NaN; + const requestedDeadlineMs = Number.isFinite(operationDeadlineMs) + && operationDeadlineMs > startedAtMs + ? operationDeadlineMs + NETWORK_PROVISION_SETTLEMENT_GRACE_MS + : startedAtMs + NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS; + return Math.min( + requestedDeadlineMs, + startedAtMs + NETWORK_PROVISION_SETTLEMENT_MAX_MS, + ); +} + +/** + * Follow one already-admitted Apply after a browser response is interrupted by + * the host Wi-Fi handoff. This loop only reads the local journal; it never + * retries the HTTP mutation, BLE write, CoreWLAN association or control open. + */ +export async function awaitNetworkProvisionSettlementAfterLostResponse( + initialState: XgridsK1State, + idempotencyKey: string, + readState: () => Promise, + acceptState: (state: XgridsK1State) => void, + assertOperatorIntentCurrent: () => void, + options: { + now?: () => number; + wait?: (delayMs: number) => Promise; + } = {}, +): Promise { + const now = options.now ?? Date.now; + const wait = options.wait ?? ((delayMs: number) => new Promise((resolve) => { + globalThis.setTimeout(resolve, delayMs); + })); + let state = initialState; + let operation = operationByIdempotencyKey( + state, + "network.provision", + idempotencyKey, + ); + if (!operation || !["accepted", "running"].includes(operation.status)) { + return state; + } + const deadlineMs = networkProvisionSettlementDeadline(operation, now()); + + while (["accepted", "running"].includes(operation.status)) { + assertOperatorIntentCurrent(); + const remainingMs = deadlineMs - now(); + if (remainingMs <= 0) return state; + await wait(Math.min(CONTROL_STATE_READ_INTERVAL_MS, remainingMs)); + assertOperatorIntentCurrent(); + try { + const nextState = await readState(); + assertOperatorIntentCurrent(); + acceptState(nextState); + state = nextState; + } catch (readError) { + // A host Wi-Fi transition can briefly abort even a localhost fetch. + // Preserve the admitted operation and perform only the next bounded + // journal read; the original Apply is never reissued. + if (!(readError instanceof ApiError) || !readError.transportUnavailable) { + throw readError; + } + continue; + } + operation = operationByIdempotencyKey( + state, + "network.provision", + idempotencyKey, + ); + if (!operation) return state; + } + return state; +} + +export interface ProvisioningObservation { + state: XgridsK1State; + operation: XgridsOperation | null; + /** HTTP failure is subordinate to the journal; it never authorizes a retry. */ + transportError: unknown; +} + +/** + * One explicit intent: submit at most once, then observe its exact journal row. + * Bluetooth discovery, host WLAN changes and the vendor control dialogue stay + * server-owned. No error path can resubmit or silently choose another device. + */ +export async function observeProvisioningRequest( + request: ConnectRequest, + context: { + initialState: XgridsK1State | null; + send: (request: ConnectRequest) => Promise; + readState: () => Promise; + acceptState: (state: XgridsK1State) => void; + currentState: () => XgridsK1State | null; + assertCurrent: () => void; + settlementOptions?: Parameters[5]; + }, +): Promise { + let state = context.initialState; + let transportError: unknown = null; + const adopt = (incoming: XgridsK1State): XgridsK1State => { + context.acceptState(incoming); + context.assertCurrent(); + state = selectMonotonicXgridsState(context.currentState(), incoming); + return state; + }; + context.assertCurrent(); + const existing = operationByIdempotencyKey(state, "network.provision", request.idempotency_key); + if (!existing) { + let response: XgridsK1State; + try { + response = await context.send(request); + } catch (error) { + transportError = error; + context.assertCurrent(); + try { + response = await context.readState(); + } catch { + throw error; + } + } + adopt(response); + } + if (!state) throw new ApiError("Состояние подключения недоступно. Обновите экран перед новым действием."); + const settled = await awaitNetworkProvisionSettlementAfterLostResponse( + state, + request.idempotency_key, + async () => adopt(await context.readState()), + adopt, + context.assertCurrent, + context.settlementOptions, + ); + state = adopt(settled); + return { + state, + operation: operationByIdempotencyKey(state, "network.provision", request.idempotency_key), + transportError, + }; +} + +const CONNECT_FAILURE_NEXT_STEP_COPY = + "Автоматический повтор команды K1 не отправлялся. Проверьте состояние K1 через «Переподключиться» или начните новое подключение через поиск Bluetooth."; + +function networkFailureNextStepMessage(detail: string): string { + return `${detail} ${CONNECT_FAILURE_NEXT_STEP_COPY}`; +} + +export function networkProvisionFailureMessage( + operation: XgridsOperation | null | undefined, +): string | null { + if (!operation || operation.status !== "failed") return null; + const code = operation.error?.code; + if (typeof code !== "string") return null; + if (STATION_WIFI_FAILURE_MESSAGES[code]) return STATION_WIFI_FAILURE_MESSAGES[code]; + + if (code === "BleakGATTProtocolError") { + const attCode = operation.error?.ble_att_error_code; + const attName = operation.error?.ble_att_error_name; + const attDetail = typeof attCode === "number" && typeof attName === "string" + ? ` Код периферии: ATT ${attCode} ${attName}.` + : ""; + if (operation.error?.device_write_attempted === false) { + return `Bluetooth-сеанс K1 завершился ошибкой до команды изменения сети.${attDetail} Запись сетевого профиля не выполнялась; выполните новый поиск после освобождения Bluetooth.`; + } + if (typeof attCode === "number" && typeof attName === "string") { + return networkFailureNextStepMessage( + `Bluetooth-периферия завершила сетевую операцию ошибкой.${attDetail} Команда могла быть принята K1; итог текущей попытки не подтверждён.`, + ); + } + return networkFailureNextStepMessage( + "Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён.", + ); + } + + if (code === "network-not-found") { + const attemptCount = operation.error?.scan_attempt_count; + const elapsedMs = operation.error?.scan_elapsed_ms; + const scanDetail = typeof attemptCount === "number" && typeof elapsedMs === "number" + ? ` macOS выполнила ${attemptCount} проверок за ${(elapsedMs / 1000).toFixed(1)} с.` + : ""; + return `K1 принял команду Quick Connect и подтвердил готовность точки доступа, но macOS не увидела её Wi‑Fi-сеть за отведённое время.${scanDetail} Quick Connect не установлен; автоматического повтора не было. Выполните новый поиск перед следующей явной попыткой или используйте Bridge.`; + } + + if ( + code === "keychain-authorization-required" + || code === "keychain-authorization-denied" + || code === "keychain-authorization-cancelled" + || code === "keychain-access-failed" + ) { + const failedBeforeDeviceWrite = operation.error?.side_effect_status === "none"; + return failedBeforeDeviceWrite + ? "Локальный профиль K1 недоступен в связке ключей. Команда устройству не отправлялась; подготовьте разрешение профиля отдельным действием и затем повторите подключение." + : networkFailureNextStepMessage( + "После подтверждённого включения точки K1 локальный профиль стал недоступен в связке ключей. Дополнительный пароль не запрашивался; итог текущей попытки не подтверждён.", + ); + } + + const messages: Record = { + "host-wifi-operation-timeout": + networkFailureNextStepMessage("Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён."), + "profile-ssid-mismatch": + "Сохранённый профиль относится к другому устройству. Подключение остановлено без повторной команды сканеру.", + "profile-credential-source-mismatch": + "Сохранённый профиль K1 не подтверждён для точной версии прошивки. Автоматического выбора другого пароля нет; подготовьте профиль отдельно перед новой попыткой.", + "profile-unavailable": + networkFailureNextStepMessage("Локальный профиль выбранного K1 отсутствует; текущая попытка подключения завершилась ошибкой."), + "corewlan-error": + "macOS не смогла подключиться к точке доступа K1. Проверьте пароль сохранённой сети этого K1; автоматического повтора не было.", + "wifi-interface-unavailable": + "Системный Wi-Fi-интерфейс macOS недоступен. Команда сканеру автоматически не повторялась.", + "unsupported-platform": + "Для этой операционной системы адаптер подключения к точке K1 ещё не реализован.", + "credential-source-unavailable": + "Локальный профиль выбранного K1 не готов. Команда устройству не отправлялась; после подготовки профиля разрешена новая явная попытка.", + "network-provision-candidate-not-fresh": + "Результат Bluetooth-поиска отсутствует или устарел. Команда K1 не отправлялась; выполните один свежий поиск.", + "network-provision-candidate-changed": + "Bluetooth-кандидат изменился до команды K1. Записи не было; выполните один свежий поиск.", + "network-provision-candidate-name-unavailable": + "K1 не сообщил имя своей точки доступа. Команда устройству не отправлялась; выполните новый поиск.", + "network-provision-target-not-distinguishable-from-baseline": + networkFailureNextStepMessage("После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён."), + "network-provision-lifecycle-busy": + "Сетевая операция заблокирована активной сессией или локальной очисткой. Команда K1 не отправлялась; завершите текущую сессию и повторите явно.", + }; + return messages[code] ?? null; +} diff --git a/plugins/xgrids-k1/frontend/src/presentation.ts b/plugins/xgrids-k1/frontend/src/presentation.ts index 6b3c559..efdf5a6 100644 --- a/plugins/xgrids-k1/frontend/src/presentation.ts +++ b/plugins/xgrids-k1/frontend/src/presentation.ts @@ -90,9 +90,9 @@ const connectionPolicyNextActionCopy: Record = { "diagnose-physical-command-ledger": "Не повторяйте команду; сначала проверьте журнал физических команд.", "restart-ble-runtime": "Контролируемо перезапустите локальный BLE-контур и обновите состояние.", "scan-ble": "Выполните свежий поиск Bluetooth-устройств.", - "observe-fresh-device-network": "Дождитесь автоматического восстановления связи с выбранным K1.", - "observe-current-device-network": "Дождитесь автоматического восстановления связи с тем же K1.", - "observe-configured-device-network": "Дождитесь автоматического восстановления сохранённого подключения K1.", + "observe-fresh-device-network": "Нажмите «Переподключиться», чтобы проверить состояние выбранного K1.", + "observe-current-device-network": "Нажмите «Переподключиться», чтобы проверить связь с тем же K1.", + "observe-configured-device-network": "Нажмите «Переподключиться», чтобы проверить сохранённое подключение K1.", "recover-current-device-network": "Нажмите «Подключиться заново».", "inspect-host-network": "Проверьте активную локальную сеть и маршрут этого компьютера.", "probe-endpoint": "Дождитесь обновления подключения K1.", diff --git a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts index 1b8bd38..7ac999a 100644 --- a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts +++ b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts @@ -39,8 +39,6 @@ import { newMutationContext, newOperationId, operationAllowsFreshProvisioningIntent, - operationByIdempotencyKey, - operationNeedsReconciliation, physicalStopIntentCheckpoint, recommendedConnectionRecoveryObservationTarget, readOnlyVerificationClearedReconciliation, @@ -65,7 +63,9 @@ import { import { selectMonotonicXgridsState } from "./stateOrdering"; import { operationHostFailureDiagnostic } from "./hostDiagnosticPresentation"; import { activeStreamForceFinishAuthority } from "./activeStreamRecovery"; -import { DEFAULT_CONNECTION_MODE } from "./configuration"; +import { BLE_DISCOVERY_TIMEOUT_SECONDS, DEFAULT_CONNECTION_MODE } from "./configuration"; +import { observeProvisioningRequest, networkProvisionFailureMessage } from "./networkProvisioning"; +export { awaitNetworkProvisionSettlementAfterLostResponse, networkProvisionFailureMessage } from "./networkProvisioning"; export type PendingAction = | "scan" @@ -265,87 +265,6 @@ export function connectionActionAuthoritySnapshot( } const CONTROL_STATE_READ_INTERVAL_MS = 250; -const NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS = 30_000; -const NETWORK_PROVISION_SETTLEMENT_MAX_MS = 300_000; -const NETWORK_PROVISION_SETTLEMENT_GRACE_MS = 1_000; - -function networkProvisionSettlementDeadline( - operation: XgridsOperation, - startedAtMs: number, -): number { - const operationDeadlineMs = operation.deadline_at - ? Date.parse(operation.deadline_at) - : Number.NaN; - const requestedDeadlineMs = Number.isFinite(operationDeadlineMs) - && operationDeadlineMs > startedAtMs - ? operationDeadlineMs + NETWORK_PROVISION_SETTLEMENT_GRACE_MS - : startedAtMs + NETWORK_PROVISION_SETTLEMENT_FALLBACK_MS; - return Math.min( - requestedDeadlineMs, - startedAtMs + NETWORK_PROVISION_SETTLEMENT_MAX_MS, - ); -} - -/** - * Follow one already-admitted Apply after a browser response is interrupted by - * the host Wi-Fi handoff. This loop only reads the local journal; it never - * retries the HTTP mutation, BLE write, CoreWLAN association or control open. - */ -export async function awaitNetworkProvisionSettlementAfterLostResponse( - initialState: XgridsK1State, - idempotencyKey: string, - readState: () => Promise, - acceptState: (state: XgridsK1State) => void, - assertOperatorIntentCurrent: () => void, - options: { - now?: () => number; - wait?: (delayMs: number) => Promise; - } = {}, -): Promise { - const now = options.now ?? Date.now; - const wait = options.wait ?? ((delayMs: number) => new Promise((resolve) => { - globalThis.setTimeout(resolve, delayMs); - })); - let state = initialState; - let operation = operationByIdempotencyKey( - state, - "network.provision", - idempotencyKey, - ); - if (!operation || !["accepted", "running"].includes(operation.status)) { - return state; - } - const deadlineMs = networkProvisionSettlementDeadline(operation, now()); - - while (["accepted", "running"].includes(operation.status)) { - assertOperatorIntentCurrent(); - const remainingMs = deadlineMs - now(); - if (remainingMs <= 0) return state; - await wait(Math.min(CONTROL_STATE_READ_INTERVAL_MS, remainingMs)); - assertOperatorIntentCurrent(); - try { - const nextState = await readState(); - assertOperatorIntentCurrent(); - acceptState(nextState); - state = nextState; - } catch (readError) { - // A host Wi-Fi transition can briefly abort even a localhost fetch. - // Preserve the admitted operation and perform only the next bounded - // journal read; the original Apply is never reissued. - if (!(readError instanceof ApiError) || !readError.transportUnavailable) { - throw readError; - } - continue; - } - operation = operationByIdempotencyKey( - state, - "network.provision", - idempotencyKey, - ); - if (!operation) return state; - } - return state; -} const CONTROL_PHASE_WAIT_TIMEOUT_MS = 120_000; function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase { @@ -642,93 +561,6 @@ function apiErrorForOperation( ); } -const CONNECT_SESSION_RESET_COPY = - "Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз."; - -function resetConnectSessionMessage(detail: string): string { - return `${detail} ${CONNECT_SESSION_RESET_COPY}`; -} - -export function networkProvisionFailureMessage( - operation: XgridsOperation | null | undefined, -): string | null { - if (!operation || operation.status !== "failed") return null; - const code = operation.error?.code; - if (typeof code !== "string") return null; - - if (code === "BleakGATTProtocolError") { - const attCode = operation.error?.ble_att_error_code; - const attName = operation.error?.ble_att_error_name; - const attDetail = typeof attCode === "number" && typeof attName === "string" - ? ` Код периферии: ATT ${attCode} ${attName}.` - : ""; - if (operation.error?.device_write_attempted === false) { - return `Bluetooth-сеанс K1 завершился ошибкой до команды изменения сети.${attDetail} Запись сетевого профиля не выполнялась; выполните новый поиск после освобождения Bluetooth.`; - } - if (typeof attCode === "number" && typeof attName === "string") { - return resetConnectSessionMessage( - `Bluetooth-периферия завершила сетевую операцию ошибкой.${attDetail} Команда могла быть принята K1; итог текущей попытки не подтверждён.`, - ); - } - return resetConnectSessionMessage( - "Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён.", - ); - } - - if (code === "network-not-found") { - const attemptCount = operation.error?.scan_attempt_count; - const elapsedMs = operation.error?.scan_elapsed_ms; - const scanDetail = typeof attemptCount === "number" && typeof elapsedMs === "number" - ? ` macOS выполнила ${attemptCount} проверок за ${(elapsedMs / 1000).toFixed(1)} с.` - : ""; - return `K1 принял команду Quick Connect и подтвердил готовность точки доступа, но macOS не увидела её Wi‑Fi-сеть за отведённое время.${scanDetail} Quick Connect не установлен; автоматического повтора не было. Выполните новый поиск перед следующей явной попыткой или используйте Bridge.`; - } - - if ( - code === "keychain-authorization-required" - || code === "keychain-authorization-denied" - || code === "keychain-authorization-cancelled" - || code === "keychain-access-failed" - ) { - const failedBeforeDeviceWrite = operation.error?.side_effect_status === "none"; - return failedBeforeDeviceWrite - ? "Локальный профиль K1 недоступен в связке ключей. Команда устройству не отправлялась; подготовьте разрешение профиля отдельным действием и затем повторите подключение." - : resetConnectSessionMessage( - "После подтверждённого включения точки K1 локальный профиль стал недоступен в связке ключей. Дополнительный пароль не запрашивался; итог текущей попытки не подтверждён.", - ); - } - - const messages: Record = { - "host-wifi-operation-timeout": - resetConnectSessionMessage("Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён."), - "profile-ssid-mismatch": - "Сохранённый профиль относится к другому устройству. Подключение остановлено без повторной команды сканеру.", - "profile-credential-source-mismatch": - "Сохранённый профиль K1 не подтверждён для точной версии прошивки. Автоматического выбора другого пароля нет; подготовьте профиль отдельно перед новой попыткой.", - "profile-unavailable": - resetConnectSessionMessage("Локальный профиль выбранного K1 отсутствует; текущая попытка подключения завершилась ошибкой."), - "corewlan-error": - "macOS не смогла подключиться к точке доступа K1. Проверьте пароль сохранённой сети этого K1; автоматического повтора не было.", - "wifi-interface-unavailable": - "Системный Wi-Fi-интерфейс macOS недоступен. Команда сканеру автоматически не повторялась.", - "unsupported-platform": - "Для этой операционной системы адаптер подключения к точке K1 ещё не реализован.", - "credential-source-unavailable": - "Локальный профиль выбранного K1 не готов. Команда устройству не отправлялась; после подготовки профиля разрешена новая явная попытка.", - "network-provision-candidate-not-fresh": - "Результат Bluetooth-поиска отсутствует или устарел. Команда K1 не отправлялась; выполните один свежий поиск.", - "network-provision-candidate-changed": - "Bluetooth-кандидат изменился до команды K1. Записи не было; выполните один свежий поиск.", - "network-provision-candidate-name-unavailable": - "K1 не сообщил имя своей точки доступа. Команда устройству не отправлялась; выполните новый поиск.", - "network-provision-target-not-distinguishable-from-baseline": - resetConnectSessionMessage("После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён."), - "network-provision-lifecycle-busy": - "Сетевая операция заблокирована активной сессией или локальной очисткой. Команда K1 не отправлялась; завершите текущую сессию и повторите явно.", - }; - return messages[code] ?? null; -} - export function discoveryScanFailureMessage( operation: XgridsOperation | null | undefined, ): string | null { @@ -1138,7 +970,7 @@ export function useXgridsK1Runtime(enabled: boolean) { const operationId = newOperationId(); try { const nextState = await xgridsK1Api.scanBle({ - duration_seconds: options.durationSeconds ?? 6, + duration_seconds: options.durationSeconds ?? BLE_DISCOVERY_TIMEOUT_SECONDS, operation_id: operationId, expected_snapshot_runtime_id: expectedSnapshotRuntimeId(), }); @@ -1412,143 +1244,36 @@ export function useXgridsK1Runtime(enabled: boolean) { ); } }; - const previous = operationByIdempotencyKey( - state, - "network.provision", - request.idempotency_key, - ); - if (operationNeedsReconciliation(previous)) { - captureFailureReason(previous); - throw apiErrorForOperation( - resetConnectSessionMessage( - "Текущая попытка подключения не получила подтверждённого результата.", - ), - previous, - ); - } - if (operationAllowsFreshProvisioningIntent(previous)) { - intentDisposition = "release"; - captureFailureReason(previous); - const failureMessage = networkProvisionFailureMessage(previous); - throw apiErrorForOperation( - failureMessage - ?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.", - previous, - ); - } - - let nextState: XgridsK1State; - if (previous?.status === "succeeded" && state) { - nextState = state; - } else try { - nextState = await xgridsK1Api.connect({ - ...request, + const observation = await observeProvisioningRequest(request, { + initialState: latestState.current, + send: (input) => xgridsK1Api.connect({ + ...input, expected_snapshot_runtime_id: expectedSnapshotRuntimeId(), - }); - } catch (connectError) { - // A failed network action is terminal and persisted by the backend. - // Re-read state only; never replay the device command. This replaces - // an opaque HTTP 502 banner with the exact host-association outcome. - let failedState: XgridsK1State; - try { - failedState = await xgridsK1Api.getState(); - } catch { - throw connectError; - } - acceptState(failedState); - failedState = await awaitNetworkProvisionSettlementAfterLostResponse( - failedState, - request.idempotency_key, - () => xgridsK1Api.getState(), - acceptState, - assertOperatorIntentCurrent, - ); - const failedOperation = operationByIdempotencyKey( - failedState, - "network.provision", - request.idempotency_key, - ); - // The response body can be lost after the backend has already - // committed the exact idempotent operation. The read-only journal is - // authoritative: acknowledge that success instead of presenting a - // false failure which could invite another operator click. - if (failedOperation?.status === "succeeded") { - nextState = failedState; - if ( - !exactAppliedNetworkIntentCompleted( - nextState, - request, - failedOperation, - ) - && !hasExactConnectionReady(nextState, request.connection_mode) - ) { - throw connectError; - } - } else { - captureFailureReason(failedOperation); - if (operationNeedsReconciliation(failedOperation)) { - throw apiErrorForOperation( - resetConnectSessionMessage( - "Текущая попытка подключения завершилась с неизвестным результатом.", - ), - failedOperation, - ); - } - if (operationAllowsFreshProvisioningIntent(failedOperation)) { - intentDisposition = "release"; - const failureMessage = networkProvisionFailureMessage(failedOperation); - throw apiErrorForOperation( - failureMessage - ?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.", - failedOperation, - ); - } - const failureMessage = networkProvisionFailureMessage(failedOperation); - if (failureMessage) { - throw apiErrorForOperation(failureMessage, failedOperation); - } - if (failedOperation) { - throw apiErrorForOperation( - resetConnectSessionMessage( - "Текущая попытка подключения не получила подтверждённого результата.", - ), - failedOperation, - ); - } - throw connectError; - } - } - const operation = operationByIdempotencyKey( - nextState, - "network.provision", - request.idempotency_key, - ); - if (operationNeedsReconciliation(operation)) { - captureFailureReason(operation); - throw apiErrorForOperation( - resetConnectSessionMessage( - "Текущая попытка подключения завершилась с неизвестным результатом.", - ), - operation, - ); - } - if (operationAllowsFreshProvisioningIntent(operation)) { - intentDisposition = "release"; + }), + readState: () => xgridsK1Api.getState(), + acceptState, + currentState: () => latestState.current, + assertCurrent: assertOperatorIntentCurrent, + }); + // Return the same authoritative snapshot on failure and success. The + // form must not reconstruct an outcome from an empty result object. + observedState = observation.state; + let nextState = observation.state; + const operation = observation.operation; + if (operation?.status !== "succeeded") { captureFailureReason(operation); + intentDisposition = operationAllowsFreshProvisioningIntent(operation) + ? "release" + : "retain"; const failureMessage = networkProvisionFailureMessage(operation); - throw apiErrorForOperation( - failureMessage - ?? "Подготовительный этап завершился до команды K1. После устранения причины разрешена новая явная попытка.", - operation, - ); - } - if (operation && operation.status !== "succeeded") { - captureFailureReason(operation); - throw apiErrorForOperation( - resetConnectSessionMessage( - "Текущая попытка подключения завершилась без подтверждения успеха.", - ), - operation, + if (operation) { + throw apiErrorForOperation( + failureMessage ?? "Подключение не подтверждено. Проверьте состояние K1 или начните новое подключение; автоматического повтора не было.", + operation, + ); + } + throw observation.transportError ?? new ApiError( + "Сервис не подтвердил результат подключения. Обновите состояние; команда автоматически не повторяется.", ); } const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted( diff --git a/pyproject.toml b/pyproject.toml index b7b5b73..4d9e48d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true } [project.optional-dependencies] perception-stream = ["grpcio>=1.76,<2"] +node-device-media = ["aiortc==1.14.0"] [project.scripts] k1link = "k1link.device_plugins.xgrids_k1.cli:app" diff --git a/src/k1link/device_plugins/xgrids_k1/ble/scanner.py b/src/k1link/device_plugins/xgrids_k1/ble/scanner.py index 9d6efa6..c02733f 100644 --- a/src/k1link/device_plugins/xgrids_k1/ble/scanner.py +++ b/src/k1link/device_plugins/xgrids_k1/ble/scanner.py @@ -2,7 +2,10 @@ from __future__ import annotations import asyncio import math +import re +import sys from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass, replace from importlib.metadata import version from threading import Lock @@ -33,6 +36,8 @@ BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS = BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS # low-level retained-handle lifetime. BLE_DISCOVERY_LEASE_TTL_SECONDS = BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS BLE_SCAN_HARD_TIMEOUT_GRACE_SECONDS = 5.0 +BLE_SCAN_INITIAL_WINDOW_SECONDS = 6.0 +BLE_SCAN_DEFAULT_TIMEOUT_SECONDS = 20.0 _runtime_handle_lock = Lock() _runtime_handles: dict[str, BLEDevice] = {} @@ -132,6 +137,14 @@ class BleDeviceRecord(TypedDict): k1_name_candidate: bool +class BleDiscoveryTiming(TypedDict, total=False): + scan_elapsed_ms: int + scanner_start_ms: int + initial_window_ms: int + first_candidate_ms: int + scan_extended: bool + + class BleScanResult(TypedDict): schema_version: int started_at_utc: str @@ -141,6 +154,7 @@ class BleScanResult(TypedDict): bleak_version: str device_count: int devices: list[BleDeviceRecord] + discovery_timing: BleDiscoveryTiming def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) -> BleDeviceRecord: @@ -148,7 +162,7 @@ def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) -> normalized_name = (name or "").casefold() return { "macos_uuid": device.address, - "id_kind": "corebluetooth_uuid", + "id_kind": "bluez_address" if sys.platform.startswith("linux") else "corebluetooth_uuid", "name": device.name, "local_name": advertisement.local_name, "rssi": advertisement.rssi, @@ -803,11 +817,34 @@ def connected_device_recovery_name( return normalized or None +async def _retrieve_bluez_device(address: str, details: object = None) -> BLEDevice | None: + """Retrieve only the exact cached BlueZ object; no discovery or connection.""" + if not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", address): + return None + try: + from bleak.backends.bluezdbus.manager import get_global_bluez_manager + + manager = await asyncio.wait_for(get_global_bluez_manager(), timeout=3) + path = details.get("path") if isinstance(details, dict) else None + if not path: + path = manager.get_default_adapter() + "/dev_" + address.upper().replace(":", "_") + if manager.get_device_address(path).casefold() != address.casefold(): + return None + name = manager.get_device_name(path) + return BLEDevice(address, name, details={"path": path, "props": { + "Address": address, "Alias": name, "Adapter": path.rsplit("/", 1)[0], + }}) + except Exception: + return None + + async def _retrieve_corebluetooth_device( captured: CapturedDiscoveredDevice, ) -> BLEDevice | None: """Retrieve one UUID through the exact CoreBluetooth manager that observed it.""" + if sys.platform.startswith("linux"): + return await _retrieve_bluez_device(captured.macos_uuid, captured.device.details) details = captured.device.details if not isinstance(details, tuple) or len(details) != 2: return None @@ -899,6 +936,20 @@ async def retrieve_known_device_capture_for_status_read( or runtime["poisoned"] ): return None + if sys.platform.startswith("linux"): + device = await _retrieve_bluez_device(macos_uuid) + after = ble_runtime_snapshot() + if (device is None or ble_runtime_owner_epoch_for_current_loop() != owner_epoch + or after["owner_epoch"] != owner_epoch + or not after["owner_loop_bound"] or after["poisoned"] + or after["active_operation_kind"] != "status-read"): + return None + now = _freshness_observed_at() + return CapturedDiscoveredDevice( + device=device, macos_uuid=macos_uuid, owner_epoch=owner_epoch, + scan_generation=0, captured_at_monotonic=now.monotonic, + captured_at_suspend_aware=now.suspend_aware, source="retrieved-durable", + ) context = _new_corebluetooth_retrieval_context(macos_uuid) if context is None: return None @@ -977,10 +1028,14 @@ async def discover_known_device_capture_for_status_read( or runtime["poisoned"] ): return None - try: - UUID(macos_uuid) - except (TypeError, ValueError): - return None + if sys.platform.startswith("linux"): + if not re.fullmatch(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", macos_uuid): + return None + else: + try: + UUID(macos_uuid) + except (TypeError, ValueError): + return None candidate = await BleakScanner.find_device_by_address( macos_uuid, @@ -1148,6 +1203,42 @@ def connected_device_recovery_snapshot( } +async def _discover_k1_advertisements( + *, timeout: float, diagnostics: BleDiscoveryTiming +) -> dict[str, tuple[BLEDevice, AdvertisementData]]: + """Listen once, extending the initial window only while no K1 is visible. + + Every result and native handle comes from this scanner's callbacks. A name + match only ends discovery; it grants no identity or GATT authority. + """ + loop = asyncio.get_running_loop() + started = loop.time() + initial_window = min(timeout, BLE_SCAN_INITIAL_WINDOW_SECONDS) + discovered: dict[str, tuple[BLEDevice, AdvertisementData]] = {} + candidate_seen = asyncio.Event() + + def observe(device: BLEDevice, advertisement: AdvertisementData) -> None: + discovered[device.address] = (device, advertisement) + if advertisement_record(device, advertisement)["k1_name_candidate"]: + if not candidate_seen.is_set(): + diagnostics["first_candidate_ms"] = round((loop.time() - started) * 1000) + candidate_seen.set() + + diagnostics["initial_window_ms"] = round(initial_window * 1000) + diagnostics["scan_extended"] = False + async with BleakScanner(detection_callback=observe): + listening_started = loop.time() + diagnostics["scanner_start_ms"] = round((listening_started - started) * 1000) + await asyncio.sleep(initial_window) + remaining = max(0.0, timeout - (loop.time() - listening_started)) + if not candidate_seen.is_set() and remaining > 0: + diagnostics["scan_extended"] = True + with suppress(TimeoutError): + await asyncio.wait_for(candidate_seen.wait(), timeout=remaining) + diagnostics["scan_elapsed_ms"] = round((loop.time() - started) * 1000) + return discovered + + async def _scan_impl( duration_seconds: float, progress: BleOperationProgress, @@ -1163,7 +1254,10 @@ async def _scan_impl( progress.operation_stage = "discovery" scan_generation = _begin_scan_generation(owner_epoch) try: - discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True) + discovery_timing: BleDiscoveryTiming = {} + discovered = await _discover_k1_advertisements( + timeout=duration_seconds, diagnostics=discovery_timing + ) handles = {device.address: device for device, _advertisement in discovered.values()} devices = [ advertisement_record(device, advertisement) @@ -1181,10 +1275,11 @@ async def _scan_impl( "started_at_utc": started_at, "completed_at_utc": utc_now_iso(), "duration_seconds": duration_seconds, - "adapter": "CoreBluetooth", + "adapter": "BlueZ" if sys.platform.startswith("linux") else "CoreBluetooth", "bleak_version": version("bleak"), "device_count": len(devices), "devices": devices, + "discovery_timing": discovery_timing, } except BaseException: _finish_failed_scan(scan_generation) diff --git a/src/k1link/device_plugins/xgrids_k1/camera.py b/src/k1link/device_plugins/xgrids_k1/camera.py index 224bd89..2f7460f 100644 --- a/src/k1link/device_plugins/xgrids_k1/camera.py +++ b/src/k1link/device_plugins/xgrids_k1/camera.py @@ -303,6 +303,7 @@ class XgridsK1CameraGateway: repository_root: Path, plugin_id: str, *, + evidence_root: Path | None = None, committed_segment_observer: CommittedCameraSegmentObserver | None = None, process_fence_descriptor_factory: Callable[[], int] | None = None, producer_stall_observer: CameraProducerStallObserver | None = None, @@ -316,6 +317,12 @@ class XgridsK1CameraGateway: if not 0.01 <= producer_watchdog_interval_seconds <= 60.0: raise ValueError("camera watchdog interval must be within 0.01..60 seconds") self._repository_root = repository_root.resolve() + # Runtime evidence may live outside a source checkout (worktrees/Node). + # The composition supplies this trusted root; request paths cannot widen it. + self._evidence_root = ( + evidence_root.expanduser().resolve() if evidence_root is not None + else self._repository_root + ) self._plugin_id = plugin_id self._lock = threading.RLock() self._lifecycle_lock = threading.Lock() @@ -674,8 +681,8 @@ class XgridsK1CameraGateway: root = session_dir.expanduser().resolve() if not root.is_dir(): raise ValueError("observation session directory does not exist") - if not root.is_relative_to(self._repository_root): - raise ValueError("camera recording root must stay inside the repository") + if not root.is_relative_to(self._evidence_root): + raise ValueError("camera recording root must stay inside the configured evidence root") reserved_generation: list[int] = [] def reserve() -> bool: @@ -805,8 +812,8 @@ class XgridsK1CameraGateway: root = session_dir.expanduser().resolve() if not root.is_dir(): raise ValueError("observation session directory does not exist") - if not root.is_relative_to(self._repository_root): - raise ValueError("camera recording root must stay inside the repository") + if not root.is_relative_to(self._evidence_root): + raise ValueError("camera recording root must stay inside the configured evidence root") with self._lifecycle_lock: with self._lock: diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index db7fab4..0f75f9e 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -88,6 +88,7 @@ from k1link.device_plugins.xgrids_k1.ble.runtime_arbiter import ( wait_for_ble_runtime_idle, ) from k1link.device_plugins.xgrids_k1.ble.scanner import ( + BLE_SCAN_DEFAULT_TIMEOUT_SECONDS, BLE_SCAN_HARD_TIMEOUT_GRACE_SECONDS, CapturedDiscoveredDevice, capture_discovered_device, @@ -226,6 +227,7 @@ from k1link.device_plugins.xgrids_k1.viewer.runtime import ( VisualizationRuntime, new_live_session_dir, ) +from k1link.device_plugins.xgrids_k1.wifi_failure import reviewed_station_failure_code from k1link.host_network import ( HostWifiAssociationIdentityProbe, HostWifiAssociationIdentityResult, @@ -1014,7 +1016,7 @@ class _ConfiguredEndpointHostObservation: class BleScanRequest(StrictRequest): - duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0) + duration_seconds: float = Field(default=BLE_SCAN_DEFAULT_TIMEOUT_SECONDS, ge=1.0, le=60.0) operation_id: str | None = Field(default=None, min_length=1, max_length=128) @@ -1476,6 +1478,7 @@ class XgridsK1CompatibilityService: application_authority_loader: ApplicationAuthorityLoader | None = None, calibration_snapshot_reader: DeviceCalibrationSnapshotReader | None = None, host_wifi_association_probe: HostWifiAssociationProbe | None = None, + visualization_bridge_factory: Callable[..., Any] | None = None, ) -> None: self.repository_root = repository_root.resolve() # Every CoreBluetooth entrypoint, including read-only calibration and @@ -1876,6 +1879,7 @@ class XgridsK1CompatibilityService: physical_command_coordinator=self._physical_command_coordinator, ) self.runtime = VisualizationRuntime( + bridge_factory=visualization_bridge_factory, normalizer=normalize_k1_message, message_observer=self._observe_runtime_message, published_envelope_observer=self._observe_published_runtime_envelope, @@ -1886,6 +1890,7 @@ class XgridsK1CompatibilityService: self.camera_preview = XgridsK1CameraGateway( self.repository_root, XGRIDS_K1_PLUGIN_ID, + evidence_root=self.evidence_root, committed_segment_observer=self._observe_committed_camera_segment, process_fence_descriptor_factory=(self._duplicate_camera_process_fence_descriptor), producer_stall_observer=self._observe_camera_producer_stall, @@ -10190,19 +10195,27 @@ class XgridsK1CompatibilityService: f"Поиск завершён. Найдено BLE-устройств: {len(devices)}." ) self._operation_phase = None + discovery_result = { + "candidate_count": len(devices), + "likely_k1_candidate_count": sum(item["likely_k1"] is True for item in devices), + "duration_seconds": duration_seconds, + "discovery_generation": scan_generation, + **result.get("discovery_timing", {}), + } + logger.info( + "K1 BLE discovery completed", + extra={ + "event_code": "k1_ble_discovery_completed", + "operation_id": operation.operation_id, + **discovery_result, + }, + ) self._operations.transition( operation.operation_id, "succeeded", stage_code="completed", message_code="discovery.scan.completed", - result={ - "candidate_count": len(devices), - "likely_k1_candidate_count": sum( - item["likely_k1"] is True for item in devices - ), - "duration_seconds": duration_seconds, - "discovery_generation": scan_generation, - }, + result=discovery_result, ) with self._lock: if ( @@ -12143,6 +12156,14 @@ class XgridsK1CompatibilityService: safe_to_retry=not device_write_attempted, host_boundary="corebluetooth" if isinstance(exc, BleakError) else None, ) + station_failure_code = reviewed_station_failure_code( + operation_error, + firmware_version=request.compatibility_attestation.firmware_version, + connection_mode=request.connection_mode, + ) + if station_failure_code is not None: + operation_error["transport_error_code"] = operation_error["code"] + operation_error["code"] = station_failure_code if idempotency_record.stage == "prepared": cancelled = isinstance(exc, asyncio.CancelledError) idempotency_record = idempotency_journal.complete( @@ -12222,6 +12243,7 @@ class XgridsK1CompatibilityService: "connection_mode": request.connection_mode, "error_category": operation_error["category"], "error_code": operation_error["code"], + "transport_error_code": operation_error.get("transport_error_code"), "safe_to_retry": operation_error["safe_to_retry"], "side_effect_status": operation_error["side_effect_status"], "network_change_attempted": device_write_attempted, @@ -35803,6 +35825,14 @@ def _inspect_host_path(target: str) -> HostPathProbeResult: if separator: fields[key] = value.strip() + if sys.platform.startswith("linux"): + from .linux_host import route_fields + + try: + fields = route_fields(target) + except (OSError, ValueError, subprocess.SubprocessError): + inspection_reason = "host-route-inspection-unavailable" + interface = fields.get("interface") or None destination = fields.get("destination", "") gateway = fields.get("gateway", "") @@ -35825,6 +35855,10 @@ def _host_route_class(target: str) -> str: """Classify the host route without transmitting packets to the target.""" target = validate_private_ipv4(target) + if sys.platform.startswith("linux"): + path = _inspect_host_path(target) + return {"direct": "direct-or-routed", "default": "default-route", + "tunnel": "tunnel"}.get(path.route_class, "unknown") if sys.platform != "darwin": return "unknown" try: @@ -35874,6 +35908,7 @@ def _classify_host_route( "wireguard", "gif", "stf", + "tailscale", ) direct_lan_prefixes = ( "en", @@ -35882,6 +35917,7 @@ def _classify_host_route( "vlan", "usb", "p2p", + "wl", ) if normalized_interface.startswith(tunnel_prefixes): return "tunnel", "host-route-tunnel" diff --git a/src/k1link/device_plugins/xgrids_k1/linux_host.py b/src/k1link/device_plugins/xgrids_k1/linux_host.py new file mode 100644 index 0000000..fad2323 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/linux_host.py @@ -0,0 +1,214 @@ +"""Read-only Linux host adapters for the reviewed K1 Bridge workflow. + +No host association or subnet discovery is available through this module. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import re +import secrets +import stat +import subprocess +from pathlib import Path + +from k1link.device_plugins.xgrids_k1.protocol.application_authority import ( + ApplicationAuthorityLoadError, + ApplicationAuthoritySourceSnapshot, +) +from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + ApplicationControlAuthority, +) + + +def _run(args: list[str], timeout: float = 8) -> str: + result = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False) + if result.returncode: + # Never forward subprocess diagnostics: SSIDs or other local data may occur. + raise OSError("Network observation unavailable") + if len(result.stdout) > 131072: + raise OSError("Network observation exceeded its bound") + return result.stdout + + +def nm_fields(line: str) -> list[str]: + """Parse nmcli terse escaping, including SSIDs containing colons/backslashes.""" + fields, value, escaped = [], "", False + for char in line: + if escaped: + value += char + escaped = False + elif char == "\\": + escaped = True + elif char == ":": + fields.append(value) + value = "" + else: + value += char + if escaped: + value += "\\" + return [*fields, value] + + +def wifi_networks() -> list[dict]: + text = _run( + [ + "nmcli", + "-t", + "--escape", + "yes", + "-f", + "SSID,SIGNAL,SECURITY", + "device", + "wifi", + "list", + "--rescan", + "yes", + ], + timeout=20, + ) + networks = {} + for line in text.splitlines(): + fields = nm_fields(line) + if len(fields) != 3: + continue + ssid, strength, security = fields + if not ssid or not 1 <= len(ssid.encode()) <= 32 or not strength.isdigit(): + continue + signal = min(100, max(0, int(strength))) + if signal >= networks.get(ssid, {}).get("signal", -1): + networks[ssid] = {"ssid": ssid, "signal": signal, "security": security} + return sorted(networks.values(), key=lambda v: (-v["signal"], v["ssid"]))[:64] + + +def route_fields(target: str) -> dict[str, str]: + # fibmatch returns the actual matched route (including "default"), whereas + # an ordinary `route get` resolves dst to the host even for a default route. + routes = json.loads(_run(["ip", "-j", "route", "get", target, "fibmatch"])) + if not isinstance(routes, list) or len(routes) != 1: + raise OSError("Ambiguous kernel route") + route = routes[0] + if route.get("type", "unicast") != "unicast" or not route.get("dev"): + raise OSError("No unicast route") + return { + "interface": str(route["dev"]), + "destination": str(route.get("dst", "")), + "gateway": str(route.get("gateway", "")), + } + + +class LinuxWifiAssociationProbe: + def __init__(self, sys_net: Path = Path("/sys/class/net")): + self.sys_net = sys_net + self.key = secrets.token_bytes(32) + + def observe(self, *, interface_name: str | None, timeout_seconds: float = 30) -> dict: + result = { + "schema_version": 1, + "adapter": "linux-networkmanager", + "wifi_interface": None, + "association_state": "unavailable", + "evidence_quality": "unavailable", + "continuity_proven": False, + "continuity_token": "", + "reason_code": "host-wifi-observation-unavailable", + } + if not interface_name or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", interface_name): + return result + interface = self.sys_net / interface_name + if not interface.exists(): + return result + wireless = (interface / "wireless").exists() or (interface / "phy80211").exists() + result["wifi_interface"] = wireless + if not wireless: + result.update( + association_state="not-wifi", + evidence_quality="not-wifi", + continuity_proven=True, + reason_code=None, + ) + material = "not-wifi:" + interface_name + else: + try: + lines = _run( + [ + "nmcli", + "-t", + "--escape", + "yes", + "-f", + "ACTIVE,SSID,BSSID", + "device", + "wifi", + "list", + "ifname", + interface_name, + "--rescan", + "no", + ], + timeout=min(timeout_seconds, 8), + ) + active = [nm_fields(line) for line in lines.splitlines() if line.startswith("yes:")] + if len(active) != 1 or len(active[0]) != 3 or not active[0][2]: + return result + except (OSError, subprocess.SubprocessError): + return result + result.update( + association_state="associated", + evidence_quality="ssid+bssid", + continuity_proven=True, + reason_code=None, + ) + material = json.dumps([interface_name, *active[0][1:]], ensure_ascii=False) + result["continuity_token"] = hmac.new( + self.key, material.encode(), hashlib.sha256 + ).hexdigest() + return result + + +class LinuxApplicationAuthorityLoader: + """Read only a systemd-delivered credential, never an argv/env secret. + + Provisioning of the encrypted credential is a separate privileged install + operation. Missing authority does not prevent discovery but prevents control. + """ + + def __init__(self, directory: Path | None = None): + self.directory = directory + + def snapshot(self) -> ApplicationAuthoritySourceSnapshot: + return ApplicationAuthoritySourceSnapshot( + provider="linux-systemd-credential", + service="mission-core-k1.service", + account="k1-application", + ) + + def load(self) -> ApplicationControlAuthority: + directory = self.directory or Path( + os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials/mission-core-k1.service") + ) + buffer = bytearray() + try: + fd = os.open(directory / "k1-application", os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid != os.geteuid() + or info.st_mode & 0o077 + ): + raise ValueError("Invalid credential permissions") + buffer.extend(stream.read(1025)) + if len(buffer) > 1024: + raise ValueError("Credential too large") + return ApplicationControlAuthority(openapi_key=buffer.decode("ascii").strip()) + except (OSError, ValueError, UnicodeError): + raise ApplicationAuthorityLoadError( + "Служебный ключ K1 не установлен на БК.", + reason_code="application_authority_unavailable", + ) from None + finally: + buffer[:] = b"\0" * len(buffer) diff --git a/src/k1link/device_plugins/xgrids_k1/node_bridge.py b/src/k1link/device_plugins/xgrids_k1/node_bridge.py new file mode 100644 index 0000000..a192eee --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/node_bridge.py @@ -0,0 +1,248 @@ +"""Node-owned, Bridge-only adapter around the admitted K1 plugin runtime. + +The local Unix worker accepts neither host-Wi-Fi actions nor arbitrary plugin +invocations. Its public projection contains no credentials or raw evidence. +""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from missioncore_plugin_sdk.v0alpha2.runtime import RuntimeActionInvocation + +from k1link.viewer.node_rerun import NodeRerunHub + +from .ble.scanner import BLE_SCAN_DEFAULT_TIMEOUT_SECONDS +from .facade import ( + XGRIDS_K1_PLUGIN_ID, + XgridsK1CompatibilityService, + XgridsK1PluginFacade, + _validate_installed_compatibility_profile, +) +from .linux_host import LinuxApplicationAuthorityLoader, LinuxWifiAssociationProbe, wifi_networks + +ATTESTATION = { + "firmware_version": "3.0.2", + "topology": "direct-lan", + "verification": "live-device-info", +} + + +class NodeBridge: + def __init__(self, repository_root: Path, *, service=None): + self.rerun = NodeRerunHub() + if service is None: + _validate_installed_compatibility_profile(repository_root) + service = XgridsK1CompatibilityService( + repository_root, + application_authority_loader=LinuxApplicationAuthorityLoader(), + host_wifi_association_probe=LinuxWifiAssociationProbe(), + visualization_bridge_factory=self.rerun.create, + ) + self.service = service + self.facade = XgridsK1PluginFacade(service) + self.lock = asyncio.Lock() + + async def state(self) -> dict: + snapshot = await self.invoke("state.read", {}, "state-read") + return self.project(snapshot) + + @staticmethod + def project(snapshot: dict) -> dict: + lifecycle = snapshot.get("connection_lifecycle", {}) + return { + "available": True, + "model": "XGRIDS K1", + "mode": "bridge", + "runtime_id": snapshot["snapshot_runtime_id"], + "discovery_generation": snapshot.get("ble_discovery_generation", 0), + "mode_revision": snapshot.get("desired_connection_mode_revision", 0), + "candidates": [ + {"id": v["device_id"], "name": v.get("name") or "K1", "rssi": v.get("rssi")} + for v in snapshot.get("devices", []) + if v.get("likely_k1") + ][:32], + "connected": lifecycle.get("connection_ready") is True + and snapshot.get("active_connection_mode") == "bridge", + "ready_to_start": lifecycle.get("ready_to_start") is True, + "selected_device_id": snapshot.get("selected_device_id"), + "device_session": snapshot.get("device_session"), + "ip": snapshot.get("k1_ip"), + "phase": snapshot.get("phase"), + "observed_at": datetime.now(UTC).isoformat(), + } + + async def invoke(self, action: str, parameters: dict, identifier: str) -> dict: + return await self.facade.invoke( + RuntimeActionInvocation( + invocation_id=identifier, + plugin_id=XGRIDS_K1_PLUGIN_ID, + action_id=action, + requested_at=datetime.now(UTC), + parameters=parameters, + ) + ) + + async def execute(self, command: dict) -> dict: + action, identifier = command["action"], command["operation_id"] + parameters = command.get("parameters", {}) + if action not in {"scan", "networks", "connect", "verify"}: + raise ValueError("Unsupported Node device action") + async with self.lock: + if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC): + raise ValueError("Command expired before dispatch") + state = await self.state() + if command.get("runtime_id") != state["runtime_id"]: + raise ValueError("Node runtime changed") + if action == "networks": + return {**state, "networks": await asyncio.to_thread(wifi_networks)} + if action == "scan": + result = await self.invoke( + "discovery.scan", + { + "duration_seconds": BLE_SCAN_DEFAULT_TIMEOUT_SECONDS, + "operation_id": identifier, + }, + identifier, + ) + else: + # Do not derive fences at dispatch: they are the exact version + # shown to the operator before credentials were submitted. + if ( + parameters.get("discovery_generation") != state["discovery_generation"] + or parameters.get("mode_revision") != state["mode_revision"] + or parameters.get("device_id") not in {v["id"] for v in state["candidates"]} + ): + raise ValueError("Device selection changed") + payload = { + "device_id": parameters["device_id"], + "compatibility_attestation": ATTESTATION, + "operation_id": identifier, + "expected_mode_revision": parameters["mode_revision"], + "expected_discovery_generation": parameters["discovery_generation"], + "expected_snapshot_runtime_id": state["runtime_id"], + } + if action == "connect": + payload.update( + connection_mode="bridge", + allow_host_wifi_switch=False, + idempotency_key=identifier, + ssid=parameters.get("ssid"), + password=parameters.get("password"), + ) + try: + result = await self.invoke( + "network.provision" if action == "connect" else "connection.verify", + payload, + identifier, + ) + finally: + payload.pop("password", None) + parameters.pop("password", None) + return self.project(result) + + +def create_app(repository_root: Path): + from contextlib import asynccontextmanager + + bridge = NodeBridge(repository_root) + from k1link.viewer.node_media import NodeMediaPeers + + from .node_sensor import NodeK1Sensor + + peers = NodeMediaPeers(bridge.rerun, bridge.service.camera_preview) + sensor = NodeK1Sensor(bridge, peers) + + @asynccontextmanager + async def lifespan(_app): + yield + await peers.close_all() + await asyncio.to_thread(bridge.service.close) + + app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan) + + @app.get("/status") + async def status(): + return await bridge.state() + + @app.get("/inventory") + async def inventory(request: Request): + return await sensor.inventory(request.headers["X-Node-Id"]) + + @app.get("/prepare-safe") + async def prepare_safe(): + state = await sensor.raw_state() + acquisition = state.get("acquisition") or {} + control = state.get("application_control_session") or {} + physical = control.get("physical_command") or state.get("physical_command") or {} + return { + "safe": not bridge.lock.locked() + and state.get("source_mode") == "idle" + and acquisition.get("state") in {None, "completed", "failed", "aborted"} + and not physical.get("requires_reconciliation") + and control.get("state") + not in {"start-requested", "initializing", "scanning", "stop-requested"} + } + + @app.post("/sensor-operation") + async def sensor_operation(request: Request): + try: + data = await request.body() + if len(data) > 65536: + raise ValueError("Request too large") + command = await request.json() + if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC): + raise ValueError("Command expired") + result = await sensor.execute(command, request.headers["X-Node-Id"]) + return {"state": "complete", "result": result} + except Exception: + return { + "state": "unknown", + "error": "Действие K1 не подтверждено. Обновите состояние устройства.", + } + + @app.post("/operation") + async def operation(request: Request): + # The Go broker admits size/action/binding/deadline and journals the + # operation before calling this private Unix socket. Never serialize + # Pydantic validation errors, exception text or the incoming payload. + body = {} + try: + data = await request.body() + if len(data) > 16384: + raise ValueError("Request too large") + body = await request.json() + return await bridge.execute(body) + except Exception: + return JSONResponse( + { + "error": ( + "Действие K1 не подтверждено. Обновите состояние; " + "проверьте питание, Bluetooth и сеть БК." + ) + }, + status_code=409, + ) + finally: + if isinstance(body, dict) and isinstance(body.get("parameters"), dict): + body["parameters"].pop("password", None) + + return app + + +def main(): + import os + + import uvicorn + + os.umask(0o007) + app = create_app(Path("/usr/lib/mission-core-node/k1")) + uvicorn.run(app, uds="/run/mission-core-k1/driver.sock", access_log=False, log_level="warning") + + +if __name__ == "__main__": + main() diff --git a/src/k1link/device_plugins/xgrids_k1/node_sensor.py b/src/k1link/device_plugins/xgrids_k1/node_sensor.py new file mode 100644 index 0000000..538e510 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/node_sensor.py @@ -0,0 +1,255 @@ +"""SDK projection and explicit acquisition actions for the Node-owned K1.""" + +import asyncio +import hashlib +import time +from datetime import UTC, datetime + +from .facade import ( + XGRIDS_K1_MODEL_ID, + XGRIDS_K1_PLUGIN_ID, + XGRIDS_K1_PLUGIN_VERSION, + ViewerSettingsRequest, +) +from .node_bridge import ATTESTATION + +PHYSICAL_ACCEPTANCE_KEYS = ( + "operator_present", + "owner_controlled_device", + "lixelgo_closed", + "battery_storage_confirmed", + "expected_physical_state_confirmed", +) + + +def project_sensor(snapshot, node_id): + session = snapshot.get("device_session") + if not session: + return None + identifier = "k1_" + hashlib.sha256(session["device_id"].encode()).hexdigest()[:32] + lifecycle = snapshot.get("connection_lifecycle", {}) + connected = ( + lifecycle.get("connection_ready") is True + and snapshot.get("active_connection_mode") == "bridge" + ) + acquisition = snapshot.get("acquisition") or {} + acquisition_state = { + "running": "streaming", + "active": "streaming", + "prepared": "preparing", + "starting": "starting", + "stopping": "stopping", + "failed": "failed", + }.get(acquisition.get("state"), "idle") + if snapshot.get("source_mode") == "live": + acquisition_state = "streaming" + now = datetime.now(UTC).isoformat() + context = { + "session_id": session["device_session_id"], + "device": { + "device_id": identifier, + "model": { + "plugin_id": XGRIDS_K1_PLUGIN_ID, + "plugin_version": XGRIDS_K1_PLUGIN_VERSION, + "model_id": XGRIDS_K1_MODEL_ID, + }, + "stability": "provisional", + "basis": "plugin-derived", + }, + "execution": { + "node_id": node_id, + "agent_instance_id": snapshot["snapshot_runtime_id"], + "platform": "linux", + }, + "opened_at": session["opened_at"], + } + control = snapshot.get("application_control_session") or {} + return { + "id": identifier, + "name": "XGRIDS K1", + "model": "XGRIDS K1", + "kind": "k1", + "prepared": True, + "configured": True, + "verified": connected, + "online": connected, + "usb": "", + "connection_label": "Wi-Fi · " + (snapshot.get("k1_ip") or "—"), + "layers": ["points", "camera"], + "snapshot": { + "context": context, + "revision": snapshot["snapshot_revision"], + "enrollment": "enrolled", + "connectivity": "connected" if connected else "offline", + "acquisition": acquisition_state, + "observed_at": now, + }, + "control": { + "generation": control.get("session_generation"), + "revision": control.get("state_revision"), + "phase": control.get("state"), + "can_start": lifecycle.get("ready_to_start", False), + "can_stop": control.get("state") in {"start-requested", "initializing", "scanning"}, + "acquisition_id": acquisition.get("acquisition_id"), + }, + "live_settings": snapshot.get("viewer_settings", {}), + "frames": snapshot.get("metrics", {}), + "recordings": [], + } + + +class NodeK1Sensor: + def __init__(self, bridge, peers): + self.bridge, self.peers = bridge, peers + + async def raw_state(self): + return await self.bridge.invoke("state.read", {}, "sensor-state") + + async def inventory(self, node_id): + state = await self.raw_state() + item = project_sensor(state, node_id) + return {"items": [item] if item else []} + + @staticmethod + def cas(state, *, acquisition=True): + control = state["application_control_session"] + if acquisition: + return { + "expected_control_session_generation": control["session_generation"], + "expected_control_state_revision": control["state_revision"], + } + return { + "expected_session_generation": control["session_generation"], + "expected_state_revision": control["state_revision"], + } + + async def execute(self, command, node_id): + state = await self.raw_state() + item = project_sensor(state, node_id) + if ( + not item + or command["session"]["device_id"] != item["id"] + or command["session"]["session_id"] != item["snapshot"]["context"]["session_id"] + ): + raise ValueError("Device session changed") + action, params, identifier = ( + command["action_id"], + command.get("parameters", {}), + command["operation_id"], + ) + if action == "details": + return item + if action == "close-peer": + await self.peers.close(params.get("peer_id")) + return {"ok": True} + if action == "offer": + if item["snapshot"]["acquisition"] != "streaming": + raise ValueError("Acquisition is not active") + return await self.peers.offer(params) + async with self.bridge.lock: + if datetime.fromisoformat(command["deadline_at"]) <= datetime.now(UTC): + raise ValueError("Command expired before dispatch") + state = await self.raw_state() + current = project_sensor(state, node_id) + if ( + current is None + or current["snapshot"]["context"]["session_id"] != command["session"]["session_id"] + ): + raise ValueError("Device session changed") + runtime = state["snapshot_runtime_id"] + if action == "option": + if ( + set(params) != {"profile", "settings"} + or params["profile"] != "live-acquisition" + ): + raise ValueError("Only the live profile belongs to this device") + settings = ViewerSettingsRequest.model_validate(params["settings"]) + result = await self.bridge.invoke( + "viewer.settings.update", settings.model_dump(), identifier + ) + return project_sensor(result, node_id) + if action == "verify": + result = await self.bridge.invoke( + "connection.verify", {"expected_snapshot_runtime_id": runtime}, identifier + ) + return project_sensor(result, node_id) + if action not in {"start", "stop"} or params.get("operator_confirmed") is not True: + raise ValueError("Unsupported device action") + if ( + params.get("control_generation") != current["control"]["generation"] + or params.get("acquisition_id") != current["control"]["acquisition_id"] + ): + raise ValueError("Control session changed") + # Same explicit START/STOP acceptance as the admitted local plugin. + # No confirmation is inferred from polling, discovery or a preview offer. + acceptance = dict.fromkeys(PHYSICAL_ACCEPTANCE_KEYS, True) + if action == "stop": + result = await self.bridge.invoke( + "acquisition.stop", + { + "operation_id": identifier, + "idempotency_key": identifier, + "acquisition_id": params.get("acquisition_id"), + "mode": "graceful", + "physical_acceptance": acceptance, + "expected_snapshot_runtime_id": runtime, + **self.cas(state), + }, + identifier, + ) + await self.peers.close_all() + return project_sensor(result, node_id) + deadline = min( + datetime.fromisoformat(command["deadline_at"]).timestamp(), time.time() + 165 + ) + dispatched = set() + while time.time() < deadline: + if state["snapshot_runtime_id"] != runtime: + raise ValueError("Runtime changed during START") + control = state.get("application_control_session") or {} + phase = control.get("state") + physical = control.get("physical_command") or state.get("physical_command") or {} + if physical.get("requires_reconciliation"): + raise ValueError("Physical state requires explicit reconciliation") + acquisition = state.get("acquisition") or {} + payload = {"expected_snapshot_runtime_id": runtime} + next_action = None + if phase == "connection-ready": + if control.get("inspection_only"): + next_action = "application-control.session.open" + payload.update(acceptance, timezone_name="UTC") + else: + next_action = "application-control.workspace.enter" + payload.update(self.cas(state, acquisition=False), operator_confirmed=True) + elif phase == "workspace-ready" and acquisition.get("state") != "prepared": + next_action = "acquisition.prepare" + payload.update( + self.cas(state), + operation_id=identifier + "_prepare", + idempotency_key=identifier + "_prepare", + project_name="node-" + identifier[3:15], + compatibility_attestation=ATTESTATION, + ) + elif phase == "project-ready" and acquisition.get("state") == "prepared": + next_action = "acquisition.start" + payload.update( + self.cas(state), + operation_id=identifier, + idempotency_key=identifier, + acquisition_id=acquisition["acquisition_id"], + expected_state_revision=acquisition["state_revision"], + physical_acceptance=acceptance, + ) + elif phase in {"failed", "idle", "closed", "completed"}: + raise ValueError("K1 control not ready") + elif phase in {"start-requested", "initializing", "scanning"}: + return project_sensor(state, node_id) + if next_action and next_action not in dispatched: + dispatched.add(next_action) + state = await self.bridge.invoke( + next_action, payload, identifier + "_" + str(len(dispatched)) + ) + else: + await asyncio.sleep(0.5) + state = await self.raw_state() + raise TimeoutError("K1 did not confirm START") diff --git a/src/k1link/device_plugins/xgrids_k1/wifi_failure.py b/src/k1link/device_plugins/xgrids_k1/wifi_failure.py new file mode 100644 index 0000000..f0a514f --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/wifi_failure.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from collections.abc import Mapping + + +def reviewed_station_failure_code( + error: Mapping[str, object], *, firmware_version: str, connection_mode: str +) -> str | None: + """Interpret the reviewed FW 3.0.2 station callback, retaining ATT evidence. + + lixel_nman's wifi_connect return value is passed to the GATT write result. + Its 4 (SSID not found) and 6 (credentials required) collide with standard + ATT names. This is a profile-scoped diagnosis, not network-state proof or + retry authority. See the 2026-09-06 firmware callback audit. + """ + if not ( + firmware_version == "3.0.2" + and connection_mode in {"bridge", "direct-connect"} + and error.get("code") == "BleakGATTProtocolError" + and error.get("operation_stage") == "gatt-write" + and error.get("device_write_attempted") is True + and error.get("device_write_confirmed") is False + and error.get("resolved_write_mode") == "with_response" + and error.get("frame_length") == 99 + ): + return None + code = error.get("ble_att_error_code") + if not isinstance(code, int) or isinstance(code, bool): + return None + return { + 4: "k1-wifi-network-not-found", + 6: "k1-wifi-credentials-required", + }.get(code) diff --git a/src/k1link/fleet/device_enrollment.py b/src/k1link/fleet/device_enrollment.py new file mode 100644 index 0000000..f32c970 --- /dev/null +++ b/src/k1link/fleet/device_enrollment.py @@ -0,0 +1,173 @@ +"""Bounded, transient delivery of Node-owned wireless device operations. + +Unlike ordinary sensor operations these can contain a WLAN password. The +pending payload never enters Fleet's database, listing, event stream or result. +The Node owns the durable, redacted operation journal and at-most-once dispatch. +""" + +import copy +import json +import re +import time +from datetime import UTC, datetime + +from .trust import PairingError + +ACTIONS = {"scan", "networks", "connect", "verify"} + + +def validate(value): + if not isinstance(value, dict) or set(value) != { + "operation_id", + "node_id", + "runtime_id", + "action", + "deadline_at", + "parameters", + }: + raise PairingError("Неверный запрос подключения устройства.") + if ( + not re.fullmatch(r"op_[0-9a-f]{32}", str(value["operation_id"])) + or value["action"] not in ACTIONS + or len(json.dumps(value)) > 16384 + or not isinstance(value["runtime_id"], str) + or not value["runtime_id"] + or len(value["runtime_id"]) > 128 + ): + raise PairingError("Неподдерживаемая операция подключения.") + try: + delta = (datetime.fromisoformat(value["deadline_at"]) - datetime.now(UTC)).total_seconds() + if not 0 < delta <= 180: + raise ValueError + except (ValueError, TypeError): + raise PairingError("Срок запроса истёк. Обновите устройства.") from None + parameters = value["parameters"] + expected = {"device_id", "discovery_generation", "mode_revision"} + if value["action"] == "connect": + expected |= {"ssid", "password"} + elif value["action"] in {"scan", "networks"}: + expected = set() + if not isinstance(parameters, dict) or set(parameters) != expected: + raise PairingError("Неверные параметры подключения.") + if "device_id" in expected and ( + not isinstance(parameters["device_id"], str) + or not 1 <= len(parameters["device_id"]) <= 128 + or type(parameters["discovery_generation"]) is not int + or type(parameters["mode_revision"]) is not int + or min(parameters["discovery_generation"], parameters["mode_revision"]) < 0 + ): + raise PairingError("Обновите выбранное устройство.") + if value["action"] == "connect" and any( + not isinstance(parameters[key], str) or not 1 <= len(parameters[key].encode()) <= limit + for key, limit in (("ssid", 32), ("password", 64)) + ): + raise PairingError("Проверьте название сети и пароль.") + + +class DeviceEnrollment: + def __init__(self): + # Access only under the enclosing FleetRegistry.lock. + self.pending = {} + + def prune(self): + now = time.time() + for key, entry in list(self.pending.items()): + if now - entry["created"] > 600: + del self.pending[key] + elif entry["deadline"] <= now and entry["public"]["state"] in {"queued", "running"}: + entry["payload"] = None + entry["public"] = { + "operation_id": key[1], + "state": "unknown", + "error": "Подтверждение с БК не получено. Обновите состояние K1.", + } + + def submit(self, fleet, vehicle_id, value): + validate(value) + with fleet.lock: + self.prune() + row = fleet.find(vehicle_id) + state = row.get("device_enrollment", {}) + if ( + fleet.public(row)["connectivity"] != "online" + or value["node_id"] != row["node_id"] + or value["runtime_id"] != state.get("runtime_id") + or state.get("available") is not True + ): + raise PairingError("БК или служба K1 недоступны. Обновите устройства.") + key = (vehicle_id, value["operation_id"]) + existing = self.pending.get(key) + if existing: + # First intent owns this ID; never accept replacement secrets. + return copy.deepcopy(existing["public"]) + if len(self.pending) >= 32: + raise PairingError("Слишком много запросов. Повторите позже.") + public = {"operation_id": value["operation_id"], "state": "queued"} + self.pending[key] = { + "public": public, + "payload": copy.deepcopy(value), + "binding": row["binding"]["binding_id"], + "node_id": row["node_id"], + "created": time.time(), + "deadline": datetime.fromisoformat(value["deadline_at"]).timestamp(), + } + return dict(public) + + def operation(self, fleet, vehicle_id, identifier): + with fleet.lock: + self.prune() + row = fleet.find(vehicle_id) + entry = self.pending.get((vehicle_id, identifier)) + if not entry or entry["binding"] != (row.get("binding") or {}).get("binding_id"): + # A Core restart loses transient delivery; never recreate it + # from browser credentials or imply that a physical write failed. + return { + "operation_id": identifier, + "state": "unknown", + "error": "Результат запроса недоступен. Обновите состояние устройства.", + } + return copy.deepcopy(entry["public"]) + + def heartbeat(self, row, value): + self.prune() + state = value.get("device_enrollment", {"available": False}) + if not isinstance(state, dict) or len(json.dumps(state)) > 32768: + raise ValueError("Invalid device enrollment state") + row["device_enrollment"] = state + acknowledgements = [] + results = value.get("enrollment_results", []) + if not isinstance(results, list) or len(results) > 32: + raise ValueError("Invalid enrollment results") + for result in results: + if not isinstance(result, dict) or len(json.dumps(result)) > 65536: + raise ValueError("Invalid enrollment result") + key = (row["id"], result.get("operation_id")) + entry = self.pending.get(key) + if ( + entry + and entry["binding"] == row["binding"]["binding_id"] + and result.get("state") in {"running", "complete", "error", "unknown"} + ): + entry["payload"] = None # Node's journal now owns the intent. + entry["public"] = { + k: result[k] + for k in ("operation_id", "state", "error", "result") + if k in result + } + if result["state"] != "running": + acknowledgements.append(result["operation_id"]) + elif result.get("state") != "running": + acknowledgements.append(result.get("operation_id")) + commands = [] + for (vehicle_id, _), entry in self.pending.items(): + if vehicle_id != row["id"]: + continue + if entry["binding"] != row["binding"]["binding_id"]: + entry["payload"] = None + entry["public"]["state"] = "unknown" + elif entry["payload"] is not None: + commands.append(entry["payload"]) + return { + "enrollment_commands": commands[:2], + "enrollment_acknowledgements": acknowledgements, + } diff --git a/src/k1link/fleet/registry.py b/src/k1link/fleet/registry.py index 07eb63f..5f7ab01 100644 --- a/src/k1link/fleet/registry.py +++ b/src/k1link/fleet/registry.py @@ -30,6 +30,9 @@ class FleetRegistry: from .events import FleetEvents self.events = FleetEvents() + from .device_enrollment import DeviceEnrollment + + self.device_enrollment = DeviceEnrollment() self.trust = CoreTrust(root) path = root / "fleet.sqlite3" if path.is_symlink(): @@ -378,6 +381,7 @@ class FleetRegistry: return 400, {"error": "Invalid Node inventory"} sensor_state = sensors.validate_inventory(value, node_id) sensor_response = sensors.heartbeat(row, value) + enrollment_response = self.device_enrollment.heartbeat(row, value) host = value.get("host") if ( not isinstance(host, dict) @@ -403,4 +407,4 @@ class FleetRegistry: } row["binding"]["client_pem"] = self.trust.leaf(node_id, key) self.save(row) - return 200, {"ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response} + return 200, {"ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response, **enrollment_response} diff --git a/src/k1link/viewer/node_media.py b/src/k1link/viewer/node_media.py new file mode 100644 index 0000000..da8a828 --- /dev/null +++ b/src/k1link/viewer/node_media.py @@ -0,0 +1,170 @@ +"""Paired signalling, private ICE candidates and bounded live data channels.""" + +import asyncio +import ipaddress +import queue +import time +from contextlib import suppress +from uuid import uuid4 + +import aioice.ice +from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription + +PRIVATE_NETWORKS = tuple( + ipaddress.ip_network(v) + for v in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10") +) + + +def private(address): + try: + value = ipaddress.ip_address(address) + return value.version == 4 and any(value in network for network in PRIVATE_NETWORKS) + except ValueError: + return False + + +def admit_sdp(sdp): + if not isinstance(sdp, str) or not 1 <= len(sdp) <= 32768: + raise ValueError("Invalid media invitation") + media = [line for line in sdp.splitlines() if line.startswith("m=")] + if not sdp.startswith("v=0") or len(media) != 1 or not media[0].startswith("m=application "): + raise ValueError("A single data-channel media section is required") + for line in sdp.splitlines(): + if line.startswith("a=candidate:"): + parts = line.split() + if ( + len(parts) < 8 + or parts[7] != "host" + or not (private(parts[4]) or parts[4].endswith(".local")) + ): + raise ValueError("Only private host ICE candidates are admitted") + + +class NodeMediaPeers: + def __init__(self, hub, camera): + self.hub, self.camera, self.items = hub, camera, {} + # Process-local adapter in this dedicated worker; no public/STUN/TURN ICE. + original = aioice.ice.get_host_addresses + aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [ + value for value in original(use_ipv4=True, use_ipv6=False) if private(value) + ] + + async def offer(self, parameters): + admit_sdp(parameters.get("sdp")) + if len(self.items) >= 2: + raise ValueError("Close another live viewer") + pc = RTCPeerConnection(RTCConfiguration(iceServers=[])) + identifier = "peer_" + uuid4().hex + entry = {"pc": pc, "seen": time.monotonic(), "tasks": [], "labels": set()} + self.items[identifier] = entry + + @pc.on("datachannel") + def datachannel(channel): + if channel.label not in {"rrd", "camera"} or channel.label in entry["labels"]: + channel.close() + return + entry["labels"].add(channel.label) + + @channel.on("message") + def message(value): + if value == "keepalive": + entry["seen"] = time.monotonic() + + entry["tasks"].append(asyncio.create_task(self.deliver(identifier, channel))) + + @pc.on("connectionstatechange") + async def changed(): + if pc.connectionState in {"closed", "failed"}: + await self.close(identifier) + + try: + await pc.setRemoteDescription( + RTCSessionDescription(sdp=parameters["sdp"], type="offer") + ) + await pc.setLocalDescription(await pc.createAnswer()) + + async def expiry(): + await asyncio.sleep(25) + if pc.connectionState != "connected": + await self.close(identifier) + + entry["tasks"].append(asyncio.create_task(expiry())) + snapshot = self.camera.snapshot() + return { + "peer_id": identifier, + "sdp": pc.localDescription.sdp, + "type": "answer", + "camera_mime": (snapshot.get("delivery") or {}).get("media_type"), + "profile": "live-acquisition", + "transport": "webrtc-rrd-fmp4", + } + except BaseException: + await self.close(identifier) + raise + + async def send(self, channel, payload): + if len(payload) > 8 * 1024 * 1024: + raise RuntimeError("Preview fragment exceeds bound") + for offset in range(0, len(payload), 16384): + deadline = time.monotonic() + 2 + while channel.readyState != "open" or channel.bufferedAmount > 1024 * 1024: + if channel.readyState in {"closed", "closing"} or time.monotonic() > deadline: + raise RuntimeError("Preview consumer unavailable") + await asyncio.sleep(0.01) + channel.send(payload[offset : offset + 16384]) + await asyncio.sleep(0) + + async def deliver(self, identifier, channel): + subscriber = lease = None + try: + entry = self.items[identifier] + if channel.label == "rrd": + subscriber = await asyncio.to_thread(self.hub.subscribe) + else: + generation = self.camera.snapshot().get("generation") + if generation is None: + channel.close() + return + lease = await asyncio.to_thread(self.camera.open_delivery, generation) + while identifier in self.items and time.monotonic() - entry["seen"] < 30: + if subscriber: + payload = await asyncio.to_thread(subscriber.read) + else: + try: + segment = await asyncio.to_thread(lease.segments.get, 0.5) + except queue.Empty: + continue + if segment is None: + break + kind, payload = segment + if kind == "media": + self.camera.mark_streaming(lease) + if payload is None: + break + if payload: + await self.send(channel, payload) + except (Exception, asyncio.CancelledError): + pass + finally: + if subscriber: + subscriber.close() + if lease: + self.camera.release_delivery(lease, client_closed=True) + if channel.label == "camera": + channel.close() + else: + await self.close(identifier) + + async def close(self, identifier): + entry = self.items.pop(identifier, None) + if entry: + for task in entry["tasks"]: + if task is not asyncio.current_task(): + task.cancel() + with suppress(Exception): + await entry["pc"].close() + + async def close_all(self): + for identifier in list(self.items): + await self.close(identifier) diff --git a/src/k1link/viewer/node_rerun.py b/src/k1link/viewer/node_rerun.py new file mode 100644 index 0000000..7d1f56f --- /dev/null +++ b/src/k1link/viewer/node_rerun.py @@ -0,0 +1,144 @@ +"""Bounded live RRD publication for paired Node viewers, without a TCP listener. + +Every viewer receives a fresh native recording including StoreInfo/blueprint. +Latest-value queues discard decoded preview frames before encoding; encoded +RRD bytes are never dropped inside a stream. Slow viewers are closed instead. +""" + +import queue +import threading +from contextlib import suppress +from uuid import uuid4 + +from k1link.viewer.rerun_bridge import RerunBridge + +MAX_ENCODED_CHUNK = 8 * 1024 * 1024 + + +class RrdSubscriber: + def __init__(self, settings_provider): + self.closed = threading.Event() + self.inputs = queue.Queue(maxsize=2) + self.output = queue.Queue(maxsize=2) + self.settings_provider = settings_provider + self.thread = threading.Thread(target=self.run, name="node-rerun-view", daemon=True) + self.thread.start() + + def offer(self, envelope): + if self.closed.is_set(): + return + with suppress(queue.Full): + if self.inputs.full(): + with suppress(queue.Empty): + self.inputs.get_nowait() + self.inputs.put_nowait(envelope) + + def read(self): + if self.closed.is_set(): + return None + try: + return self.output.get(timeout=0.5) + except queue.Empty: + return b"" + + def close(self): + self.closed.set() + + def run(self): + binary = None + bridge = None + + def output(recording): + nonlocal binary + binary = recording.binary_stream() + return "webrtc+rrd://" + str(uuid4()) + + try: + bridge = RerunBridge(settings_provider=self.settings_provider, recording_output=output) + bridge.begin_session() + while not self.closed.is_set(): + payload = binary.read() + if len(payload) > MAX_ENCODED_CHUNK: + break + if payload: + # Bound both bytes and waiting time. The archive/producer + # never waits for this disposable preview subscription. + self.output.put(payload, timeout=0.5) + try: + envelope = self.inputs.get(timeout=0.1) + except queue.Empty: + continue + bridge.process(envelope) + except Exception: + pass + finally: + self.closed.set() + if bridge is not None: + with suppress(Exception): + bridge.close() + binary.read() + + +class NodeRerunBridge(RerunBridge): + def __init__(self, **kwargs): + self.lock = threading.Lock() + self.subscribers = [] + self.latest = {} + + def output(recording): + self.binary = recording.binary_stream() + return "webrtc+rrd://" + str(uuid4()) + + super().__init__(**kwargs, recording_output=output) + self.binary.read() + + def process(self, envelope): + super().process(envelope) + # The primary recording proves native publication and owns metrics. + # It is continuously drained even when no viewer is attached. + self.binary.read() + with self.lock: + self.latest[type(envelope)] = envelope + self.subscribers = [v for v in self.subscribers if not v.closed.is_set()] + for subscriber in self.subscribers: + subscriber.offer(envelope) + + def process_perception(self, frame): + super().process_perception(frame) + self.binary.read() + + def subscribe(self): + with self.lock: + self.subscribers = [v for v in self.subscribers if not v.closed.is_set()] + if self._closed or len(self.subscribers) >= 2: + raise RuntimeError("Live viewer unavailable") + subscriber = RrdSubscriber(self._settings_provider) + for envelope in self.latest.values(): + subscriber.offer(envelope) + self.subscribers.append(subscriber) + return subscriber + + def close(self): + with self.lock: + for subscriber in self.subscribers: + subscriber.close() + self.subscribers.clear() + self.latest.clear() + super().close() + self.binary.read() + + +class NodeRerunHub: + def __init__(self): + self.bridge = None + + def create(self, **kwargs): + bridge = NodeRerunBridge(**kwargs) + self.bridge = bridge + return bridge + + def subscribe(self): + bridge = self.bridge + if bridge is None: + raise RuntimeError("Live acquisition is not active") + return bridge.subscribe() diff --git a/src/k1link/viewer/rerun_bridge.py b/src/k1link/viewer/rerun_bridge.py index 95f1574..62783c5 100644 --- a/src/k1link/viewer/rerun_bridge.py +++ b/src/k1link/viewer/rerun_bridge.py @@ -118,6 +118,7 @@ class RerunBridge: settings_provider: SettingsProvider | None = None, cors_allow_origin: tuple[str, ...] = DEFAULT_CORS_ORIGINS, recording_factory: Callable[[str], rr.RecordingStream] | None = None, + recording_output: Callable[[rr.RecordingStream], str] | None = None, ) -> None: self.metrics = metrics or BridgeMetrics() self._settings_provider = settings_provider or RerunSceneSettings @@ -130,31 +131,29 @@ class RerunBridge: else: recording = recording_factory("nodedc_mission_core_spatial") try: - selected_grpc_port = _select_available_grpc_port(grpc_port) - if selected_grpc_port != grpc_port: - logger.info( - "Mission Core selected a new Rerun port because an earlier " - "viewer still owns the preferred listener", - extra={ - "event_code": "rerun_grpc_port_rotated", - "preferred_port": grpc_port, - "selected_port": selected_grpc_port, - }, - ) blueprint = _blueprint(self._settings) - url = recording.serve_grpc( - grpc_port=selected_grpc_port, - default_blueprint=blueprint, - # This is a reconnect cushion for the live preview, not the source - # of record. Raw MQTT evidence is persisted independently. A large - # late-client backlog can block the native SDK and freeze preview. - server_memory_limit=LIVE_GRPC_BUFFER_LIMIT, - # Rerun transport can replay ActivateStore before StoreInfo when an - # evicted buffer is served newest-first, leaving late viewers on the - # welcome screen. Preserve protocol order within the bounded cache. - newest_first=False, - cors_allow_origin=list(cors_allow_origin), - ) + if recording_output is not None: + # Node's paired WebRTC delivery uses an RRD sink and never opens + # an unauthenticated gRPC listener on an onboard interface. + url = recording_output(recording) + else: + selected_grpc_port = _select_available_grpc_port(grpc_port) + if selected_grpc_port != grpc_port: + logger.info( + "Mission Core selected a new Rerun port because an earlier viewer " + "still owns the preferred listener", + extra={"event_code": "rerun_grpc_port_rotated", "preferred_port": grpc_port, + "selected_port": selected_grpc_port}, + ) + url = recording.serve_grpc( + grpc_port=selected_grpc_port, + default_blueprint=blueprint, + # Bounded reconnect cushion, not the source of record. + server_memory_limit=LIVE_GRPC_BUFFER_LIMIT, + # StoreInfo must precede ActivateStore for late viewers. + newest_first=False, + cors_allow_origin=list(cors_allow_origin), + ) recording.send_blueprint( blueprint, make_active=True, diff --git a/src/k1link/web/fleet_api.py b/src/k1link/web/fleet_api.py index d6b6650..befe47d 100644 --- a/src/k1link/web/fleet_api.py +++ b/src/k1link/web/fleet_api.py @@ -136,3 +136,52 @@ def sensor_operation( return operation(fleet, vehicle_id, operation_id) except PairingError as error: raise HTTPException(404, str(error)) from None + + +@router.get("/{vehicle_id}/devices/enrollment") +def enrollment_state( + vehicle_id: str, response: Response, fleet: Annotated[FleetRegistry, Depends(local_operator)] +): + response.headers["Cache-Control"] = "no-store" + try: + with fleet.lock: + row = fleet.find(vehicle_id) + return { + **row.get("device_enrollment", {"available": False}), + "node_id": row["node_id"], + "name": row["name"], + "fresh": fleet.public(row)["connectivity"] == "online", + } + except PairingError as error: + raise HTTPException(404, str(error)) from None + + +@router.post("/{vehicle_id}/devices/enrollment/operations") +def enrollment_submit( + vehicle_id: str, + body: dict, + response: Response, + fleet: Annotated[FleetRegistry, Depends(local_operator)], +): + response.headers["Cache-Control"] = "no-store" + try: + return fleet.device_enrollment.submit(fleet, vehicle_id, body) + except (PairingError, ValueError, TypeError): + # Validation diagnostics must never echo a supplied credential. + raise HTTPException( + 409, "Запрос подключения не принят. Обновите БК и проверьте параметры сети." + ) from None + + +@router.get("/{vehicle_id}/devices/enrollment/operations/{operation_id}") +def enrollment_operation( + vehicle_id: str, + operation_id: str, + response: Response, + fleet: Annotated[FleetRegistry, Depends(local_operator)], +): + response.headers["Cache-Control"] = "no-store" + try: + return fleet.device_enrollment.operation(fleet, vehicle_id, operation_id) + except PairingError as error: + raise HTTPException(404, str(error)) from None diff --git a/src/k1link/web/runtime_diagnostics.py b/src/k1link/web/runtime_diagnostics.py index f02b9d2..70431f5 100644 --- a/src/k1link/web/runtime_diagnostics.py +++ b/src/k1link/web/runtime_diagnostics.py @@ -18,6 +18,7 @@ _EXTRA_FIELDS: Final = ( "connection_mode", "error_category", "error_code", + "transport_error_code", "reason_code", "failed_phase", "dialogue_stage", @@ -32,6 +33,20 @@ _EXTRA_FIELDS: Final = ( "device_write_confirmed", "ble_att_error_code", "ble_att_error_name", + "resolved_write_mode", + "max_write_without_response_size", + "mtu_size", + "frame_length", + "bridge_frame_length", + "quick_connect_frame_length", + "scan_elapsed_ms", + "scanner_start_ms", + "initial_window_ms", + "first_candidate_ms", + "scan_extended", + "candidate_count", + "likely_k1_candidate_count", + "discovery_generation", "helper_stage", "helper_elapsed_ms", "status_reconciliation", @@ -102,6 +117,26 @@ class ScannerDiagnosticJsonFormatter(logging.Formatter): value = getattr(record, field, None) if value is not None and isinstance(value, (str, int, bool)): document[field] = value + if record.exc_info is not None: + exception_type, _exception, traceback = record.exc_info + if exception_type is not None: + document["exception_type"] = exception_type.__name__[:128] + if traceback is not None: + while traceback.tb_next is not None: + traceback = traceback.tb_next + code = traceback.tb_frame.f_code + # Preserve the failure location, never exception text, locals, + # source lines or absolute paths that may contain private data. + document["exception_site"] = ( + f"{Path(code.co_filename).name}:{traceback.tb_lineno}:{code.co_name}" + )[:256] + properties = getattr(record, "write_characteristic_properties", None) + if isinstance(properties, (list, tuple)) and all( + isinstance(value, str) + and value in {"read", "write", "write-without-response", "notify", "indicate"} + for value in properties + ): + document["write_characteristic_properties"] = sorted(set(properties)) return json.dumps(document, ensure_ascii=False, separators=(",", ":")) diff --git a/tests/fleet/test_device_enrollment.py b/tests/fleet/test_device_enrollment.py new file mode 100644 index 0000000..38e536e --- /dev/null +++ b/tests/fleet/test_device_enrollment.py @@ -0,0 +1,111 @@ +import copy +import json +import secrets +import threading +from datetime import UTC, datetime, timedelta + +import pytest + +from k1link.fleet.device_enrollment import DeviceEnrollment, validate +from k1link.fleet.trust import PairingError + + +class Fleet: + def __init__(self): + self.lock = threading.RLock() + self.row = { + "id": "vehicle", + "node_id": "node-1", + "binding": {"binding_id": "binding-1"}, + "device_enrollment": {"available": True, "runtime_id": "runtime-1"}, + } + + def find(self, _identifier): + return self.row + + def public(self, row): + return {"connectivity": "online"} + + +def command(): + # Synthetic value constructed at runtime; never a real WLAN credential. + return { + "operation_id": "op_" + "a" * 32, + "node_id": "node-1", + "runtime_id": "runtime-1", + "action": "connect", + "deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(), + "parameters": { + "device_id": "AA:BB:CC:DD:EE:FF", + "discovery_generation": 1, + "mode_revision": 0, + "ssid": "test-net", + "password": secrets.token_urlsafe(20), + }, + } + + +def test_password_only_lives_in_pending_delivery_and_never_in_fleet_or_result(): + fleet, bus, request = Fleet(), DeviceEnrollment(), command() + secret = request["parameters"]["password"] + public = bus.submit(fleet, "vehicle", request) + assert secret not in json.dumps(public) + assert secret not in json.dumps(fleet.row) + envelope = bus.heartbeat(fleet.row, {}) + assert envelope["enrollment_commands"][0]["parameters"]["password"] == secret + bus.heartbeat( + fleet.row, + {"enrollment_results": [{"operation_id": request["operation_id"], "state": "running"}]}, + ) + assert secret not in json.dumps(list(bus.pending.values())) + assert not bus.heartbeat(fleet.row, {})["enrollment_commands"] + + +def test_unknown_after_core_restart_does_not_recreate_command(): + fleet, bus, request = Fleet(), DeviceEnrollment(), command() + bus.submit(fleet, "vehicle", request) + restarted = DeviceEnrollment() + assert restarted.operation(fleet, "vehicle", request["operation_id"])["state"] == "unknown" + assert not restarted.heartbeat(fleet.row, {})["enrollment_commands"] + + +def test_fences_board_runtime_and_deadline_and_forbids_host_actions(): + for change in ( + {"node_id": "node-2"}, + {"runtime_id": "runtime-2"}, + {"action": "quick-connect"}, + {"deadline_at": datetime.now(UTC).isoformat()}, + ): + fleet, bus, request = Fleet(), DeviceEnrollment(), command() + request.update(change) + with pytest.raises(PairingError): + bus.submit(fleet, "vehicle", request) + request = command() + request["parameters"]["allow_host_wifi_switch"] = True + with pytest.raises(PairingError): + validate(request) + + +def test_rebinding_drops_delivery_and_old_results(): + fleet, bus, request = Fleet(), DeviceEnrollment(), command() + bus.submit(fleet, "vehicle", request) + fleet.row["binding"]["binding_id"] = "binding-2" + assert not bus.heartbeat(fleet.row, {})["enrollment_commands"] + assert bus.operation(fleet, "vehicle", request["operation_id"])["state"] == "unknown" + + +def test_expiry_drops_secret_without_automatic_retry(): + fleet, bus, request = Fleet(), DeviceEnrollment(), command() + bus.submit(fleet, "vehicle", request) + bus.pending[("vehicle", request["operation_id"])]["deadline"] = 0 + assert bus.operation(fleet, "vehicle", request["operation_id"])["state"] == "unknown" + assert bus.pending[("vehicle", request["operation_id"])]["payload"] is None + + +def test_duplicate_id_cannot_replace_original_secret(): + fleet, bus, request = Fleet(), DeviceEnrollment(), command() + bus.submit(fleet, "vehicle", request) + duplicate = copy.deepcopy(request) + duplicate["parameters"]["password"] += "changed" + bus.submit(fleet, "vehicle", duplicate) + assert bus.pending[("vehicle", request["operation_id"])]["payload"] == request diff --git a/tests/test_ble_scanner.py b/tests/test_ble_scanner.py index 4d0c248..8fa1c5b 100644 --- a/tests/test_ble_scanner.py +++ b/tests/test_ble_scanner.py @@ -1,5 +1,5 @@ import asyncio -from collections.abc import Iterator +from collections.abc import Callable, Iterator from pathlib import Path from typing import Literal @@ -36,6 +36,117 @@ from k1link.device_plugins.xgrids_k1.ble.scanner import ( RecoveryOperationKind = Literal["status-read", "wifi-provision", "ap-enable"] +def install_advertisement_source( + monkeypatch: MonkeyPatch, + observations: list[tuple[float, BLEDevice, str | None]], +) -> dict[str, int]: + """A single native scanner that delivers advertisements while it is open.""" + lifecycle = {"started": 0, "stopped": 0} + + class FakeScanner: + def __init__( + self, detection_callback: Callable[[BLEDevice, AdvertisementData], None] + ) -> None: + self.callback = detection_callback + self.scheduled: list[asyncio.TimerHandle] = [] + + async def __aenter__(self) -> "FakeScanner": + lifecycle["started"] += 1 + for delay, device, name in observations: + advertisement = AdvertisementData( + local_name=name, manufacturer_data={}, service_data={}, + service_uuids=[], tx_power=None, rssi=-45, platform_data=(), + ) + self.scheduled.append(asyncio.get_running_loop().call_later( + delay, self.callback, device, advertisement + )) + return self + + async def __aexit__(self, *_args: object) -> None: + lifecycle["stopped"] += 1 + for timer in self.scheduled: + timer.cancel() + + monkeypatch.setattr(scanner_module, "BleakScanner", FakeScanner) + monkeypatch.setattr(scanner_module, "BLE_SCAN_INITIAL_WINDOW_SECONDS", 0.02) + return lifecycle + + +def test_discovery_keeps_one_scanner_open_for_a_late_k1_name(monkeypatch: MonkeyPatch) -> None: + device = BLEDevice("LATE-K1", None, details=object()) + lifecycle = install_advertisement_source(monkeypatch, [ + (0, device, None), (0.05, device, "XGR-LATE"), + ]) + + async def scenario() -> None: + result = await scan(0.4) + timing = result["discovery_timing"] + assert timing["scan_extended"] is True + assert timing["first_candidate_ms"] >= timing["initial_window_ms"] + assert timing["scan_elapsed_ms"] < 400 + assert len(result["devices"]) == 1 + assert result["devices"][0]["k1_name_candidate"] is True + assert discovered_device(device.address) is device + + asyncio.run(scenario()) + assert lifecycle == {"started": 1, "stopped": 1} + + +def test_discovery_finishes_after_initial_window_when_k1_is_visible( + monkeypatch: MonkeyPatch, +) -> None: + device = BLEDevice("EARLY-K1", "XGR-EARLY", details=object()) + lifecycle = install_advertisement_source(monkeypatch, [(0, device, "XGR-EARLY")]) + result = asyncio.run(scan(0.4)) + timing = result["discovery_timing"] + assert timing["scan_extended"] is False + assert timing["first_candidate_ms"] < timing["initial_window_ms"] + assert 20 <= timing["scan_elapsed_ms"] < 400 + assert lifecycle == {"started": 1, "stopped": 1} + + +def test_discovery_without_k1_stops_at_deadline_and_does_not_reuse_old_results( + monkeypatch: MonkeyPatch, +) -> None: + device = BLEDevice("OTHER-DEVICE", "Headphones", details=object()) + lifecycle = install_advertisement_source(monkeypatch, [(0, device, "Headphones")]) + + async def scenario() -> None: + result = await scan(0.06) + timing = result["discovery_timing"] + assert timing["scan_extended"] is True + assert "first_candidate_ms" not in timing + assert timing["scan_elapsed_ms"] >= 60 + assert result["devices"][0]["k1_name_candidate"] is False + install_advertisement_source(monkeypatch, []) + empty = await scan(0.01) + assert empty["devices"] == [] + assert empty["discovery_timing"]["scan_extended"] is False + assert discovered_device(device.address) is None + + asyncio.run(scenario()) + assert lifecycle == {"started": 1, "stopped": 1} + + +def test_cancelled_discovery_closes_scanner_without_publishing_partial_handles( + monkeypatch: MonkeyPatch, +) -> None: + device = BLEDevice("PARTIAL", "Headphones", details=object()) + lifecycle = install_advertisement_source(monkeypatch, [(0, device, "Headphones")]) + + async def scenario() -> None: + task = asyncio.create_task(scan(1)) + await asyncio.sleep(0.04) + assert lifecycle["started"] == 1 + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert discovered_device(device.address) is None + + asyncio.run(scenario()) + assert lifecycle == {"started": 1, "stopped": 1} + + async def _retrieve_connected_inside_ble_lease( macos_uuid: str, *, @@ -175,7 +286,7 @@ def test_scan_retains_the_live_corebluetooth_handle( return {"LIVE-UUID": (device, advertisement)} monkeypatch.setattr( - "k1link.device_plugins.xgrids_k1.ble.scanner.BleakScanner.discover", + "k1link.device_plugins.xgrids_k1.ble.scanner._discover_k1_advertisements", fake_discover, ) @@ -209,7 +320,7 @@ def test_scan_start_invalidates_previous_lease_and_failure_leaves_it_empty( ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: return {old_device.address: (old_device, old_advertisement)} - monkeypatch.setattr(scanner_module.BleakScanner, "discover", initial_discover) + monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", initial_discover) await scan(1.0) assert discovered_device(old_device.address) is old_device @@ -221,7 +332,7 @@ def test_scan_start_invalidates_previous_lease_and_failure_leaves_it_empty( await release_failing_scan.wait() raise RuntimeError("synthetic BLE scan failure") - monkeypatch.setattr(scanner_module.BleakScanner, "discover", failing_discover) + monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", failing_discover) scan_task = asyncio.create_task(scan(1.0)) await failing_scan_started.wait() @@ -263,7 +374,7 @@ def test_process_arbiter_rejects_overlapping_scan(monkeypatch: MonkeyPatch) -> N await release_older_scan.wait() return {older_device.address: (older_device, older_advertisement)} - monkeypatch.setattr(scanner_module.BleakScanner, "discover", blocked_discover) + monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", blocked_discover) older_task = asyncio.create_task(scan(1.0)) await older_scan_started.wait() @@ -377,7 +488,7 @@ def test_connected_handle_is_session_scoped_and_survives_wall_clock_age( async def fake_discover(**_kwargs: object) -> dict[str, tuple[BLEDevice, AdvertisementData]]: return discoveries.pop(0) - monkeypatch.setattr(scanner_module.BleakScanner, "discover", fake_discover) + monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", fake_discover) monkeypatch.setattr(scanner_module, "monotonic", lambda: monotonic_clock[0]) monkeypatch.setattr( scanner_module, @@ -487,7 +598,7 @@ def test_scan_and_retained_handle_survive_macos_sleep_until_explicit_invalidatio ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: return {device.address: (device, advertisement)} - monkeypatch.setattr(scanner_module.BleakScanner, "discover", fake_discover) + monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", fake_discover) monkeypatch.setattr(scanner_module, "monotonic", lambda: monotonic_clock[0]) monkeypatch.setattr( scanner_module, @@ -1340,7 +1451,7 @@ def test_later_fresh_scan_does_not_replace_retained_corebluetooth_object( async def fake_discover(**_kwargs: object) -> dict[str, tuple[BLEDevice, AdvertisementData]]: return discoveries.pop(0) - monkeypatch.setattr(scanner_module.BleakScanner, "discover", fake_discover) + monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", fake_discover) async def scenario() -> None: await scan(1.0) diff --git a/tests/test_k1_wifi_failure.py b/tests/test_k1_wifi_failure.py new file mode 100644 index 0000000..f64d308 --- /dev/null +++ b/tests/test_k1_wifi_failure.py @@ -0,0 +1,72 @@ +from copy import deepcopy + +import pytest + +from k1link.device_plugins.xgrids_k1.wifi_failure import reviewed_station_failure_code + + +def station_error(code: int = 4) -> dict[str, object]: + return { + "code": "BleakGATTProtocolError", + "operation_stage": "gatt-write", + "device_write_attempted": True, + "device_write_confirmed": False, + "resolved_write_mode": "with_response", + "frame_length": 99, + "ble_att_error_code": code, + "ble_att_error_name": "INVALID_PDU", + "safe_to_retry": False, + "side_effect_status": "unknown", + } + + +@pytest.mark.parametrize("mode", ["bridge", "direct-connect"]) +@pytest.mark.parametrize( + "code,reason", [(4, "k1-wifi-network-not-found"), (6, "k1-wifi-credentials-required")] +) +def test_reviewed_station_reply_retains_raw_facts_and_retry_fence( + mode: str, code: int, reason: str +) -> None: + error = station_error(code) + before = deepcopy(error) + assert ( + reviewed_station_failure_code(error, firmware_version="3.0.2", connection_mode=mode) + == reason + ) + assert error == before + + +@pytest.mark.parametrize( + "field,value", + [ + ("code", "RuntimeError"), + ("operation_stage", "baseline-read"), + ("device_write_attempted", False), + ("device_write_confirmed", True), + ("resolved_write_mode", "without_response"), + ("frame_length", 100), + ("ble_att_error_code", 14), + ("ble_att_error_code", "4"), + ("ble_att_error_code", {}), + ], +) +def test_unrelated_transport_failures_are_not_wifi_diagnoses(field: str, value: object) -> None: + error = {**station_error(), field: value} + assert ( + reviewed_station_failure_code(error, firmware_version="3.0.2", connection_mode="bridge") + is None + ) + + +@pytest.mark.parametrize( + "firmware,mode", [("3.0.1", "bridge"), ("unknown", "bridge"), ("3.0.2", "quick-connect")] +) +def test_other_firmware_and_quick_connect_do_not_inherit_station_codes( + firmware: str, mode: str +) -> None: + assert ( + reviewed_station_failure_code( + station_error(), firmware_version=firmware, connection_mode=mode + ) + is None + ) diff --git a/tests/test_node_k1_bridge.py b/tests/test_node_k1_bridge.py new file mode 100644 index 0000000..680fe8a --- /dev/null +++ b/tests/test_node_k1_bridge.py @@ -0,0 +1,204 @@ +import asyncio +import copy +import json +import secrets +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot + +from k1link.device_plugins.xgrids_k1.linux_host import nm_fields, route_fields +from k1link.device_plugins.xgrids_k1.node_bridge import NodeBridge +from k1link.device_plugins.xgrids_k1.node_sensor import NodeK1Sensor, project_sensor +from k1link.viewer.node_rerun import NodeRerunHub + + +def state(): + return { + "snapshot_runtime_id": "runtime-one", + "snapshot_revision": 1, + "devices": [ + {"device_id": "AA:BB:CC:DD:EE:FF", "name": "K1-test", "likely_k1": True}, + {"device_id": "unrelated", "name": "Other", "likely_k1": False}, + ], + "ble_discovery_generation": 1, + "desired_connection_mode_revision": 0, + "active_connection_mode": "bridge", + "connection_lifecycle": {"connection_ready": True, "ready_to_start": True}, + "selected_device_id": "AA:BB:CC:DD:EE:FF", + "source_mode": "idle", + "device_session": { + "device_id": "synthetic-k1", + "device_session_id": "session-test", + "opened_at": datetime.now(UTC).isoformat(), + }, + "application_control_session": { + "session_generation": 1, + "state_revision": 1, + "state": "connection-ready", + }, + } + + +class Facade: + def __init__(self): + self.current = state() + self.actions = [] + + async def invoke(self, request): + self.actions.append((request.action_id, copy.deepcopy(request.parameters))) + if request.action_id == "application-control.workspace.enter": + self.current["application_control_session"]["state"] = "workspace-ready" + elif request.action_id == "acquisition.prepare": + self.current["acquisition"] = { + "acquisition_id": "acquisition-test", + "state": "prepared", + "state_revision": 1, + } + self.current["application_control_session"]["state"] = "project-ready" + elif request.action_id == "acquisition.start": + self.current["application_control_session"]["state"] = "initializing" + self.current["acquisition"]["state"] = "running" + self.current["source_mode"] = "live" + return copy.deepcopy(self.current) + + +def bridge(): + result = NodeBridge(Path.cwd(), service=object()) + result.facade = Facade() + return result + + +def test_node_bridge_forces_reviewed_bridge_and_clears_input_secret(): + async def run(): + device = bridge() + command = { + "operation_id": "op_" + "a" * 32, + "runtime_id": "runtime-one", + "deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(), + "action": "connect", + "parameters": { + "device_id": "AA:BB:CC:DD:EE:FF", + "discovery_generation": 1, + "mode_revision": 0, + "ssid": "test-network", + "password": secrets.token_urlsafe(24), + "allow_host_wifi_switch": True, + }, + } + secret = command["parameters"]["password"] + output = await device.execute(command) + payload = next(v for a, v in device.facade.actions if a == "network.provision") + assert payload["connection_mode"] == "bridge" + assert payload["allow_host_wifi_switch"] is False + assert payload["password"] == secret + assert "password" not in command["parameters"] + assert secret not in json.dumps(output) + assert [v["id"] for v in output["candidates"]] == ["AA:BB:CC:DD:EE:FF"] + + asyncio.run(run()) + + +def test_stale_discovery_prevents_provisioning(): + async def run(): + device = bridge() + with pytest.raises(ValueError): + await device.execute( + { + "operation_id": "op_" + "a" * 32, + "runtime_id": "runtime-one", + "deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(), + "action": "connect", + "parameters": { + "device_id": "AA:BB:CC:DD:EE:FF", + "discovery_generation": 0, + "mode_revision": 0, + }, + } + ) + assert all(action != "network.provision" for action, _ in device.facade.actions) + + asyncio.run(run()) + + +def test_sensor_projection_binds_native_sdk_to_board(): + value = project_sensor(state(), "node-test") + snapshot = DeviceSessionSnapshot.model_validate(value["snapshot"]) + assert snapshot.context.execution.node_id == "node-test" + assert snapshot.context.device.device_id == value["id"] + assert value["kind"] == "k1" + + +def test_one_start_intent_preserves_canonical_enter_prepare_start_sequence(): + async def run(): + device = bridge() + sensor = NodeK1Sensor(device, None) + item = project_sensor(state(), "node-test") + command = { + "operation_id": "op_" + "a" * 32, + "action_id": "start", + "session": {"device_id": item["id"], "session_id": "session-test"}, + "deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(), + "parameters": { + "operator_confirmed": True, + "control_generation": 1, + "acquisition_id": None, + }, + } + result = await sensor.execute(command, "node-test") + actions = [a for a, _ in device.facade.actions if a != "state.read"] + assert actions == [ + "application-control.workspace.enter", + "acquisition.prepare", + "acquisition.start", + ] + assert result["snapshot"]["acquisition"] == "streaming" + command["parameters"]["control_generation"] = 2 + with pytest.raises(ValueError): + await sensor.execute(command, "node-test") + assert len([a for a, _ in device.facade.actions if a == "acquisition.start"]) == 1 + + asyncio.run(run()) + + +def test_networkmanager_ssids_are_not_split_at_escaped_colons(): + assert nm_fields(r"field\:network\\name:88:WPA2") == ["field:network\\name", "88", "WPA2"] + + +def test_linux_kernel_route_is_matched_route_not_resolved_host(monkeypatch): + from k1link.device_plugins.xgrids_k1 import linux_host + from k1link.device_plugins.xgrids_k1.facade import _classify_host_route + + calls = [] + + def run(args): + calls.append(args) + return '[{"dst":"default","dev":"wlp2s0","gateway":"192.168.1.1"}]' + + monkeypatch.setattr(linux_host, "_run", run) + result = route_fields("192.168.2.7") + assert calls[0] == ["ip", "-j", "route", "get", "192.168.2.7", "fibmatch"] + assert _classify_host_route(result["interface"], result["destination"])[0] == "default" + assert _classify_host_route("wlp2s0", "192.168.1.0/24")[0] == "direct" + assert _classify_host_route("tailscale0", "192.168.1.7")[0] == "tunnel" + + +def test_native_node_rrd_opens_no_grpc_listener(monkeypatch): + from k1link.viewer import rerun_bridge + + def forbidden(*_args, **_kwargs): + raise AssertionError("Node must not expose a gRPC listener") + + monkeypatch.setattr(rerun_bridge, "_select_available_grpc_port", forbidden) + hub = NodeRerunHub() + publisher = hub.create() + publisher.begin_session() + subscriber = hub.subscribe() + try: + data = subscriber.read() + assert data[:4] == b"RRF2" + finally: + subscriber.close() + subscriber.thread.join(6) + publisher.close() diff --git a/tests/test_node_media.py b/tests/test_node_media.py new file mode 100644 index 0000000..b125df8 --- /dev/null +++ b/tests/test_node_media.py @@ -0,0 +1,102 @@ +import asyncio +import queue + +import pytest +from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription + +from k1link.viewer.node_media import NodeMediaPeers, admit_sdp + +SDP_HEADER = "v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" + + +@pytest.mark.parametrize( + "address,kind", [("8.8.8.8", "host"), ("192.168.1.2", "relay"), ("::1", "host")] +) +def test_media_rejects_non_private_or_relay_candidates(address, kind): + with pytest.raises(ValueError): + admit_sdp(SDP_HEADER + f"a=candidate:1 1 udp 1 {address} 12345 typ {kind}\r\n") + + +def test_media_accepts_paired_lan_and_tailnet_candidates(): + for address in ("192.168.1.2", "100.80.6.113", "peer.local"): + admit_sdp(SDP_HEADER + f"a=candidate:1 1 udp 1 {address} 12345 typ host\r\n") + + +def test_failed_media_offer_retires_peer_without_camera_channel(monkeypatch): + async def reject(*_): + raise ValueError("Invalid remote description") + + monkeypatch.setattr(RTCPeerConnection, "setRemoteDescription", reject) + + async def run(): + class Camera: + def snapshot(self): + return {} + + peers = NodeMediaPeers(None, Camera()) + try: + with pytest.raises(ValueError): + await peers.offer({"sdp": SDP_HEADER}) + assert peers.items == {} + finally: + await peers.close_all() + + asyncio.run(run()) + + +def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch): + """One bounded loopback peer, no STUN/TURN, device or external network.""" + import aioice.ice + + class Subscription: + def __init__(self): + self.output = queue.Queue() + self.output.put(b"RRF2-transport-fixture") + + def read(self): + try: + return self.output.get(timeout=0.1) + except queue.Empty: + return b"" + + def close(self): + pass + + class Hub: + def subscribe(self): + return Subscription() + + class Camera: + def snapshot(self): + return {"generation": None} + + async def run(): + peers = NodeMediaPeers(Hub(), Camera()) + monkeypatch.setattr(aioice.ice, "get_host_addresses", lambda **_: ["127.0.0.1"]) + client = RTCPeerConnection(RTCConfiguration(iceServers=[])) + channel = client.createDataChannel("rrd", ordered=True) + client.createDataChannel("camera", ordered=True) + received = asyncio.Event() + payloads = [] + + @channel.on("message") + def message(data): + payloads.append(data) + received.set() + + try: + await client.setLocalDescription(await client.createOffer()) + answer = await peers.offer({"sdp": client.localDescription.sdp}) + await client.setRemoteDescription( + RTCSessionDescription(sdp=answer["sdp"], type="answer") + ) + await asyncio.wait_for(received.wait(), timeout=8) + assert payloads == [b"RRF2-transport-fixture"] + assert answer["peer_id"] in peers.items + assert channel.readyState == "open" + finally: + await client.close() + await peers.close_all() + assert not peers.items + + asyncio.run(run()) diff --git a/tests/test_viewer_diagnostics_api.py b/tests/test_viewer_diagnostics_api.py index 8ba6fd9..1ea6de0 100644 --- a/tests/test_viewer_diagnostics_api.py +++ b/tests/test_viewer_diagnostics_api.py @@ -42,6 +42,29 @@ def _request(*, ui_build_id: str | None = None) -> Request: return Request({"type": "http", "method": "GET", "path": "/", "headers": headers}) +def test_scanner_exception_keeps_location_without_private_message_or_locals( + tmp_path: Path, +) -> None: + target = configure_scanner_diagnostics(tmp_path / "logs") + logger = logging.getLogger(f"{SCANNER_LOGGER_NAME}.test") + private_detail = "synthetic-private-runtime-detail" + try: + raise ValueError(private_detail) + except ValueError: + logger.exception("Camera activation failed") + for handler in logging.getLogger(SCANNER_LOGGER_NAME).handlers: + handler.flush() + serialized = target.read_text(encoding="utf-8").splitlines()[-1] + document = json.loads(serialized) + assert document["exception_type"] == "ValueError" + assert document["exception_site"].startswith("test_viewer_diagnostics_api.py:") + assert document["exception_site"].endswith( + ":test_scanner_exception_keeps_location_without_private_message_or_locals" + ) + assert private_detail not in serialized + assert str(Path(__file__).parent) not in serialized + + def test_private_scanner_diagnostics_are_durable_structured_and_bounded( tmp_path: Path, ) -> None: @@ -67,6 +90,19 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded( "device_write_confirmed": False, "ble_att_error_code": 4, "ble_att_error_name": "INVALID_PDU", + "resolved_write_mode": "with_response", + "max_write_without_response_size": 253, + "mtu_size": 256, + "frame_length": 99, + "write_characteristic_properties": ["read", "write"], + "scan_elapsed_ms": 7300, + "scanner_start_ms": 31, + "initial_window_ms": 6000, + "first_candidate_ms": 7240, + "scan_extended": True, + "candidate_count": 4, + "likely_k1_candidate_count": 1, + "discovery_generation": 2, "helper_stage": "compile", "helper_elapsed_ms": 34720, "camera_source_id": "sensor.camera.right", @@ -126,6 +162,19 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded( assert document["device_write_confirmed"] is False assert document["ble_att_error_code"] == 4 assert document["ble_att_error_name"] == "INVALID_PDU" + assert document["resolved_write_mode"] == "with_response" + assert document["max_write_without_response_size"] == 253 + assert document["mtu_size"] == 256 + assert document["frame_length"] == 99 + assert document["write_characteristic_properties"] == ["read", "write"] + assert document["scan_elapsed_ms"] == 7300 + assert document["scanner_start_ms"] == 31 + assert document["initial_window_ms"] == 6000 + assert document["first_candidate_ms"] == 7240 + assert document["scan_extended"] is True + assert document["candidate_count"] == 4 + assert document["likely_k1_candidate_count"] == 1 + assert document["discovery_generation"] == 2 assert document["helper_stage"] == "compile" assert document["helper_elapsed_ms"] == 34720 assert document["camera_source_id"] == "sensor.camera.right" diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py index 02425a3..2222a38 100644 --- a/tests/test_xgrids_acquisition_lifecycle.py +++ b/tests/test_xgrids_acquisition_lifecycle.py @@ -5256,11 +5256,14 @@ def test_ble_scan_operation_id_is_exactly_once_and_request_bound( if on_admitted is not None: on_admitted() transport_calls += 1 - return _ble_scan_result("exactly-once-device") + return { + **_ble_scan_result("exactly-once-device"), + "discovery_timing": {"scan_elapsed_ms": 7100, "scan_extended": True}, + } monkeypatch.setattr(facade_module, "scan", successful_scan) operation_id = "op-00000000-0000-4000-8000-000000000001" - request = BleScanRequest(duration_seconds=6.0, operation_id=operation_id) + request = BleScanRequest(operation_id=operation_id) first = asyncio.run(service.scan_ble(request)) repeated = asyncio.run(service.scan_ble(request)) @@ -5271,8 +5274,10 @@ def test_ble_scan_operation_id_is_exactly_once_and_request_bound( assert first["last_operation"]["result"] == { "candidate_count": 1, "likely_k1_candidate_count": 1, - "duration_seconds": 6.0, + "duration_seconds": 20.0, "discovery_generation": 1, + "scan_elapsed_ms": 7100, + "scan_extended": True, } assert repeated["last_operation"]["operation_id"] == operation_id with pytest.raises(ValueError, match="different request"): @@ -21315,6 +21320,61 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host( assert "lab-network" not in caplog.text +@pytest.mark.parametrize( + ("att_code", "public_code"), + [(4, "k1-wifi-network-not-found"), (6, "k1-wifi-credentials-required")], +) +def test_station_reply_preserves_ambiguity_and_explains_wifi_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + att_code: int, + public_code: str, +) -> None: + from bleak.exc import BleakGATTProtocolError + + service, _ = service_with_fake_runtime(tmp_path) + _set_scanned_devices(service, [{"device_id": "k1-a"}]) + calls = 0 + + async def rejected_write( + *_: object, on_write_dispatch: Any = None, **__: object, + ) -> dict[str, Any]: + nonlocal calls + calls += 1 + on_write_dispatch(_wifi_status_read(None, device_id="k1-a")["status"], "with_response") + exc = BleakGATTProtocolError(att_code) + exc.operation_stage = "gatt-write" # type: ignore[attr-defined] + exc.device_write_attempted = True # type: ignore[attr-defined] + exc.device_write_confirmed = False # type: ignore[attr-defined] + exc.att_error_code = att_code # type: ignore[attr-defined] + exc.att_error_name = exc.code.name # type: ignore[attr-defined] + exc.resolved_write_mode = "with_response" # type: ignore[attr-defined] + exc.frame_length = 99 # type: ignore[attr-defined] + raise exc + + monkeypatch.setattr(facade_module, "provision_wifi_once", rejected_write) + with pytest.raises(BleakGATTProtocolError): + asyncio.run(service.connect(_connect_request( + device_id="k1-a", ssid="lab-network", + password=SecretStr(PRIMARY_TEST_CREDENTIAL), + compatibility_attestation=ATTESTATION, + idempotency_key="explicit-station-rejection", + ))) + state = service.state() + operation = next(item for item in state["operations"] if item["action"] == "network.provision") + error = operation["error"] + assert calls == 1 + assert operation["status"] == "failed" + assert error["code"] == public_code + assert error["transport_error_code"] == "BleakGATTProtocolError" + assert error["ble_att_error_code"] == att_code + assert error["safe_to_retry"] is False + assert error["device_write_confirmed"] is False + assert error["side_effect_status"] == "unknown" + assert state["connection_attempt"]["public_error_code"] == public_code + assert state["network_mutation_ledger"]["status"] == "unresolved" + + def test_ambiguous_ble_write_is_audit_only_for_next_explicit_intent( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/test_xgrids_ble_runtime_arbiter.py b/tests/test_xgrids_ble_runtime_arbiter.py index 8660a9f..9c67158 100644 --- a/tests/test_xgrids_ble_runtime_arbiter.py +++ b/tests/test_xgrids_ble_runtime_arbiter.py @@ -181,7 +181,7 @@ def test_scan_lease_rejects_wifi_entrypoints_process_wide( resolution_attempted = True raise AssertionError("busy contender must not touch CoreBluetooth") - monkeypatch.setattr(scanner_module.BleakScanner, "discover", blocked_discover) + monkeypatch.setattr(scanner_module, "_discover_k1_advertisements", blocked_discover) monkeypatch.setattr( wifi_module.BleakScanner, "find_device_by_address", diff --git a/tests/test_xgrids_camera_gateway.py b/tests/test_xgrids_camera_gateway.py index 7ed0055..77c89a4 100644 --- a/tests/test_xgrids_camera_gateway.py +++ b/tests/test_xgrids_camera_gateway.py @@ -299,6 +299,99 @@ def test_camera_selection_is_exclusive_and_hides_device_transport( gateway.close() +@pytest.mark.parametrize("entrypoint", ["acquisition", "selected-preview"]) +def test_camera_records_in_configured_evidence_outside_source_checkout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, entrypoint: str, +) -> None: + checkout = tmp_path / "checkout" + checkout.mkdir() + evidence = tmp_path / "durable" / "sessions" + session = evidence / "synthetic-camera-session" + session.mkdir(parents=True) + monkeypatch.setenv("MISSIONCORE_FFMPEG_BINARY", str(_fake_ffmpeg(tmp_path))) + gateway = XgridsK1CameraGateway(checkout, XGRIDS_K1_PLUGIN_ID, evidence_root=evidence) + try: + if entrypoint == "acquisition": + gateway.activate_recording_producer( + "sensor.camera.right", "192.168.1.20", session, + pre_prepare_fence=lambda reserve: reserve(), + commit_fence=lambda commit: commit(), + ) + else: + gateway.select("sensor.camera.right", "192.168.1.20") + gateway.start_recording(session) + _wait_until(lambda: gateway.snapshot()["recording"]["media_ready"] is True) + assert gateway.snapshot()["recording"]["session"] == session.name + assert not list(checkout.rglob("*.m4s")) + finally: + gateway.close() + assert any(session.rglob("*.m4s")) + + +@pytest.mark.parametrize("entrypoint", ["acquisition", "selected-preview"]) +@pytest.mark.parametrize("escape", ["sibling", "symlink"]) +def test_camera_rejects_paths_outside_configured_evidence_before_admission( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, entrypoint: str, escape: str, +) -> None: + checkout = tmp_path / "checkout" + checkout.mkdir() + evidence = tmp_path / "evidence" + evidence.mkdir() + outside = checkout / "not-evidence" + outside.mkdir() + session = outside + if escape == "symlink": + session = evidence / "escaped-session" + session.symlink_to(outside, target_is_directory=True) + gateway = XgridsK1CameraGateway(checkout, XGRIDS_K1_PLUGIN_ID, evidence_root=evidence) + + def forbidden(*_args: object, **_kwargs: object) -> bool: + raise AssertionError("An escaped path must not reach authority or FFmpeg") + + monkeypatch.setattr(camera_module.subprocess, "Popen", forbidden) + try: + with pytest.raises(ValueError, match="configured evidence root"): + if entrypoint == "acquisition": + gateway.activate_recording_producer( + "sensor.camera.right", "192.168.1.20", session, + pre_prepare_fence=forbidden, commit_fence=forbidden, + ) + else: + gateway.start_recording(session) + assert gateway.snapshot()["recording"]["active"] is False + finally: + gateway.close() + + +def test_service_camera_uses_the_same_external_evidence_root_as_acquisition( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + checkout = tmp_path / "checkout" + checkout.mkdir() + evidence = tmp_path / "configured-evidence" + session = evidence / "synthetic-session" + session.mkdir(parents=True) + monkeypatch.setenv("MISSIONCORE_EVIDENCE_DIR", str(evidence)) + service = XgridsK1CompatibilityService(checkout) + authority_entered = False + + def deny_authority(_reserve: Callable[[], bool]) -> bool: + nonlocal authority_entered + authority_entered = True + return False + + try: + with pytest.raises(ValueError, match="authority"): + service.camera_preview.activate_recording_producer( + "sensor.camera.right", "192.168.1.20", session, + pre_prepare_fence=deny_authority, commit_fence=lambda _commit: False, + ) + assert authority_entered is True + assert service.camera_preview.snapshot()["active_source_id"] is None + finally: + service.close() + + def test_camera_derived_observer_runs_only_after_durable_archive_commit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/uv.lock b/uv.lock index a1ba119..8b6c463 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,37 @@ version = 1 revision = 3 requires-python = "==3.12.*" +[[package]] +name = "aioice" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "ifaddr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/04/df7286233f468e19e9bedff023b6b246182f0b2ccb04ceeb69b2994021c6/aioice-0.10.2.tar.gz", hash = "sha256:bf236c6829ee33c8e540535d31cd5a066b531cb56de2be94c46be76d68b1a806", size = 44307, upload-time = "2025-11-28T15:56:48.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e3/0d23b1f930c17d371ce1ec36ee529f22fd19ebc2a07fe3418e3d1d884ce2/aioice-0.10.2-py3-none-any.whl", hash = "sha256:14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf", size = 24875, upload-time = "2025-11-28T15:56:47.847Z" }, +] + +[[package]] +name = "aiortc" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aioice" }, + { name = "av" }, + { name = "cryptography" }, + { name = "google-crc32c" }, + { name = "pyee" }, + { name = "pylibsrtp" }, + { name = "pyopenssl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/9c/4e027bfe0195de0442da301e2389329496745d40ae44d2d7c4571c4290ce/aiortc-1.14.0.tar.gz", hash = "sha256:adc8a67ace10a085721e588e06a00358ed8eaf5f6b62f0a95358ff45628dd762", size = 1180864, upload-time = "2025-10-13T21:40:37.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ab/31646a49209568cde3b97eeade0d28bb78b400e6645c56422c101df68932/aiortc-1.14.0-py3-none-any.whl", hash = "sha256:4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e", size = 93183, upload-time = "2025-10-13T21:40:36.59Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -42,6 +73,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "av" +version = "16.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/cd/3a83ffbc3cc25b39721d174487fb0d51a76582f4a1703f98e46170ce83d4/av-16.1.0.tar.gz", hash = "sha256:a094b4fd87a3721dacf02794d3d2c82b8d712c85b9534437e82a8a978c175ffd", size = 4285203, upload-time = "2026-01-11T07:31:33.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/84/2535f55edcd426cebec02eb37b811b1b0c163f26b8d3f53b059e2ec32665/av-16.1.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:640f57b93f927fba8689f6966c956737ee95388a91bd0b8c8b5e0481f73513d6", size = 26945785, upload-time = "2026-01-09T20:18:34.486Z" }, + { url = "https://files.pythonhosted.org/packages/b6/17/ffb940c9e490bf42e86db4db1ff426ee1559cd355a69609ec1efe4d3a9eb/av-16.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ae3fb658eec00852ebd7412fdc141f17f3ddce8afee2d2e1cf366263ad2a3b35", size = 21481147, upload-time = "2026-01-09T20:18:36.716Z" }, + { url = "https://files.pythonhosted.org/packages/15/c1/e0d58003d2d83c3921887d5c8c9b8f5f7de9b58dc2194356a2656a45cfdc/av-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ee558d9c02a142eebcbe55578a6d817fedfde42ff5676275504e16d07a7f86", size = 39517197, upload-time = "2026-01-11T09:57:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/32/77/787797b43475d1b90626af76f80bfb0c12cfec5e11eafcfc4151b8c80218/av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", size = 41174337, upload-time = "2026-01-11T09:57:35.792Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/d90df7f1e3b97fc5554cf45076df5045f1e0a6adf13899e10121229b826c/av-16.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cf065f9d438e1921dc31fc7aa045790b58aee71736897866420d80b5450f62a", size = 40817720, upload-time = "2026-01-11T09:57:39.039Z" }, + { url = "https://files.pythonhosted.org/packages/80/6f/13c3a35f9dbcebafd03fe0c4cbd075d71ac8968ec849a3cfce406c35a9d2/av-16.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a345877a9d3cc0f08e2bc4ec163ee83176864b92587afb9d08dff50f37a9a829", size = 42267396, upload-time = "2026-01-11T09:57:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" }, +] + [[package]] name = "bleak" version = "3.0.2" @@ -172,6 +218,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "fastapi" version = "0.139.0" @@ -208,6 +263,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/e1/8ccb4a985c5baf82947e48cff18483c2125ea11e55e8a28950730ab6c065/foxglove_sdk-0.25.3-cp310-abi3-win_amd64.whl", hash = "sha256:ac881ae307ba432766e6141d9098ced2059838226d225fbeb20de1c57d782a9f", size = 16496466, upload-time = "2026-06-25T00:24:24.906Z" }, ] +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, +] + [[package]] name = "grpcio" version = "1.83.1" @@ -290,6 +358,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -421,6 +498,9 @@ dependencies = [ ] [package.optional-dependencies] +node-device-media = [ + { name = "aiortc" }, +] perception-stream = [ { name = "grpcio" }, ] @@ -435,6 +515,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiortc", marker = "extra == 'node-device-media'", specifier = "==1.14.0" }, { name = "bleak", specifier = "==3.0.2" }, { name = "cryptography", specifier = ">=46,<47" }, { name = "fastapi", specifier = ">=0.116,<1" }, @@ -451,7 +532,7 @@ requires-dist = [ { name = "typer", specifier = ">=0.15,<1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" }, ] -provides-extras = ["perception-stream"] +provides-extras = ["perception-stream", "node-device-media"] [package.metadata.requires-dev] dev = [ @@ -618,6 +699,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] +[[package]] +name = "pyee" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/f1/fdedc2c75c3e31a330659c85e5793bb18b3397981fbf0844c6dee5b18926/pyee-14.0.0.tar.gz", hash = "sha256:76dd0f4314ecd27f02dc73589dea7fd3853f9b6176d8ef9b122860657e3602de", size = 98760, upload-time = "2026-08-13T04:26:11.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/12/5347938b1f9a6453f0dbdfcc3e2388a1320ef9b9ec17fbefbc4ab647ea98/pyee-14.0.0-py3-none-any.whl", hash = "sha256:3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d", size = 15553, upload-time = "2026-08-13T04:26:09.916Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -627,6 +720,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pylibsrtp" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/a6/6e532bec974aaecbf9fe4e12538489fb1c28456e65088a50f305aeab9f89/pylibsrtp-1.0.0.tar.gz", hash = "sha256:b39dff075b263a8ded5377f2490c60d2af452c9f06c4d061c7a2b640612b34d4", size = 10858, upload-time = "2025-10-13T16:12:31.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/af/89e61a62fa3567f1b7883feb4d19e19564066c2fcd41c37e08d317b51881/pylibsrtp-1.0.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:822c30ea9e759b333dc1f56ceac778707c51546e97eb874de98d7d378c000122", size = 1865017, upload-time = "2025-10-13T16:12:15.62Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0e/8d215484a9877adcf2459a8b28165fc89668b034565277fd55d666edd247/pylibsrtp-1.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:aaad74e5c8cbc1c32056c3767fea494c1e62b3aea2c908eda2a1051389fdad76", size = 2182739, upload-time = "2025-10-13T16:12:17.121Z" }, + { url = "https://files.pythonhosted.org/packages/57/3f/76a841978877ae13eac0d4af412c13bbd5d83b3df2c1f5f2175f2e0f68e5/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9209b86e662ebbd17c8a9e8549ba57eca92a3e87fb5ba8c0e27b8c43cd08a767", size = 2732922, upload-time = "2025-10-13T16:12:18.348Z" }, + { url = "https://files.pythonhosted.org/packages/0e/14/cf5d2a98a66fdfe258f6b036cda570f704a644fa861d7883a34bc359501e/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", size = 2434534, upload-time = "2025-10-13T16:12:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/bd/08/a3f6e86c04562f7dce6717cd2206a0f84ca85c5e38121d998e0e330194c3/pylibsrtp-1.0.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:81fb8879c2e522021a7cbd3f4bda1b37c192e1af939dfda3ff95b4723b329663", size = 2345818, upload-time = "2025-10-13T16:12:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d5/130c2b5b4b51df5631684069c6f0a6761c59d096a33d21503ac207cf0e47/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4ddb562e443cf2e557ea2dfaeef0d7e6b90e96dd38eb079b4ab2c8e34a79f50b", size = 2774490, upload-time = "2025-10-13T16:12:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/715a453bfee3bea92a243888ad359094a7727cc6d393f21281320fe7798c/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f02e616c9dfab2b03b32d8cc7b748f9d91814c0211086f987629a60f05f6e2cc", size = 2372603, upload-time = "2025-10-13T16:12:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/e3/56/52fa74294254e1f53a4ff170ee2006e57886cf4bb3db46a02b4f09e1d99f/pylibsrtp-1.0.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c134fa09e7b80a5b7fed626230c5bc257fd771bd6978e754343e7a61d96bc7e6", size = 2451269, upload-time = "2025-10-13T16:12:25.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/51/2e9b34f484cbdd3bac999bf1f48b696d7389433e900639089e8fc4e0da0d/pylibsrtp-1.0.0-cp310-abi3-win32.whl", hash = "sha256:bae377c3b402b17b9bbfbfe2534c2edba17aa13bea4c64ce440caacbe0858b55", size = 1247503, upload-time = "2025-10-13T16:12:27.39Z" }, + { url = "https://files.pythonhosted.org/packages/c3/70/43db21af194580aba2d9a6d4c7bd8c1a6e887fa52cd810b88f89096ecad2/pylibsrtp-1.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:8d6527c4a78a39a8d397f8862a8b7cdad4701ee866faf9de4ab8c70be61fd34d", size = 1601659, upload-time = "2025-10-13T16:12:29.037Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ec/6e02b2561d056ea5b33046e3cad21238e6a9097b97d6ccc0fbe52b50c858/pylibsrtp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:2696bdb2180d53ac55d0eb7b58048a2aa30cd4836dd2ca683669889137a94d2a", size = 1159246, upload-time = "2025-10-13T16:12:30.285Z" }, +] + [[package]] name = "pyobjc-core" version = "12.2.1" @@ -674,6 +789,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/8f/42cfa987c07a2b5ce8c236a42b0fb388b8807dac72c25e004cd4905ea9a3/pyobjc_framework_libdispatch-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8f41b5021ff70bc51220a79b41ebd1eacb55fe3ceb67448594f30e491a2c42a5", size = 15656, upload-time = "2026-06-19T16:12:50.361Z" }, ] +[[package]] +name = "pyopenssl" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, +] + [[package]] name = "pytest" version = "8.4.2"