From e6f7648b84305c3f8e4714287d5a4374ea25feaa Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 16 Jul 2026 19:44:06 +0300 Subject: [PATCH] feat(device-plugins): add profiled K1 lifecycle and canonical data plane --- apps/control-station/package-lock.json | 2 +- apps/control-station/package.json | 3 +- .../src/core/device-plugins/contracts.ts | 50 +- .../src/core/device-plugins/manifestParser.ts | 205 ++- .../src/core/device-plugins/registry.ts | 45 +- .../src/core/runtime/contracts.ts | 29 + .../xgrids-k1/XgridsK1Connection.tsx | 226 +++- .../src/device-plugins/xgrids-k1/api.ts | 179 ++- .../src/device-plugins/xgrids-k1/lifecycle.ts | 173 +++ .../src/device-plugins/xgrids-k1/manifest.ts | 16 +- .../xgrids-k1/runtimeContext.tsx | 74 +- .../xgrids-k1/useXgridsK1Runtime.ts | 109 +- .../test/devicePluginContracts.test.mjs | 358 +++++ plugins/xgrids-k1/plugin.manifest.json | 22 +- plugins/xgrids-k1/profile_loader.py | 486 +++++++ plugins/xgrids-k1/profiles/README.md | 70 + .../profiles/fw-3.0.2/direct-lan.v1.json | 406 ++++++ src/k1link/analyze/stream_summary.py | 3 +- src/k1link/ble/wifi_provisioning.py | 18 +- src/k1link/data_plane/__init__.py | 26 + src/k1link/data_plane/views.py | 128 ++ src/k1link/mqtt/capture.py | 2 +- src/k1link/protocol/normalizer.py | 182 +++ src/k1link/viewer/foxglove_bridge.py | 131 +- src/k1link/viewer/metrics.py | 134 ++ src/k1link/viewer/rerun_bridge.py | 133 +- src/k1link/viewer/runtime.py | 59 +- src/k1link/web/app.py | 17 +- src/k1link/web/device_lifecycle.py | 391 ++++++ src/k1link/web/plugin_catalog.py | 188 ++- src/k1link/web/xgrids_k1_facade.py | 1146 ++++++++++++++++- tests/test_ble_scanner.py | 6 +- tests/test_canonical_pipeline.py | 314 +++++ tests/test_device_lifecycle.py | 164 +++ tests/test_frontend_plugin_boundaries.py | 43 + tests/test_plugin_catalog.py | 206 ++- tests/test_plugin_runtime.py | 30 +- tests/test_rerun_bridge.py | 38 +- tests/test_stream_decoders.py | 14 +- tests/test_stream_summary.py | 10 +- tests/test_web_console.py | 4 +- tests/test_web_validation_security.py | 91 ++ tests/test_wifi_provisioning.py | 17 +- tests/test_xgrids_acquisition_lifecycle.py | 697 ++++++++++ tests/test_xgrids_compatibility_profile.py | 196 +++ 45 files changed, 6401 insertions(+), 440 deletions(-) create mode 100644 apps/control-station/src/device-plugins/xgrids-k1/lifecycle.ts create mode 100644 apps/control-station/test/devicePluginContracts.test.mjs create mode 100644 plugins/xgrids-k1/profile_loader.py create mode 100644 plugins/xgrids-k1/profiles/README.md create mode 100644 plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json create mode 100644 src/k1link/data_plane/__init__.py create mode 100644 src/k1link/data_plane/views.py create mode 100644 src/k1link/protocol/normalizer.py create mode 100644 src/k1link/viewer/metrics.py create mode 100644 src/k1link/web/device_lifecycle.py create mode 100644 tests/test_canonical_pipeline.py create mode 100644 tests/test_device_lifecycle.py create mode 100644 tests/test_web_validation_security.py create mode 100644 tests/test_xgrids_acquisition_lifecycle.py create mode 100644 tests/test_xgrids_compatibility_profile.py diff --git a/apps/control-station/package-lock.json b/apps/control-station/package-lock.json index 9f4cb6c..bf469d0 100644 --- a/apps/control-station/package-lock.json +++ b/apps/control-station/package-lock.json @@ -25,7 +25,7 @@ "vite-plugin-wasm": "^3.6.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": { diff --git a/apps/control-station/package.json b/apps/control-station/package.json index fc1c2c4..32220e3 100644 --- a/apps/control-station/package.json +++ b/apps/control-station/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "test:unit": "node --test test/*.test.mjs", "typecheck": "tsc -b --pretty false" }, "dependencies": { @@ -27,6 +28,6 @@ "vite-plugin-wasm": "^3.6.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } } diff --git a/apps/control-station/src/core/device-plugins/contracts.ts b/apps/control-station/src/core/device-plugins/contracts.ts index 9d3f7ec..63989dc 100644 --- a/apps/control-station/src/core/device-plugins/contracts.ts +++ b/apps/control-station/src/core/device-plugins/contracts.ts @@ -1,8 +1,17 @@ import type { ComponentType, ReactNode } from "react"; -export const DEVICE_PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1" as const; +export const DEVICE_PLUGIN_API_VERSION_V1ALPHA1 = "missioncore.nodedc/v1alpha1" as const; +export const DEVICE_PLUGIN_API_VERSION_V1ALPHA2 = "missioncore.nodedc/v1alpha2" as const; +export const DEVICE_PLUGIN_API_VERSION = DEVICE_PLUGIN_API_VERSION_V1ALPHA2; +export const SUPPORTED_DEVICE_PLUGIN_API_VERSIONS = Object.freeze([ + DEVICE_PLUGIN_API_VERSION_V1ALPHA1, + DEVICE_PLUGIN_API_VERSION_V1ALPHA2, +] as const); export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const; +export type DevicePluginApiVersion = + (typeof SUPPORTED_DEVICE_PLUGIN_API_VERSIONS)[number]; + export interface DeviceCapability { id: string; label: string; @@ -28,14 +37,24 @@ export interface DeviceModelDefinition { }; } -export interface DevicePluginManifest { - apiVersion: typeof DEVICE_PLUGIN_API_VERSION; +export interface DeviceCompatibilityProfileDefinition { + profileId: string; + path: string; + modelId: string; +} + +interface DevicePluginManifestBase { + apiVersion: DevicePluginApiVersion; kind: "DevicePlugin"; metadata: { id: string; version: string; displayName: string; }; +} + +export interface DevicePluginManifestV1Alpha1 extends DevicePluginManifestBase { + apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA1; spec: { hostApiRange: "v1alpha1"; runtime: { @@ -48,6 +67,31 @@ export interface DevicePluginManifest { }; } +export interface DevicePluginManifestV1Alpha2 extends DevicePluginManifestBase { + apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA2; + spec: { + hostApiRange: "v1alpha2"; + runtime: { + backendEntrypoint: string; + isolation: "transitional-in-process"; + }; + permissions: readonly string[]; + actions: readonly DevicePluginActionDefinition[]; + models: readonly DeviceModelDefinition[]; + compatibilityProfiles: readonly DeviceCompatibilityProfileDefinition[]; + }; +} + +export type DevicePluginManifest = + | DevicePluginManifestV1Alpha1 + | DevicePluginManifestV1Alpha2; + +export function isDevicePluginManifestV1Alpha2( + manifest: DevicePluginManifest, +): manifest is DevicePluginManifestV1Alpha2 { + return manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2; +} + export interface DevicePluginHostActions { openSpatialScene: () => void; } diff --git a/apps/control-station/src/core/device-plugins/manifestParser.ts b/apps/control-station/src/core/device-plugins/manifestParser.ts index 2f28e14..a941ea9 100644 --- a/apps/control-station/src/core/device-plugins/manifestParser.ts +++ b/apps/control-station/src/core/device-plugins/manifestParser.ts @@ -1,9 +1,13 @@ import { - DEVICE_PLUGIN_API_VERSION, + DEVICE_PLUGIN_API_VERSION_V1ALPHA1, + DEVICE_PLUGIN_API_VERSION_V1ALPHA2, type DeviceCapability, + type DeviceCompatibilityProfileDefinition, type DeviceModelDefinition, type DevicePluginActionDefinition, type DevicePluginManifest, + type DevicePluginManifestV1Alpha1, + type DevicePluginManifestV1Alpha2, } from "./contracts"; function record(value: unknown, path: string): Record { @@ -36,6 +40,14 @@ function text(value: unknown, path: string, maxLength = 160): string { return value; } +function identifier(value: unknown, path: string): string { + const candidate = text(value, path, 192); + if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(candidate)) { + throw new Error(`Некорректный manifest: ${path} не является идентификатором.`); + } + return candidate; +} + function flag(value: unknown, path: string): boolean { if (typeof value !== "boolean") { throw new Error(`Некорректный manifest: ${path} должен быть boolean.`); @@ -50,25 +62,44 @@ function list(value: unknown, path: string): unknown[] { return value; } -function capability(value: unknown, path: string): DeviceCapability { - const item = record(value, path); - exactKeys(item, path, ["id", "label"]); - return { id: text(item.id, `${path}.id`), label: text(item.label, `${path}.label`) }; +function contractText(value: unknown, path: string, strictIdentifiers: boolean): string { + return strictIdentifiers ? identifier(value, path) : text(value, path); } -function action(value: unknown, path: string): DevicePluginActionDefinition { +function capability( + value: unknown, + path: string, + strictIdentifiers: boolean, +): DeviceCapability { + const item = record(value, path); + exactKeys(item, path, ["id", "label"]); + return { + id: contractText(item.id, `${path}.id`, strictIdentifiers), + label: text(item.label, `${path}.label`), + }; +} + +function action( + value: unknown, + path: string, + strictIdentifiers: boolean, +): DevicePluginActionDefinition { const item = record(value, path); exactKeys(item, path, ["id", "mutating", "secretFields"]); return { - id: text(item.id, `${path}.id`), + id: contractText(item.id, `${path}.id`, strictIdentifiers), mutating: flag(item.mutating, `${path}.mutating`), secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) => - text(field, `${path}.secretFields[${index}]`), + contractText(field, `${path}.secretFields[${index}]`, strictIdentifiers), ), }; } -function model(value: unknown, path: string): DeviceModelDefinition { +function model( + value: unknown, + path: string, + strictIdentifiers: boolean, +): DeviceModelDefinition { const item = record(value, path); exactKeys(item, path, [ "id", @@ -87,14 +118,14 @@ function model(value: unknown, path: string): DeviceModelDefinition { throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`); } return { - id: text(item.id, `${path}.id`), + id: contractText(item.id, `${path}.id`, strictIdentifiers), vendor: text(item.vendor, `${path}.vendor`), displayName: text(item.displayName, `${path}.displayName`), category: text(item.category, `${path}.category`), description: text(item.description, `${path}.description`, 1024), verified: flag(item.verified, `${path}.verified`), capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) => - capability(entry, `${path}.capabilities[${index}]`), + capability(entry, `${path}.capabilities[${index}]`, strictIdentifiers), ), ui: { slot, @@ -103,23 +134,96 @@ function model(value: unknown, path: string): DeviceModelDefinition { }; } +function compatibilityProfile( + value: unknown, + path: string, +): DeviceCompatibilityProfileDefinition { + const item = record(value, path); + exactKeys(item, path, ["profileId", "path", "modelId"]); + const profilePath = text(item.path, `${path}.path`, 512); + const pathSegments = profilePath.split("/"); + if ( + profilePath.startsWith("/") || + profilePath.includes("\\") || + !profilePath.endsWith(".json") || + pathSegments.some((segment) => !segment || segment === "." || segment === "..") + ) { + throw new Error( + `Некорректный manifest: ${path}.path должен быть безопасным относительным JSON-путём.`, + ); + } + return { + profileId: identifier(item.profileId, `${path}.profileId`), + path: profilePath, + modelId: identifier(item.modelId, `${path}.modelId`), + }; +} + +function validateV1Alpha2Profiles( + models: readonly DeviceModelDefinition[], + profiles: readonly DeviceCompatibilityProfileDefinition[], +): void { + if (!profiles.length) { + throw new Error("Manifest v1alpha2 должен объявлять compatibilityProfiles."); + } + const modelIds = new Set(models.map((modelItem) => modelItem.id)); + const profileIds = new Set(); + const profilePaths = new Set(); + const coveredModels = new Set(); + for (const profile of profiles) { + if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) { + throw new Error( + `Manifest v1alpha2 повторяет профиль ${profile.profileId} или его путь.`, + ); + } + profileIds.add(profile.profileId); + profilePaths.add(profile.path); + if (!modelIds.has(profile.modelId)) { + throw new Error( + `Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`, + ); + } + coveredModels.add(profile.modelId); + } + const uncovered = [...modelIds].filter((modelId) => !coveredModels.has(modelId)); + if (uncovered.length) { + throw new Error( + `Manifest v1alpha2 не содержит разрешённого профиля для моделей: ${uncovered.join(", ")}.`, + ); + } +} + export function parseDevicePluginManifest(document: unknown): DevicePluginManifest { const root = record(document, "root"); exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]); - if (root.apiVersion !== DEVICE_PLUGIN_API_VERSION || root.kind !== "DevicePlugin") { + const apiVersion = root.apiVersion; + if ( + (apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA1 && + apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA2) || + root.kind !== "DevicePlugin" + ) { throw new Error("Manifest использует несовместимую версию или kind."); } const metadata = record(root.metadata, "metadata"); exactKeys(metadata, "metadata", ["id", "version", "displayName"]); const spec = record(root.spec, "spec"); - exactKeys(spec, "spec", [ + const specKeys = [ "hostApiRange", "runtime", "permissions", "actions", "models", - ]); - if (spec.hostApiRange !== "v1alpha1") { + ]; + exactKeys( + spec, + "spec", + apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 + ? [...specKeys, "compatibilityProfiles"] + : specKeys, + ); + const expectedHostApiRange = + apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 ? "v1alpha2" : "v1alpha1"; + if (spec.hostApiRange !== expectedHostApiRange) { throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`); } const runtime = record(spec.runtime, "spec.runtime"); @@ -129,45 +233,72 @@ export function parseDevicePluginManifest(document: unknown): DevicePluginManife throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`); } + const strictIdentifiers = apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2; const models = list(spec.models, "spec.models").map((entry, index) => - model(entry, `spec.models[${index}]`), + model(entry, `spec.models[${index}]`, strictIdentifiers), ); - if (models.length !== 1) { + if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 && models.length !== 1) { throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель."); } + if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 && !models.length) { + throw new Error("Manifest v1alpha2 должен объявлять хотя бы одну модель."); + } const version = text(metadata.version, "metadata.version"); if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) { throw new Error(`Некорректный manifest: версия ${version} не является semver.`); } - return { - apiVersion: DEVICE_PLUGIN_API_VERSION, + const common = { kind: "DevicePlugin", metadata: { - id: text(metadata.id, "metadata.id"), + id: contractText(metadata.id, "metadata.id", strictIdentifiers), version, displayName: text(metadata.displayName, "metadata.displayName"), }, - spec: { - hostApiRange: "v1alpha1", - runtime: { - backendEntrypoint: text( - runtime.backendEntrypoint, - "spec.runtime.backendEntrypoint", - 256, - ), - isolation, - }, - permissions: list(spec.permissions, "spec.permissions").map((entry, index) => - text(entry, `spec.permissions[${index}]`), + } as const; + const commonSpec = { + runtime: { + backendEntrypoint: text( + runtime.backendEntrypoint, + "spec.runtime.backendEntrypoint", + 256, ), - actions: list(spec.actions, "spec.actions").map((entry, index) => - action(entry, `spec.actions[${index}]`), - ), - models, + isolation, }, - }; + permissions: list(spec.permissions, "spec.permissions").map((entry, index) => + contractText(entry, `spec.permissions[${index}]`, strictIdentifiers), + ), + actions: list(spec.actions, "spec.actions").map((entry, index) => + action(entry, `spec.actions[${index}]`, strictIdentifiers), + ), + models, + } as const; + + if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1) { + return { + ...common, + apiVersion, + spec: { + ...commonSpec, + hostApiRange: "v1alpha1", + }, + } satisfies DevicePluginManifestV1Alpha1; + } + + const profiles = list(spec.compatibilityProfiles, "spec.compatibilityProfiles").map( + (entry, index) => compatibilityProfile(entry, `spec.compatibilityProfiles[${index}]`), + ); + validateV1Alpha2Profiles(models, profiles); + return { + ...common, + apiVersion, + spec: { + ...commonSpec, + hostApiRange: "v1alpha2", + compatibilityProfiles: profiles, + }, + } satisfies DevicePluginManifestV1Alpha2; } export function requirePluginAction( diff --git a/apps/control-station/src/core/device-plugins/registry.ts b/apps/control-station/src/core/device-plugins/registry.ts index bc577f5..c5aa16c 100644 --- a/apps/control-station/src/core/device-plugins/registry.ts +++ b/apps/control-station/src/core/device-plugins/registry.ts @@ -1,6 +1,8 @@ import { - DEVICE_PLUGIN_API_VERSION, + DEVICE_PLUGIN_API_VERSION_V1ALPHA1, DEVICE_STATE_READ_ACTION_ID, + SUPPORTED_DEVICE_PLUGIN_API_VERSIONS, + isDevicePluginManifestV1Alpha2, type DeviceUiPlugin, type RegisteredDeviceModel, } from "./contracts"; @@ -20,7 +22,7 @@ export function createDevicePluginRegistry( for (const plugin of installedPlugins) { const { manifest } = plugin; - if (manifest.apiVersion !== DEVICE_PLUGIN_API_VERSION) { + if (!SUPPORTED_DEVICE_PLUGIN_API_VERSIONS.includes(manifest.apiVersion)) { throw new Error( `Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`, ); @@ -35,11 +37,17 @@ export function createDevicePluginRegistry( } pluginIds.add(manifest.metadata.id); - if (manifest.spec.models.length !== 1) { + if ( + manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 && + manifest.spec.models.length !== 1 + ) { throw new Error( `Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`, ); } + if (!manifest.spec.models.length) { + throw new Error(`Плагин ${manifest.metadata.id} не объявляет ни одной модели.`); + } const permissions = new Set(manifest.spec.permissions); if (permissions.size !== manifest.spec.permissions.length) { @@ -84,6 +92,37 @@ export function createDevicePluginRegistry( } models.push({ plugin, model, ConnectionView }); } + + if (isDevicePluginManifestV1Alpha2(manifest)) { + const profileIds = new Set(); + const profilePaths = new Set(); + const declaredModelIds = new Set(manifest.spec.models.map((model) => model.id)); + const coveredModelIds = new Set(); + if (!manifest.spec.compatibilityProfiles.length) { + throw new Error(`Плагин ${manifest.metadata.id} не объявляет compatibilityProfiles.`); + } + for (const profile of manifest.spec.compatibilityProfiles) { + if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) { + throw new Error(`Плагин ${manifest.metadata.id} повторяет compatibility profile.`); + } + profileIds.add(profile.profileId); + profilePaths.add(profile.path); + if (!declaredModelIds.has(profile.modelId)) { + throw new Error( + `Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`, + ); + } + coveredModelIds.add(profile.modelId); + } + const uncovered = [...declaredModelIds].filter( + (modelId) => !coveredModelIds.has(modelId), + ); + if (uncovered.length) { + throw new Error( + `Плагин ${manifest.metadata.id} не имеет разрешённого профиля для ${uncovered.join(", ")}.`, + ); + } + } } const modelById = new Map(models.map((registered) => [registered.model.id, registered])); diff --git a/apps/control-station/src/core/runtime/contracts.ts b/apps/control-station/src/core/runtime/contracts.ts index 0ee4bf9..ca596e8 100644 --- a/apps/control-station/src/core/runtime/contracts.ts +++ b/apps/control-station/src/core/runtime/contracts.ts @@ -39,6 +39,32 @@ export interface ActiveDeviceSnapshot { endpointLabel?: string | null; } +export interface RuntimeDeviceSessionSnapshot { + sessionId: string; + deviceId: string; + compatibilityProfileId?: string | null; + connectivity?: string | null; +} + +export interface RuntimeAcquisitionSnapshot { + acquisitionId: string; + deviceId: string; + deviceSessionId: string; + compatibilityProfileId: string; + controlMode: string; + state: string; + stateRevision: number; + operatorInstructions: readonly string[]; +} + +export interface RuntimeOperationSnapshot { + operationId: string; + action: string; + status: string; + stageCode?: string | null; + messageCode?: string | null; +} + export interface SpatialSourceDescriptor { id: string; url: string; @@ -50,6 +76,9 @@ export interface MissionRuntimeState { phase: RuntimePhase; message?: string | null; activeDevice?: ActiveDeviceSnapshot | null; + deviceSession?: RuntimeDeviceSessionSnapshot | null; + acquisition?: RuntimeAcquisitionSnapshot | null; + operations?: readonly RuntimeOperationSnapshot[]; spatialSource?: SpatialSourceDescriptor | null; viewerSettings?: ViewerSettings | null; sourceMode: SourceMode; diff --git a/apps/control-station/src/device-plugins/xgrids-k1/XgridsK1Connection.tsx b/apps/control-station/src/device-plugins/xgrids-k1/XgridsK1Connection.tsx index a7544ab..9830a18 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/XgridsK1Connection.tsx +++ b/apps/control-station/src/device-plugins/xgrids-k1/XgridsK1Connection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Button, Checker, @@ -12,7 +12,14 @@ import { import type { DevicePluginConnectionProps } from "../../core/device-plugins/contracts"; import { MetricCard } from "../../components/MetricCard"; -import type { BleDevice } from "./api"; +import type { BleDevice, CompatibilityAttestation } from "./api"; +import { + isConfirmedLiveState, + isSourceRuntimeBusy, + provisioningIntentKey, + recoverableAcquisition, + sourceStatusLabel, +} from "./lifecycle"; import { localizeRuntimeMessage } from "./messages"; import { backendLabel, @@ -23,7 +30,6 @@ import { phaseLabel, phaseTone, pipelineLatency, - sourceModeLabel, } from "./presentation"; import { useXgridsK1Controller } from "./runtimeContext"; @@ -34,6 +40,12 @@ const sessionItems = [ { value: "replay", label: "Повтор записи" }, ] satisfies Array<{ value: SessionIntent; label: string }>; +const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = { + firmware_version: "3.0.2", + topology: "direct-lan", + operator_confirmed: true, +}; + function DetailRow({ label, children }: { label: string; children: ReactNode }) { return (
@@ -92,7 +104,7 @@ function DeviceRow({
{device.name?.trim() || "Устройство без имени"} - {device.likely_k1 ? Совместимый профиль : null} + {device.likely_k1 ? Кандидат по имени; профиль не подтверждён : null} {device.device_id}
@@ -145,23 +157,30 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) clearError, scan, connect, - startLive, + prepareAndStartAcquisition, startReplay, stop, + abort, } = console; const [powerConfirmed, setPowerConfirmed] = useState(false); const [selectedDeviceId, setSelectedDeviceId] = useState(""); const [ssid, setSsid] = useState(""); const [password, setPassword] = useState(""); + const [profileConfirmed, setProfileConfirmed] = useState(false); const [sessionIntent, setSessionIntent] = useState("live"); const [liveHost, setLiveHost] = useState(""); const [replayPath, setReplayPath] = useState(""); const [replaySpeed, setReplaySpeed] = useState("1"); const [replayLoop, setReplayLoop] = useState(false); + const provisioningIntentRef = useRef(null); useEffect(() => { if (state?.selected_device_id) { + if (state.selected_device_id !== selectedDeviceId) { + setProfileConfirmed(false); + provisioningIntentRef.current = null; + } setSelectedDeviceId(state.selected_device_id); return; } @@ -174,7 +193,16 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) } }, [selectedDeviceId, state?.devices, state?.selected_device_id]); - const streamActive = state?.source_mode === "live" || state?.source_mode === "replay"; + useEffect(() => { + if (state?.source_mode === "live" || state?.source_mode === "replay") { + setSessionIntent(state.source_mode); + } else if (state?.acquisition?.state === "prepared") { + setSessionIntent("live"); + } + }, [state?.acquisition?.state, state?.source_mode]); + + const confirmedLive = isConfirmedLiveState(state); + const streamActive = confirmedLive || state?.source_mode === "replay"; const metrics = streamActive ? state?.metrics : undefined; const latency = pipelineLatency(metrics); const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz); @@ -183,9 +211,44 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) const devices = state?.devices ?? []; const isBusy = pendingAction !== null; const credentialsReady = ssid.trim().length > 0 && password.length > 0; - const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy; - const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim()); - const sourceLabel = sourceModeLabel(state?.source_mode); + const canConnect = + powerConfirmed && + profileConfirmed && + selectedDeviceId.length > 0 && + credentialsReady && + !isBusy; + const activeAcquisition = recoverableAcquisition(state); + const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null; + const sourceRuntimeBusy = isSourceRuntimeBusy(state); + const sessionLocked = sourceRuntimeBusy || activeAcquisition !== null; + const effectiveSessionIntent: SessionIntent = + state?.source_mode === "live" || state?.source_mode === "replay" + ? state.source_mode + : activeAcquisition + ? "live" + : sessionIntent; + const liveTargetReady = Boolean( + state?.k1_ip || liveHost.trim() || preparedAcquisition?.target_host, + ); + const sourceLabel = sourceStatusLabel(state); + const relevantAcquisitionFailed = + state?.source_mode !== "replay" && state?.acquisition?.state === "failed"; + const sourceTone: StatusTone = + state?.phase === "error" || relevantAcquisitionFailed + ? "danger" + : confirmedLive || state?.source_mode === "replay" + ? "success" + : sourceRuntimeBusy || preparedAcquisition + ? "warning" + : "neutral"; + const connectionPhaseLabel = + sourceRuntimeBusy || preparedAcquisition ? sourceLabel : phaseLabel(state?.phase); + const connectionPhaseTone = + sourceRuntimeBusy || preparedAcquisition ? sourceTone : phaseTone(state?.phase); + const selectableSessionItems = useMemo( + () => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })), + [sessionLocked], + ); const deviceSummary = useMemo( () => devices.find((device) => device.device_id === selectedDeviceId), @@ -194,17 +257,28 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) const submitConnect = async () => { if (!canConnect) return; + const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current); + provisioningIntentRef.current = idempotencyKey; const succeeded = await connect({ device_id: selectedDeviceId, ssid: ssid.trim(), password, + compatibility_attestation: EXACT_PROFILE_ATTESTATION, + idempotency_key: idempotencyKey, }); - if (succeeded) setPassword(""); + if (succeeded) { + provisioningIntentRef.current = null; + setPassword(""); + } }; const submitLive = async () => { + if (!profileConfirmed || sourceRuntimeBusy) return; const targetHost = liveHost.trim(); - const started = await startLive(targetHost ? { host: targetHost } : {}); + const started = await prepareAndStartAcquisition({ + ...(targetHost ? { host: targetHost } : {}), + compatibility_attestation: EXACT_PROFILE_ATTESTATION, + }); if (started) host.openSpatialScene(); }; @@ -229,7 +303,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
- {phaseLabel(state?.phase)} + {connectionPhaseLabel} {localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}
@@ -286,7 +360,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) ПОДКЛЮЧЕНИЕ · ШАГИ 01–03

Подключите устройство к сети

- {phaseLabel(state?.phase)} + {connectionPhaseLabel}
@@ -303,7 +377,13 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) { + setPowerConfirmed(checked); + if (!checked) { + setProfileConfirmed(false); + provisioningIntentRef.current = null; + } + }} />
@@ -327,8 +407,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) } >

- Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый - профиль — только подсказка; окончательный выбор всегда делает оператор. + Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата + основана только на имени и не подтверждает модель или прошивку; окончательный выбор + и аттестацию всегда делает оператор.

- {liveTargetReady - ? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров." - : "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."} + {!profileConfirmed + ? "Сначала вручную подтвердите точную прошивку 3.0.2 и direct-LAN топологию. Интерфейс не аттестует устройство автоматически." + : liveTargetReady + ? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами." + : "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}

) : ( @@ -485,7 +610,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) + {activeAcquisition ? ( + + ) : null} diff --git a/apps/control-station/src/device-plugins/xgrids-k1/api.ts b/apps/control-station/src/device-plugins/xgrids-k1/api.ts index 22b9ecf..9cbb0f7 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/api.ts +++ b/apps/control-station/src/device-plugins/xgrids-k1/api.ts @@ -14,6 +14,101 @@ export interface BleDevice { export type SourceMode = "idle" | "live" | "replay"; +export type AcquisitionState = + | "preparing" + | "prepared" + | "awaiting_external_start" + | "starting" + | "acquiring" + | "awaiting_external_stop" + | "stopping" + | "finalizing" + | "completed" + | "failed" + | "aborted" + | "interrupted"; + +export type OperationStatus = + | "accepted" + | "running" + | "operator_action_required" + | "succeeded" + | "failed" + | "cancelled" + | "timed_out" + | "interrupted"; + +export interface XgridsDeviceRef { + device_id: string; + model_id: string; + identity_stability: "stable" | "provisional"; + identity_basis: + | "hardware-identifier" + | "plugin-derived" + | "operator-assigned" + | "transport-local"; + transport_alias?: string | null; +} + +export interface XgridsDeviceSession { + device_session_id: string; + device_id: string; + opened_at?: string | null; + compatibility_profile_id?: string | null; + connectivity?: "unknown" | "offline" | "connecting" | "connected" | "degraded"; +} + +export interface XgridsCompatibilityState { + profile_id?: string | null; + decision?: "compatible" | "limited" | "unknown" | "incompatible"; + permitted_mode?: "blocked" | "evidence-only" | "read-only" | "active-control"; + firmware_claim?: string | null; + vendor_writes_enabled?: boolean; + camera_preview?: string | null; +} + +export interface XgridsAcquisition { + schema_version?: string; + acquisition_id: string; + device_id: string; + device_session_id: string; + compatibility_profile_id: string; + control_mode: "operator-manual" | "plugin-commanded" | "observe-only"; + requested_streams: string[]; + target_host: string; + duration_seconds: number; + evidence_policy: "required" | "best-effort" | "disabled"; + state: AcquisitionState; + state_revision: number; + created_at?: string | null; + updated_at?: string | null; + message_code?: string | null; + operator_instructions?: string[]; + result?: Record | null; +} + +export interface XgridsOperation { + schema_version?: string; + operation_id: string; + action: string; + status: OperationStatus; + accepted_at?: string | null; + completed_at?: string | null; + deadline_at?: string | null; + device_id?: string | null; + device_session_id?: string | null; + idempotency_key?: string | null; + stage_code?: string | null; + message_code?: string | null; + sequence?: number; + state_revision?: number; + cancellable?: boolean; + cancel_requested?: boolean; + result?: Record | null; + error?: Record | null; + evidence_refs?: string[]; +} + export interface XgridsK1Metrics { mqtt_to_decode_ms?: number | null; decode_ms?: number | null; @@ -28,6 +123,7 @@ export interface XgridsK1Metrics { } export interface XgridsK1State { + contract_version?: string | null; phase?: string | null; message?: string | null; devices?: BleDevice[]; @@ -39,6 +135,12 @@ export interface XgridsK1State { viewer_settings?: ViewerSettings | null; source_mode?: SourceMode | null; metrics?: XgridsK1Metrics; + compatibility?: XgridsCompatibilityState | null; + device_ref?: XgridsDeviceRef | null; + device_session?: XgridsDeviceSession | null; + acquisition?: XgridsAcquisition | null; + operations?: XgridsOperation[]; + last_operation?: XgridsOperation | null; } export interface HealthResponse { @@ -52,15 +154,66 @@ export interface ScanRequest { duration_seconds?: number; } +export interface CompatibilityAttestation { + firmware_version: "3.0.2"; + topology: "direct-lan"; + operator_confirmed: true; +} + export interface ConnectRequest { device_id: string; ssid: string; password: string; + compatibility_attestation: CompatibilityAttestation; + operation_id?: string; + idempotency_key?: string; } -export interface LiveRequest { +export interface PrepareAcquisitionRequest { host?: string; duration_seconds?: number; + requested_streams?: RequestedStreamId[]; + evidence_policy?: "required" | "best-effort" | "disabled"; + compatibility_attestation: CompatibilityAttestation; + operation_id?: string; + idempotency_key?: string; + deadline_seconds?: number; +} + +export type RequestedStreamId = + | "spatial.point-cloud.live" + | "spatial.pose.live" + | "device.status.live" + | "device.heartbeat.live"; + +export interface StartAcquisitionRequest { + acquisition_id: string; + expected_state_revision?: number; + operation_id?: string; + idempotency_key?: string; + deadline_seconds?: number; +} + +export interface StopAcquisitionRequest { + acquisition_id: string; + mode: "capture-only" | "graceful"; + operator_confirmed?: boolean; + operation_id?: string; + idempotency_key?: string; + deadline_seconds?: number; +} + +export interface AbortAcquisitionRequest { + acquisition_id: string; + operation_id?: string; + idempotency_key?: string; + deadline_seconds?: number; +} + +export interface CompatibilityLiveRequest { + host?: string; + duration_seconds?: number; + compatibility_attestation: CompatibilityAttestation; } export interface ReplayRequest { @@ -177,16 +330,32 @@ export const xgridsK1Api = { return invokeState(xgridsK1Actions.networkProvision, body); }, - startLive(body: LiveRequest = {}): Promise { - return invokeState(xgridsK1Actions.streamStartLive, body); + prepareAcquisition(body: PrepareAcquisitionRequest): Promise { + return invokeState(xgridsK1Actions.acquisitionPrepare, body); + }, + + startAcquisition(body: StartAcquisitionRequest): Promise { + return invokeState(xgridsK1Actions.acquisitionStart, body); + }, + + stopAcquisition(body: StopAcquisitionRequest): Promise { + return invokeState(xgridsK1Actions.acquisitionStop, body); + }, + + abortAcquisition(body: AbortAcquisitionRequest): Promise { + return invokeState(xgridsK1Actions.acquisitionAbort, body); }, startReplay(body: ReplayRequest): Promise { return invokeState(xgridsK1Actions.streamStartReplay, body); }, - stopSession(): Promise { - return invokeState(xgridsK1Actions.streamStop); + startLiveCompatibility(body: CompatibilityLiveRequest): Promise { + return invokeState(xgridsK1Actions.compatibilityStreamStartLive, body); + }, + + stopSessionCompatibility(): Promise { + return invokeState(xgridsK1Actions.compatibilityStreamStop); }, updateViewerSettings(body: ViewerSettings): Promise { diff --git a/apps/control-station/src/device-plugins/xgrids-k1/lifecycle.ts b/apps/control-station/src/device-plugins/xgrids-k1/lifecycle.ts new file mode 100644 index 0000000..a46c5ad --- /dev/null +++ b/apps/control-station/src/device-plugins/xgrids-k1/lifecycle.ts @@ -0,0 +1,173 @@ +import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "../../core/runtime/contracts"; + +import type { + AcquisitionState, + XgridsAcquisition, + XgridsK1State, + XgridsOperation, +} from "./api"; + +const TERMINAL_ACQUISITION_STATES = new Set([ + "completed", + "failed", + "aborted", + "interrupted", +]); + +const FAILED_OPERATION_STATUSES = new Set([ + "failed", + "cancelled", + "timed_out", + "interrupted", +]); + +export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked"; + +export function isTerminalAcquisitionState( + state: AcquisitionState | null | undefined, +): boolean { + return state ? TERMINAL_ACQUISITION_STATES.has(state) : false; +} + +export function recoverableAcquisition( + state: XgridsK1State | null | undefined, +): XgridsAcquisition | null { + const acquisition = state?.acquisition; + return acquisition && !isTerminalAcquisitionState(acquisition.state) ? acquisition : null; +} + +export function isConfirmedLiveState(state: XgridsK1State | null | undefined): boolean { + return state?.source_mode === "live" && state.acquisition?.state === "acquiring"; +} + +export function isSourceRuntimeBusy(state: XgridsK1State | null | undefined): boolean { + return state?.source_mode === "live" || state?.source_mode === "replay"; +} + +export function confirmedRuntimeSourceMode( + state: XgridsK1State | null | undefined, +): RuntimeSourceMode { + if (state?.source_mode === "replay") return "replay"; + if (isConfirmedLiveState(state)) return "live"; + return "idle"; +} + +export function effectiveAcquisition( + state: XgridsK1State | null | undefined, +): XgridsAcquisition | null { + if (state?.source_mode === "replay") return null; + return state?.acquisition ?? null; +} + +export function liveStartPlan(state: XgridsK1State | null | undefined): LiveStartPlan { + if (state?.source_mode === "replay") return "blocked"; + const acquisition = recoverableAcquisition(state); + if (!acquisition) return state?.source_mode === "live" ? "blocked" : "prepare"; + if (acquisition.state === "prepared") return "resume-prepared"; + if ( + acquisition.state === "starting" || + acquisition.state === "awaiting_external_start" || + acquisition.state === "acquiring" + ) { + return "already-running"; + } + return "blocked"; +} + +export function normalizeRuntimePhase( + state: XgridsK1State | null | undefined, +): RuntimePhase { + const phase = state?.phase; + const acquisitionState = effectiveAcquisition(state)?.state; + if (acquisitionState === "failed" || acquisitionState === "interrupted") return "error"; + if (acquisitionState === "awaiting_external_start" || acquisitionState === "starting") { + return "starting"; + } + if (acquisitionState === "acquiring") return "streaming"; + if ( + acquisitionState === "awaiting_external_stop" || + acquisitionState === "stopping" || + acquisitionState === "finalizing" + ) { + return "stopping"; + } + if (acquisitionState === "prepared") return "connected"; + if (phase === "error") return "error"; + if (phase === "connected") return "connected"; + if (phase === "starting_live") return "starting"; + if (phase === "live") return "streaming"; + if (phase === "replay") return "replaying"; + if (phase === "stopping") return "stopping"; + if (["scanning", "device_selected", "provisioning", "connecting"].includes(phase ?? "")) { + return "configuring"; + } + return "idle"; +} + +export function spatialSourceId( + state: XgridsK1State | null | undefined, + sourceUrl: string, +): string | null { + if (!sourceUrl) return null; + if (state?.source_mode === "replay") return `replay:${sourceUrl}`; + return state?.acquisition?.acquisition_id ?? sourceUrl; +} + +export function sourceStatusLabel(state: XgridsK1State | null | undefined): string { + if (state?.source_mode === "replay") return "Повтор записи"; + if (isConfirmedLiveState(state)) return "Реальное время · данные подтверждены"; + if (state?.source_mode === "live") { + if (state.acquisition?.state === "failed" || state.phase === "error") { + return "Ошибка локального приёмника"; + } + return "Ожидание реальных данных"; + } + if (state?.acquisition?.state === "prepared") return "Приём подготовлен"; + return "Ожидание"; +} + +export function operationByIdempotencyKey( + state: XgridsK1State | null | undefined, + action: string, + idempotencyKey: string | null | undefined, +): XgridsOperation | null { + if (!idempotencyKey) return null; + return ( + [...(state?.operations ?? [])] + .reverse() + .find( + (operation) => + operation.action === action && operation.idempotency_key === idempotencyKey, + ) ?? null + ); +} + +export function operationNeedsReconciliation( + operation: XgridsOperation | null | undefined, +): boolean { + if (!operation || !FAILED_OPERATION_STATUSES.has(operation.status)) return false; + return operation.error?.safe_to_retry !== true; +} + +function defaultUuid(): string { + const cryptoApi = globalThis.crypto; + if (!cryptoApi) { + throw new Error("Web Crypto недоступен; безопасный идентификатор операции не создан."); + } + if (typeof cryptoApi.randomUUID === "function") return cryptoApi.randomUUID(); + const bytes = new Uint8Array(16); + cryptoApi.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex + .slice(6, 8) + .join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`; +} + +export function provisioningIntentKey( + current: string | null, + createUuid: () => string = defaultUuid, +): string { + return current ?? `network-provision:${createUuid()}`; +} diff --git a/apps/control-station/src/device-plugins/xgrids-k1/manifest.ts b/apps/control-station/src/device-plugins/xgrids-k1/manifest.ts index 2cd9a5c..db66319 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/manifest.ts +++ b/apps/control-station/src/device-plugins/xgrids-k1/manifest.ts @@ -11,9 +11,21 @@ export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument); export const xgridsK1Actions = Object.freeze({ stateRead: requirePluginAction(xgridsK1Manifest, DEVICE_STATE_READ_ACTION_ID), discoveryScan: requirePluginAction(xgridsK1Manifest, "discovery.scan"), + deviceInspect: requirePluginAction(xgridsK1Manifest, "device.inspect"), + sensorCatalogRead: requirePluginAction(xgridsK1Manifest, "sensor.catalog.read"), + deviceCalibrationSnapshotRead: requirePluginAction( + xgridsK1Manifest, + "calibration.device-snapshot.read", + ), networkProvision: requirePluginAction(xgridsK1Manifest, "network.provision"), - streamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"), + connectionVerify: requirePluginAction(xgridsK1Manifest, "connection.verify"), + acquisitionPrepare: requirePluginAction(xgridsK1Manifest, "acquisition.prepare"), + acquisitionStart: requirePluginAction(xgridsK1Manifest, "acquisition.start"), + acquisitionStop: requirePluginAction(xgridsK1Manifest, "acquisition.stop"), + acquisitionAbort: requirePluginAction(xgridsK1Manifest, "acquisition.abort"), + acquisitionStateRead: requirePluginAction(xgridsK1Manifest, "acquisition.state.read"), + compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"), streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"), - streamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"), + compatibilityStreamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"), viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"), }); diff --git a/apps/control-station/src/device-plugins/xgrids-k1/runtimeContext.tsx b/apps/control-station/src/device-plugins/xgrids-k1/runtimeContext.tsx index c9164f3..8f34e4e 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/runtimeContext.tsx +++ b/apps/control-station/src/device-plugins/xgrids-k1/runtimeContext.tsx @@ -8,8 +8,13 @@ import { import type { MissionRuntimeController, MissionRuntimeState, - RuntimePhase, } from "../../core/runtime/contracts"; +import { + confirmedRuntimeSourceMode, + effectiveAcquisition, + normalizeRuntimePhase, + spatialSourceId, +} from "./lifecycle"; import { localizeRuntimeMessage } from "./messages"; import { xgridsK1Manifest } from "./manifest"; import { finiteMetric, pipelineLatency } from "./presentation"; @@ -19,19 +24,6 @@ export type XgridsK1Controller = ReturnType; const XgridsK1RuntimeContext = createContext(null); -function normalizePhase(phase: string | null | undefined): RuntimePhase { - if (phase === "error") return "error"; - if (phase === "connected") return "connected"; - if (phase === "starting_live") return "starting"; - if (phase === "live") return "streaming"; - if (phase === "replay") return "replaying"; - if (phase === "stopping") return "stopping"; - if (["scanning", "device_selected", "provisioning", "connecting"].includes(phase ?? "")) { - return "configuring"; - } - return "idle"; -} - function normalizeState( controller: XgridsK1Controller, activeModel: DeviceModelDefinition, @@ -39,31 +31,67 @@ function normalizeState( const state = controller.state; if (!state) return null; const metrics = state.metrics; - const hasDevice = Boolean(state.selected_device_id || state.k1_ip); + const deviceRef = state.device_ref; + const deviceSession = state.device_session; + const acquisition = effectiveAcquisition(state); const sourceUrl = state.rerun_grpc_url?.trim() ?? ""; + const resolvedSpatialSourceId = + state.source_mode === "replay" + ? spatialSourceId(state, sourceUrl) + : acquisition?.acquisition_id ?? spatialSourceId(state, sourceUrl); return { - phase: normalizePhase(state.phase), + phase: normalizeRuntimePhase(state), message: localizeRuntimeMessage(state.message), - activeDevice: hasDevice + activeDevice: deviceRef ? { pluginId: xgridsK1Manifest.metadata.id, - modelId: activeModel.id, + modelId: deviceRef.model_id || activeModel.id, displayName: activeModel.displayName, - instanceId: state.selected_device_id, + instanceId: deviceRef.device_id, endpointLabel: state.k1_ip, } : null, - spatialSource: sourceUrl + deviceSession: deviceSession ? { - id: "xgrids-k1-rerun-live", + sessionId: deviceSession.device_session_id, + deviceId: deviceSession.device_id, + compatibilityProfileId: deviceSession.compatibility_profile_id, + connectivity: deviceSession.connectivity, + } + : null, + acquisition: acquisition + ? { + acquisitionId: acquisition.acquisition_id, + deviceId: acquisition.device_id, + deviceSessionId: acquisition.device_session_id, + compatibilityProfileId: acquisition.compatibility_profile_id, + controlMode: acquisition.control_mode, + state: acquisition.state, + stateRevision: acquisition.state_revision, + operatorInstructions: acquisition.operator_instructions ?? [], + } + : null, + operations: (state.operations ?? []).map((operation) => ({ + operationId: operation.operation_id, + action: operation.action, + status: operation.status, + stageCode: operation.stage_code, + messageCode: operation.message_code, + })), + spatialSource: sourceUrl && resolvedSpatialSourceId + ? { + id: resolvedSpatialSourceId, url: sourceUrl, - label: "Локальный пространственный поток", + label: + state.source_mode === "replay" + ? "Повтор пространственной записи" + : "Локальный пространственный поток", kind: "rerun-grpc", } : null, viewerSettings: state.viewer_settings, - sourceMode: state.source_mode ?? "idle", + sourceMode: confirmedRuntimeSourceMode(state), metrics: { latencyMs: pipelineLatency(metrics), frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz), diff --git a/apps/control-station/src/device-plugins/xgrids-k1/useXgridsK1Runtime.ts b/apps/control-station/src/device-plugins/xgrids-k1/useXgridsK1Runtime.ts index 677232d..c87e9cc 100644 --- a/apps/control-station/src/device-plugins/xgrids-k1/useXgridsK1Runtime.ts +++ b/apps/control-station/src/device-plugins/xgrids-k1/useXgridsK1Runtime.ts @@ -7,14 +7,27 @@ import { xgridsK1Api, openEventSocket, type ConnectRequest, - type XgridsK1State, type EventSocketStatus, - type LiveRequest, + type PrepareAcquisitionRequest, type ReplayRequest, + type XgridsK1State, } from "./api"; +import { + isTerminalAcquisitionState, + liveStartPlan, + operationByIdempotencyKey, + operationNeedsReconciliation, +} from "./lifecycle"; import { localizeRuntimeMessage } from "./messages"; -export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer"; +export type PendingAction = + | "scan" + | "connect" + | "live" + | "replay" + | "stop" + | "abort" + | "viewer"; function messageFor(error: unknown): string { if (error instanceof ApiError) { @@ -114,13 +127,64 @@ export function useXgridsK1Runtime(enabled: boolean) { ); const connect = useCallback( - (request: ConnectRequest) => run("connect", () => xgridsK1Api.connect(request)), - [run], + (request: ConnectRequest) => + run("connect", async () => { + const previous = operationByIdempotencyKey( + state, + "network.provision", + request.idempotency_key, + ); + if (previous?.status === "succeeded" && state) return state; + if (operationNeedsReconciliation(previous)) { + throw new ApiError( + "Предыдущая запись настроек завершилась с неопределённым результатом. Автоматический повтор заблокирован; измените параметры только после проверки устройства.", + ); + } + + const nextState = await xgridsK1Api.connect(request); + const operation = operationByIdempotencyKey( + nextState, + "network.provision", + request.idempotency_key, + ); + if (operationNeedsReconciliation(operation)) { + throw new ApiError( + "Результат записи настроек требует ручной проверки. Повторная аппаратная запись не выполнена.", + ); + } + if (operation && operation.status !== "succeeded") { + throw new ApiError("Запись настроек ещё выполняется; дождитесь обновления состояния."); + } + return nextState; + }), + [run, state], ); - const startLive = useCallback( - (request: LiveRequest = {}) => run("live", () => xgridsK1Api.startLive(request)), - [run], + const prepareAndStartAcquisition = useCallback( + (request: PrepareAcquisitionRequest) => + run("live", async () => { + const plan = liveStartPlan(state); + if (plan === "blocked") { + throw new ApiError( + "Сначала завершите текущий приём или повтор записи.", + ); + } + if (plan === "already-running" && state) return state; + + const prepared = + plan === "resume-prepared" && state + ? state + : await xgridsK1Api.prepareAcquisition(request); + const acquisition = prepared.acquisition; + if (!acquisition?.acquisition_id) { + throw new ApiError("Локальный сервис не вернул идентификатор подготовленного приёма."); + } + return xgridsK1Api.startAcquisition({ + acquisition_id: acquisition.acquisition_id, + expected_state_revision: acquisition.state_revision, + }); + }), + [run, state], ); const startReplay = useCallback( @@ -129,10 +193,32 @@ export function useXgridsK1Runtime(enabled: boolean) { ); const stop = useCallback( - () => run("stop", () => xgridsK1Api.stopSession()), - [run], + () => + run("stop", () => { + const acquisition = state?.acquisition; + const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state); + if (acquisition && !acquisitionTerminal) { + return xgridsK1Api.stopAcquisition({ + acquisition_id: acquisition.acquisition_id, + mode: "capture-only", + }); + } + // Replay and pre-v1alpha2 sessions remain a compatibility-only path. + return xgridsK1Api.stopSessionCompatibility(); + }), + [run, state?.acquisition], ); + const abort = useCallback(() => { + const acquisition = state?.acquisition; + if (!acquisition || isTerminalAcquisitionState(acquisition.state)) { + return Promise.resolve(false); + } + return run("abort", () => + xgridsK1Api.abortAcquisition({ acquisition_id: acquisition.acquisition_id }), + ); + }, [run, state?.acquisition]); + const updateViewerSettings = useCallback( (request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)), [run], @@ -210,9 +296,10 @@ export function useXgridsK1Runtime(enabled: boolean) { clearError: () => setError(null), scan, connect, - startLive, + prepareAndStartAcquisition, startReplay, stop, + abort, updateViewerSettings, }; } diff --git a/apps/control-station/test/devicePluginContracts.test.mjs b/apps/control-station/test/devicePluginContracts.test.mjs new file mode 100644 index 0000000..3c0184b --- /dev/null +++ b/apps/control-station/test/devicePluginContracts.test.mjs @@ -0,0 +1,358 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { createServer } from "vite"; + +let server; +let parseDevicePluginManifest; +let createDevicePluginRegistry; +let xgridsK1Manifest; +let xgridsK1Actions; +let xgridsK1Api; +let lifecycle; + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ({ parseDevicePluginManifest } = await server.ssrLoadModule( + "/src/core/device-plugins/manifestParser.ts", + )); + ({ createDevicePluginRegistry } = await server.ssrLoadModule( + "/src/core/device-plugins/registry.ts", + )); + ({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule( + "/src/device-plugins/xgrids-k1/manifest.ts", + )); + lifecycle = await server.ssrLoadModule( + "/src/device-plugins/xgrids-k1/lifecycle.ts", + ); + ({ xgridsK1Api } = await server.ssrLoadModule( + "/src/device-plugins/xgrids-k1/api.ts", + )); +}); + +after(async () => { + await server?.close(); +}); + +function manifestDocument({ + apiVersion = "missioncore.nodedc/v1alpha1", + pluginId = "test.device.plugin", + modelId = "test.device.model", +} = {}) { + const v1alpha2 = apiVersion === "missioncore.nodedc/v1alpha2"; + return { + apiVersion, + kind: "DevicePlugin", + metadata: { + id: pluginId, + version: "1.0.0", + displayName: "Test device", + }, + spec: { + hostApiRange: v1alpha2 ? "v1alpha2" : "v1alpha1", + runtime: { + backendEntrypoint: "test.plugin:build", + isolation: "transitional-in-process", + }, + ...(v1alpha2 + ? { + compatibilityProfiles: [ + { + profileId: `${modelId}.fw-1.v1`, + path: "profiles/fw-1/profile.v1.json", + modelId, + }, + ], + } + : {}), + permissions: ["device.read"], + actions: [{ id: "state.read", mutating: false, secretFields: [] }], + models: [ + { + id: modelId, + vendor: "Test", + displayName: "Test model", + category: "Sensor", + description: "Fixture model", + verified: true, + capabilities: [{ id: "device.read", label: "Read" }], + ui: { slot: "device.connection", componentKey: "test.connection" }, + }, + ], + }, + }; +} + +function uiPlugin(manifest) { + return { + manifest, + RuntimeProvider: ({ children }) => children, + connectionViews: { "test.connection": () => null }, + }; +} + +test("parser accepts reviewed v1alpha1 and v1alpha2 shapes", () => { + const legacy = parseDevicePluginManifest(manifestDocument()); + const current = parseDevicePluginManifest( + manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }), + ); + + assert.equal(legacy.apiVersion, "missioncore.nodedc/v1alpha1"); + assert.equal(legacy.spec.hostApiRange, "v1alpha1"); + assert.equal(current.apiVersion, "missioncore.nodedc/v1alpha2"); + assert.equal(current.spec.hostApiRange, "v1alpha2"); + assert.deepEqual(current.spec.compatibilityProfiles, [ + { + profileId: "test.device.model.fw-1.v1", + path: "profiles/fw-1/profile.v1.json", + modelId: "test.device.model", + }, + ]); +}); + +test("v1alpha1 keeps the original nonblank identifier compatibility", () => { + const legacyDocument = manifestDocument({ + pluginId: "legacy plugin id", + modelId: "legacy model id", + }); + legacyDocument.spec.permissions = ["legacy permission"]; + legacyDocument.spec.actions[0].secretFields = ["legacy secret field"]; + legacyDocument.spec.models[0].capabilities = [ + { id: "legacy capability", label: "Legacy" }, + ]; + + const legacy = parseDevicePluginManifest(legacyDocument); + + assert.equal(legacy.metadata.id, "legacy plugin id"); + assert.equal(legacy.spec.models[0].id, "legacy model id"); + assert.deepEqual(legacy.spec.permissions, ["legacy permission"]); +}); + +test("v1alpha2 applies strict identifiers without redefining v1alpha1", () => { + const current = manifestDocument({ + apiVersion: "missioncore.nodedc/v1alpha2", + pluginId: "current plugin id", + }); + + assert.throws( + () => parseDevicePluginManifest(current), + /не является идентификатором/, + ); +}); + +test("v1alpha2 parser and registry accept multiple independently profiled models", () => { + const document = manifestDocument({ + apiVersion: "missioncore.nodedc/v1alpha2", + pluginId: "test.family", + modelId: "test.family.model-a", + }); + document.spec.models.push({ + ...document.spec.models[0], + id: "test.family.model-b", + displayName: "Test model B", + }); + document.spec.compatibilityProfiles.push({ + profileId: "test.family.model-b.fw-1.v1", + path: "profiles/fw-1/model-b.v1.json", + modelId: "test.family.model-b", + }); + + const manifest = parseDevicePluginManifest(document); + const plugin = { + ...uiPlugin(manifest), + connectionViews: { "test.connection": () => null }, + }; + const registry = createDevicePluginRegistry([plugin]); + + assert.deepEqual(registry.models.map(({ model }) => model.id), [ + "test.family.model-a", + "test.family.model-b", + ]); +}); + +test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions", () => { + assert.equal(xgridsK1Manifest.apiVersion, "missioncore.nodedc/v1alpha2"); + assert.deepEqual(xgridsK1Manifest.spec.compatibilityProfiles, [ + { + profileId: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1", + path: "profiles/fw-3.0.2/direct-lan.v1.json", + modelId: "xgrids.lixelkity-k1", + }, + ]); + assert.equal(xgridsK1Actions.acquisitionPrepare, "acquisition.prepare"); + assert.equal(xgridsK1Actions.acquisitionStart, "acquisition.start"); + assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop"); +}); + +test("v1alpha2 compatibility profile is exact-key and rejects duplicated profile status", () => { + const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }); + document.spec.compatibilityProfiles[0].status = "verified"; + + assert.throws( + () => parseDevicePluginManifest(document), + /неизвестные поля status/, + ); +}); + +test("v1alpha2 compatibility profile fails closed on unsafe paths", () => { + const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }); + document.spec.compatibilityProfiles[0].path = "../private/profile.json"; + + assert.throws( + () => parseDevicePluginManifest(document), + /безопасным относительным JSON-путём/, + ); +}); + +test("v1alpha2 compatibility profile cannot reference an unknown model", () => { + const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }); + document.spec.compatibilityProfiles[0].modelId = "missing.model"; + + assert.throws( + () => parseDevicePluginManifest(document), + /ссылается на неизвестную модель missing\.model/, + ); +}); + +test("registry accepts v1alpha1 and v1alpha2 plugins together", () => { + const legacy = parseDevicePluginManifest( + manifestDocument({ pluginId: "test.legacy", modelId: "test.legacy.model" }), + ); + const current = parseDevicePluginManifest( + manifestDocument({ + apiVersion: "missioncore.nodedc/v1alpha2", + pluginId: "test.current", + modelId: "test.current.model", + }), + ); + const registry = createDevicePluginRegistry([uiPlugin(legacy), uiPlugin(current)]); + + assert.equal(registry.plugins.length, 2); + assert.equal(registry.models.length, 2); + assert.equal(registry.resolveModel("test.current.model")?.plugin.manifest.metadata.id, "test.current"); +}); + +test("registry independently rejects an uncovered v1alpha2 model", () => { + const current = parseDevicePluginManifest( + manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }), + ); + current.spec.compatibilityProfiles[0].modelId = "missing.model"; + + assert.throws( + () => createDevicePluginRegistry([uiPlugin(current)]), + /ссылается на неизвестную модель missing\.model/, + ); +}); + +test("live source is confirmed only by an acquiring acquisition", () => { + const waiting = { + source_mode: "live", + phase: "live", + acquisition: { state: "awaiting_external_start" }, + }; + const acquiring = { + ...waiting, + acquisition: { state: "acquiring" }, + }; + + assert.equal(lifecycle.isConfirmedLiveState(waiting), false); + assert.equal(lifecycle.confirmedRuntimeSourceMode(waiting), "idle"); + assert.equal(lifecycle.sourceStatusLabel(waiting), "Ожидание реальных данных"); + assert.equal(lifecycle.isConfirmedLiveState(acquiring), true); + assert.equal(lifecycle.confirmedRuntimeSourceMode(acquiring), "live"); +}); + +test("prepared acquisition resumes without another prepare and remains recoverable", () => { + const prepared = { + source_mode: "idle", + acquisition: { + acquisition_id: "acq-1", + state: "prepared", + }, + }; + + assert.equal(lifecycle.liveStartPlan(prepared), "resume-prepared"); + assert.equal(lifecycle.recoverableAcquisition(prepared)?.acquisition_id, "acq-1"); +}); + +test("replay ignores a stale failed live acquisition", () => { + const replay = { + source_mode: "replay", + phase: "replay", + rerun_grpc_url: "rerun+http://127.0.0.1:9876/proxy", + acquisition: { + acquisition_id: "old-live-acquisition", + state: "failed", + }, + }; + + assert.equal(lifecycle.normalizeRuntimePhase(replay), "replaying"); + assert.equal(lifecycle.effectiveAcquisition(replay), null); + assert.equal( + lifecycle.spatialSourceId(replay, replay.rerun_grpc_url), + `replay:${replay.rerun_grpc_url}`, + ); +}); + +test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => { + let created = 0; + const createUuid = () => { + created += 1; + return "11111111-1111-4111-8111-111111111111"; + }; + const first = lifecycle.provisioningIntentKey(null, createUuid); + const repeated = lifecycle.provisioningIntentKey(first, createUuid); + const failedOperation = { + status: "failed", + error: { safe_to_retry: false }, + }; + + assert.equal(first, "network-provision:11111111-1111-4111-8111-111111111111"); + assert.equal(repeated, first); + assert.equal(created, 1); + assert.equal(lifecycle.operationNeedsReconciliation(failedOperation), true); +}); + +test("device mutations send explicit nested compatibility attestation", async () => { + const originalFetch = globalThis.fetch; + const calls = []; + const syntheticCredential = "x".repeat(32); + globalThis.fetch = async (path, init) => { + calls.push({ path, init }); + return new Response(JSON.stringify({ state: { source_mode: "idle" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + const attestation = { + firmware_version: "3.0.2", + topology: "direct-lan", + operator_confirmed: true, + }; + + try { + await xgridsK1Api.connect({ + device_id: "ble-device", + ssid: "lab-network", + password: syntheticCredential, + compatibility_attestation: attestation, + idempotency_key: "network-provision:test", + }); + await xgridsK1Api.prepareAcquisition({ + compatibility_attestation: attestation, + }); + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(calls.length, 2); + const provisioning = JSON.parse(calls[0].init.body); + const prepare = JSON.parse(calls[1].init.body); + assert.deepEqual(provisioning.input.compatibility_attestation, attestation); + assert.equal(provisioning.input.idempotency_key, "network-provision:test"); + assert.deepEqual(prepare.input.compatibility_attestation, attestation); +}); diff --git a/plugins/xgrids-k1/plugin.manifest.json b/plugins/xgrids-k1/plugin.manifest.json index 0cfe76e..a98386a 100644 --- a/plugins/xgrids-k1/plugin.manifest.json +++ b/plugins/xgrids-k1/plugin.manifest.json @@ -1,17 +1,24 @@ { - "apiVersion": "missioncore.nodedc/v1alpha1", + "apiVersion": "missioncore.nodedc/v1alpha2", "kind": "DevicePlugin", "metadata": { "id": "nodedc.device.xgrids-lixelkity-k1", - "version": "0.1.0", + "version": "0.2.0", "displayName": "XGRIDS K1 Integration" }, "spec": { - "hostApiRange": "v1alpha1", + "hostApiRange": "v1alpha2", "runtime": { "backendEntrypoint": "k1link.web.xgrids_k1_facade:build_xgrids_k1_plugin", "isolation": "transitional-in-process" }, + "compatibilityProfiles": [ + { + "profileId": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1", + "path": "profiles/fw-3.0.2/direct-lan.v1.json", + "modelId": "xgrids.lixelkity-k1" + } + ], "permissions": [ "device.discovery.ble", "device.provisioning.wifi-over-ble", @@ -21,7 +28,16 @@ "actions": [ { "id": "state.read", "mutating": false, "secretFields": [] }, { "id": "discovery.scan", "mutating": false, "secretFields": [] }, + { "id": "device.inspect", "mutating": false, "secretFields": [] }, + { "id": "sensor.catalog.read", "mutating": false, "secretFields": [] }, + { "id": "calibration.device-snapshot.read", "mutating": false, "secretFields": [] }, { "id": "network.provision", "mutating": true, "secretFields": ["password"] }, + { "id": "connection.verify", "mutating": false, "secretFields": [] }, + { "id": "acquisition.prepare", "mutating": true, "secretFields": [] }, + { "id": "acquisition.start", "mutating": true, "secretFields": [] }, + { "id": "acquisition.stop", "mutating": true, "secretFields": [] }, + { "id": "acquisition.abort", "mutating": true, "secretFields": [] }, + { "id": "acquisition.state.read", "mutating": false, "secretFields": [] }, { "id": "stream.start-live", "mutating": true, "secretFields": [] }, { "id": "stream.start-replay", "mutating": true, "secretFields": [] }, { "id": "stream.stop", "mutating": true, "secretFields": [] }, diff --git a/plugins/xgrids-k1/profile_loader.py b/plugins/xgrids-k1/profile_loader.py new file mode 100644 index 0000000..a23d142 --- /dev/null +++ b/plugins/xgrids-k1/profile_loader.py @@ -0,0 +1,486 @@ +"""Strict loader for the local XGRIDS K1 compatibility profile. + +The profile is descriptive and fail-closed. Loading it never performs device +I/O and never grants write authority to a transport implementation. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +PROFILE_SCHEMA_VERSION = 1 +DEFAULT_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" +DEFAULT_PROFILE_PATH = Path(__file__).parent / "profiles" / "fw-3.0.2" / "direct-lan.v1.json" +EVIDENCE_FLAGS = ( + "observed", + "decoded", + "replay_verified", + "physical_verified", + "write_enabled", +) + + +class CompatibilityProfileError(ValueError): + """The compatibility profile is malformed or exceeds its reviewed scope.""" + + +def _object(value: Any, path: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise CompatibilityProfileError(f"{path} must be an object") + return value + + +def _array(value: Any, path: str) -> list[Any]: + if not isinstance(value, list): + raise CompatibilityProfileError(f"{path} must be an array") + return value + + +def _string(value: Any, path: str) -> str: + if not isinstance(value, str) or not value: + raise CompatibilityProfileError(f"{path} must be a non-empty string") + return value + + +def _unique_index(items: list[Any], path: str) -> dict[str, dict[str, Any]]: + index: dict[str, dict[str, Any]] = {} + for position, value in enumerate(items): + item_path = f"{path}[{position}]" + item = _object(value, item_path) + item_id = _string(item.get("id"), f"{item_path}.id") + if item_id in index: + raise CompatibilityProfileError(f"{path} contains duplicate id {item_id!r}") + index[item_id] = item + return index + + +def _validate_evidence(value: Any, path: str) -> dict[str, bool]: + evidence = _object(value, path) + if set(evidence) != set(EVIDENCE_FLAGS): + expected = ", ".join(EVIDENCE_FLAGS) + raise CompatibilityProfileError(f"{path} must contain exactly: {expected}") + for flag in EVIDENCE_FLAGS: + if not isinstance(evidence[flag], bool): + raise CompatibilityProfileError(f"{path}.{flag} must be a boolean") + if evidence["write_enabled"]: + raise CompatibilityProfileError(f"{path}.write_enabled must remain false in v1") + return evidence # type: ignore[return-value] + + +def _walk_and_validate_evidence(value: Any, path: str = "$") -> None: + if isinstance(value, dict): + if "evidence" in value: + _validate_evidence(value["evidence"], f"{path}.evidence") + for key, child in value.items(): + child_path = f"{path}.{key}" + if key == "write_enabled" and path != "$.evidence_vocabulary": + if not isinstance(child, bool): + raise CompatibilityProfileError(f"{child_path} must be a boolean") + if child: + raise CompatibilityProfileError(f"{child_path} must remain false in v1") + _walk_and_validate_evidence(child, child_path) + elif isinstance(value, list): + for position, child in enumerate(value): + _walk_and_validate_evidence(child, f"{path}[{position}]") + + +def _expect_evidence( + item: dict[str, Any], + path: str, + *, + observed: bool, + decoded: bool, + replay_verified: bool, + physical_verified: bool, +) -> None: + actual = _validate_evidence(item.get("evidence"), f"{path}.evidence") + expected = { + "observed": observed, + "decoded": decoded, + "replay_verified": replay_verified, + "physical_verified": physical_verified, + "write_enabled": False, + } + if actual != expected: + raise CompatibilityProfileError( + f"{path}.evidence exceeds or contradicts the reviewed v1 evidence" + ) + + +def _validate_sources(profile: dict[str, Any]) -> set[str]: + sources = _unique_index( + _array(profile.get("evidence_sources"), "$.evidence_sources"), + "$.evidence_sources", + ) + for source_id, source in sources.items(): + _string(source.get("kind"), f"$.evidence_sources[{source_id!r}].kind") + path = _string(source.get("path"), f"$.evidence_sources[{source_id!r}].path") + if path.startswith("/") or ".." in Path(path).parts: + raise CompatibilityProfileError( + f"$.evidence_sources[{source_id!r}].path must be repository-relative" + ) + _string(source.get("scope"), f"$.evidence_sources[{source_id!r}].scope") + return set(sources) + + +def _validate_source_references(value: Any, source_ids: set[str], path: str = "$") -> None: + if isinstance(value, dict): + if "source_ids" in value: + references = _array(value["source_ids"], f"{path}.source_ids") + if len(references) != len(set(references)): + raise CompatibilityProfileError(f"{path}.source_ids contains duplicates") + for position, source_id in enumerate(references): + source_id = _string(source_id, f"{path}.source_ids[{position}]") + if source_id not in source_ids: + raise CompatibilityProfileError( + f"{path}.source_ids[{position}] references unknown evidence source" + ) + for key, child in value.items(): + _validate_source_references(child, source_ids, f"{path}.{key}") + elif isinstance(value, list): + for position, child in enumerate(value): + _validate_source_references(child, source_ids, f"{path}[{position}]") + + +def _validate_transports(profile: dict[str, Any]) -> None: + transports = _unique_index( + _array(profile.get("transports"), "$.transports"), + "$.transports", + ) + if set(transports) != { + "ble.wifi-bootstrap.fw3.v1", + "mqtt.direct-lan.fw3.v1", + "rtsp.camera-preview.fw3.v1", + }: + raise CompatibilityProfileError("$.transports must contain only the reviewed v1 transports") + + ble = transports["ble.wifi-bootstrap.fw3.v1"] + if ble.get("service_uuid") != "00007f00-0000-1000-8000-00805f9b34fb": + raise CompatibilityProfileError("BLE service UUID differs from reviewed evidence") + characteristics = _object(ble.get("characteristics"), "$.transports[ble].characteristics") + if characteristics != { + "wifi_request": "00007f01-0000-1000-8000-00805f9b34fb", + "wifi_status": "00007f02-0000-1000-8000-00805f9b34fb", + }: + raise CompatibilityProfileError("BLE characteristic UUIDs differ from reviewed evidence") + if ble.get("request_frame_bytes") != 99: + raise CompatibilityProfileError("BLE provisioning frame must remain exactly 99 bytes") + _expect_evidence( + ble, + "$.transports[ble.wifi-bootstrap.fw3.v1]", + observed=True, + decoded=True, + replay_verified=False, + physical_verified=True, + ) + + mqtt = transports["mqtt.direct-lan.fw3.v1"] + if mqtt.get("protocol") != "MQTT 3.1.1": + raise CompatibilityProfileError("direct-LAN application protocol must remain MQTT 3.1.1") + network = _object(mqtt.get("network"), "$.transports[mqtt].network") + if network.get("transport") != "TCP" or network.get("port") != 1883: + raise CompatibilityProfileError("direct-LAN MQTT endpoint must remain TCP 1883") + if network.get("tls") is not False or network.get("authentication") != "none-observed": + raise CompatibilityProfileError("direct-LAN MQTT security claim differs from observation") + allowlist = _array( + mqtt.get("subscription_allowlist"), + "$.transports[mqtt].subscription_allowlist", + ) + if "lixel/application/report/#" not in allowlist: + raise CompatibilityProfileError("MQTT report-topic allowlist is missing") + for topic in allowlist: + topic = _string(topic, "$.transports[mqtt].subscription_allowlist[]") + if "/request/" in topic: + raise CompatibilityProfileError( + "request topics cannot enter the subscribe-only profile" + ) + _expect_evidence( + mqtt, + "$.transports[mqtt.direct-lan.fw3.v1]", + observed=True, + decoded=False, + replay_verified=False, + physical_verified=True, + ) + + rtsp = transports["rtsp.camera-preview.fw3.v1"] + if rtsp.get("protocol") != "RTSP 1.0 with interleaved RTP over TCP": + raise CompatibilityProfileError("camera-preview protocol differs from observation") + rtsp_network = _object(rtsp.get("network"), "$.transports[rtsp].network") + if rtsp_network.get("transport") != "TCP" or rtsp_network.get("port") != 8554: + raise CompatibilityProfileError("camera-preview endpoint must remain TCP 8554") + if ( + rtsp_network.get("tls") is not False + or rtsp_network.get("authentication") != "none-observed" + ): + raise CompatibilityProfileError("camera-preview security differs from observation") + media = _object(rtsp.get("media"), "$.transports[rtsp].media") + if media != { + "codec": "H.264", + "rtp_payload_type": 96, + "clock_hz": 90000, + "framing": "RTP/AVP/TCP interleaved channels 0-1", + }: + raise CompatibilityProfileError("camera-preview media contract differs from observation") + _expect_evidence( + rtsp, + "$.transports[rtsp.camera-preview.fw3.v1]", + observed=True, + decoded=False, + replay_verified=False, + physical_verified=True, + ) + + +def _validate_channels(profile: dict[str, Any]) -> None: + channels = _unique_index(_array(profile.get("channels"), "$.channels"), "$.channels") + expected_ids = { + "spatial.point-cloud.live", + "spatial.pose.live", + "device.status.live", + "device.heartbeat.live", + "camera.preview.live", + } + if set(channels) != expected_ids: + raise CompatibilityProfileError("$.channels differs from the reviewed v1 channel set") + + point = channels["spatial.point-cloud.live"] + if point.get("topic") != "lixel/application/report/lio_pcl": + raise CompatibilityProfileError("point-cloud topic differs from reviewed evidence") + _expect_evidence( + point, + "$.channels[spatial.point-cloud.live]", + observed=True, + decoded=True, + replay_verified=True, + physical_verified=True, + ) + + pose = channels["spatial.pose.live"] + if pose.get("topic") != "lixel/application/report/lio_pose": + raise CompatibilityProfileError("pose topic differs from reviewed evidence") + _expect_evidence( + pose, + "$.channels[spatial.pose.live]", + observed=True, + decoded=True, + replay_verified=True, + physical_verified=True, + ) + + for channel_id, topic in ( + ("device.status.live", "lixel/application/report/device_status"), + ("device.heartbeat.live", "lixel/application/report/heartbeat"), + ): + channel = channels[channel_id] + if channel.get("topic") != topic or channel.get("semantic_payload") is not None: + raise CompatibilityProfileError(f"{channel_id} must remain raw-only in v1") + _expect_evidence( + channel, + f"$.channels[{channel_id}]", + observed=True, + decoded=False, + replay_verified=False, + physical_verified=True, + ) + + camera = channels["camera.preview.live"] + if camera.get("discovery_status") != "observed": + raise CompatibilityProfileError("camera discovery must match the owner-controlled run") + if camera.get("topic") is not None: + raise CompatibilityProfileError("camera preview is not an MQTT topic") + expected_endpoints = { + "rtsp://{confirmed-device-private-ipv4}:8554/live/chn_left_main", + "rtsp://{confirmed-device-private-ipv4}:8554/live/chn_right_main", + } + endpoints = _array(camera.get("endpoint_templates"), "$.channels[camera].endpoint_templates") + if set(endpoints) != expected_endpoints or len(endpoints) != len(expected_endpoints): + raise CompatibilityProfileError("camera endpoint templates differ from observation") + if camera.get("wire_format") != "RTSP 1.0, interleaved RTP/TCP, H.264 PT96 at 90000 Hz": + raise CompatibilityProfileError("camera wire format differs from observation") + _string(camera.get("semantic_payload"), "$.channels[camera].semantic_payload") + _expect_evidence( + camera, + "$.channels[camera.preview.live]", + observed=True, + decoded=False, + replay_verified=False, + physical_verified=True, + ) + + +def _validate_acquisition_control(profile: dict[str, Any]) -> None: + control = _object(profile.get("acquisition_control"), "$.acquisition_control") + if control.get("mode") != "operator-manual" or control.get("write_enabled") is not False: + raise CompatibilityProfileError("acquisition control must remain operator-manual") + + device_control = _object( + control.get("verified_device_control"), + "$.acquisition_control.verified_device_control", + ) + if device_control.get("gesture") != "physical-double-click": + raise CompatibilityProfileError("physical acquisition gesture differs from lab evidence") + _expect_evidence( + device_control, + "$.acquisition_control.verified_device_control", + observed=True, + decoded=False, + replay_verified=False, + physical_verified=True, + ) + + actions = _unique_index( + _array(control.get("semantic_actions"), "$.acquisition_control.semantic_actions"), + "$.acquisition_control.semantic_actions", + ) + if set(actions) != { + "acquisition.start", + "acquisition.stop", + "calibration.device.start", + }: + raise CompatibilityProfileError("semantic action set differs from reviewed v1") + + for action_id, action_value in (("acquisition.start", 1), ("acquisition.stop", 2)): + action = actions[action_id] + if action.get("execution") != "operator-manual": + raise CompatibilityProfileError(f"{action_id} must remain operator-manual") + mapping = _object( + action.get("vendor_request_mapping"), + f"$.acquisition_control.semantic_actions[{action_id}].vendor_request_mapping", + ) + if mapping.get("evidence_kind") != "owner-controlled-wire-observation": + raise CompatibilityProfileError(f"{action_id} vendor mapping differs from evidence") + if mapping.get("topic") != "lixel/application/request/modeling": + raise CompatibilityProfileError( + f"{action_id} vendor topic differs from static evidence" + ) + if mapping.get("qos") != 2 or mapping.get("action_field_value") != action_value: + raise CompatibilityProfileError( + f"{action_id} vendor mapping differs from static evidence" + ) + if not _array( + mapping.get("required_unresolved_context"), + f"$.acquisition_control.semantic_actions[{action_id}].required_unresolved_context", + ): + raise CompatibilityProfileError(f"{action_id} must declare unresolved request context") + _expect_evidence( + mapping, + f"$.acquisition_control.semantic_actions[{action_id}].vendor_request_mapping", + observed=True, + decoded=True, + replay_verified=False, + physical_verified=True, + ) + _expect_evidence( + action, + f"$.acquisition_control.semantic_actions[{action_id}]", + observed=True, + decoded=True, + replay_verified=False, + physical_verified=True, + ) + + calibration = actions["calibration.device.start"] + if calibration.get("execution") != "unavailable": + raise CompatibilityProfileError("calibration must remain unavailable in v1") + if calibration.get("vendor_request_mapping") is not None: + raise CompatibilityProfileError("calibration vendor request is not evidenced") + _expect_evidence( + calibration, + "$.acquisition_control.semantic_actions[calibration.device.start]", + observed=False, + decoded=False, + replay_verified=False, + physical_verified=False, + ) + + +def validate_compatibility_profile(profile: Any) -> dict[str, Any]: + """Validate and return one read-only firmware-3/direct-LAN profile object.""" + root = _object(profile, "$") + if root.get("schema_version") != PROFILE_SCHEMA_VERSION: + raise CompatibilityProfileError("unsupported compatibility profile schema_version") + if root.get("profile_id") != DEFAULT_PROFILE_ID: + raise CompatibilityProfileError("unexpected compatibility profile_id") + + scope = _object(root.get("scope"), "$.scope") + firmware = _object(scope.get("firmware"), "$.scope.firmware") + if firmware != {"match": "exact", "version": "3.0.2"}: + raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly") + if scope.get("topology") != "direct-lan": + raise CompatibilityProfileError("profile topology must remain direct-lan") + + vocabulary = _object(root.get("evidence_vocabulary"), "$.evidence_vocabulary") + if set(vocabulary) != set(EVIDENCE_FLAGS): + raise CompatibilityProfileError("evidence vocabulary differs from schema v1") + for flag in EVIDENCE_FLAGS: + _string(vocabulary[flag], f"$.evidence_vocabulary.{flag}") + + safety = _object(root.get("safety"), "$.safety") + if safety.get("default_mode") != "read-only": + raise CompatibilityProfileError("profile default mode must remain read-only") + if safety.get("vendor_writes_enabled") is not False: + raise CompatibilityProfileError("profile must not enable vendor writes") + if safety.get("unknown_firmware_policy") != "reject-profile": + raise CompatibilityProfileError("unknown firmware must fail closed") + if safety.get("request_topic_subscription_enabled") is not False: + raise CompatibilityProfileError("request-topic subscription must remain disabled") + + source_ids = _validate_sources(root) + _validate_source_references(root, source_ids) + _walk_and_validate_evidence(root) + _validate_transports(root) + _validate_channels(root) + _validate_acquisition_control(root) + return root + + +def load_compatibility_profile(path: Path | str = DEFAULT_PROFILE_PATH) -> dict[str, Any]: + """Load a JSON profile, rejecting duplicate keys and unreviewed claims.""" + resolved = Path(path).expanduser().resolve() + + def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise CompatibilityProfileError(f"duplicate JSON key {key!r}") + result[key] = value + return result + + try: + raw = resolved.read_text(encoding="utf-8") + except OSError as exc: + raise CompatibilityProfileError(f"cannot read compatibility profile: {resolved}") from exc + try: + profile = json.loads(raw, object_pairs_hook=reject_duplicate_keys) + except json.JSONDecodeError as exc: + raise CompatibilityProfileError(f"invalid compatibility profile JSON: {exc}") from exc + return validate_compatibility_profile(profile) + + +def matches_target( + profile: dict[str, Any], + *, + firmware: str, + topology: str, +) -> bool: + """Return whether an already validated exact-match profile covers the target.""" + validated = validate_compatibility_profile(profile) + scope = validated["scope"] + return scope["firmware"]["version"] == firmware and scope["topology"] == topology + + +def _main() -> int: + parser = argparse.ArgumentParser(description="Validate an XGRIDS K1 compatibility profile") + parser.add_argument("path", nargs="?", type=Path, default=DEFAULT_PROFILE_PATH) + args = parser.parse_args() + profile = load_compatibility_profile(args.path) + print(profile["profile_id"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/plugins/xgrids-k1/profiles/README.md b/plugins/xgrids-k1/profiles/README.md new file mode 100644 index 0000000..aadd3b9 --- /dev/null +++ b/plugins/xgrids-k1/profiles/README.md @@ -0,0 +1,70 @@ +# XGRIDS K1 compatibility profiles + +This directory is the plugin-local, versioned compatibility vocabulary for +verified K1 wire behavior. It is deliberately **not** a platform ontology and +does not add runtime dependencies on NODE.DC Ontology Core. + +The first profile is +[`fw-3.0.2/direct-lan.v1.json`](fw-3.0.2/direct-lan.v1.json). It matches exactly: + +- model: XGRIDS LixelKity K1; +- firmware: `3.0.2`; +- topology: K1 and connector on the same owner-controlled LAN; +- evidence scope: one physical scanner across controlled direct-LAN and + owner-operated LixelGO/iPhone laboratory runs. + +It must not be applied to another firmware or treated as a vendor API claim. +Unknown firmware fails closed. + +## Evidence flags + +Every transport, data channel, and semantic action uses five independent +boolean flags: + +| Flag | Meaning | +| --- | --- | +| `observed` | Directly seen on the owner-controlled K1 | +| `decoded` | A bounded semantic decoder exists for observed bytes | +| `replay_verified` | Captured semantic data passed the replay-to-view path | +| `physical_verified` | Correlated with a controlled physical state/action | +| `write_enabled` | The profile grants emission of a state-changing request | + +`decoded` does not mean that MQTT framing alone was parsed. Status and heartbeat +are therefore observed raw channels, not decoded status models. Camera preview +transport is observed, but its media decoder/replay flags remain independent. + +The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire +capture verifies the `ModelingRequest` topic and action values, but complete +device/session/OpenAPI header construction, settings, save completion, timeout +and rollback contracts remain unresolved. Acquisition therefore stays +`operator-manual` through the verified physical double-click. + +The existing BLE Wi-Fi provisioning workflow has its own reviewed profile and +operator confirmation. Merely loading this compatibility profile neither calls +nor authorizes that legacy mutation path. + +## Validation + +The loader uses only the Python standard library and performs no device I/O: + +```bash +python plugins/xgrids-k1/profile_loader.py +``` + +It verifies the exact firmware/topology scope, evidence vocabulary, source +references, GATT UUIDs, MQTT subscribe-only boundary, channel evidence, camera +RTSP/H.264 endpoints, operator-manual acquisition, and disabled vendor request +mappings. + +## Evolution rules + +1. A different firmware or topology gets a new profile file and profile ID. +2. New evidence may only promote the flags supported by retained raw evidence, + a documented decoder/replay check, or a physical lab report. +3. Unknown fields remain explicit; they are never filled from naming or payload + shape alone. +4. Breaking profile semantics require a new `schema_version` and validator. +5. Sensitive packet captures, device identities, addresses and credentials stay + outside Git; redacted reports and hashes are the committed evidence links. +6. A profile describes compatibility. Host authorization and transport writes + remain separate policy and execution concerns. diff --git a/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json b/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json new file mode 100644 index 0000000..feb5794 --- /dev/null +++ b/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json @@ -0,0 +1,406 @@ +{ + "schema_version": 1, + "profile_id": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1", + "profile_status": "experimental-evidence-backed", + "scope": { + "vendor": "XGRIDS", + "model": "LixelKity K1", + "firmware": { + "match": "exact", + "version": "3.0.2" + }, + "topology": "direct-lan", + "claim_limit": "One owner-controlled K1 on exact firmware 3.0.2 across controlled direct-LAN and owner-operated LixelGO/iPhone laboratory runs. This is not a vendor API or a cross-firmware compatibility claim." + }, + "evidence_vocabulary": { + "observed": "The channel, transport, or physical behavior was directly seen on the owner-controlled K1.", + "decoded": "A bounded decoder produces the stated semantic payload from observed bytes; transport framing alone is not a semantic decode.", + "replay_verified": "A captured payload of this semantic channel has passed the repository replay-to-view path.", + "physical_verified": "The result was correlated with a controlled physical K1 state or operator action.", + "write_enabled": "This profile authorizes software to emit the state-changing vendor request. False never grants runtime write authority." + }, + "safety": { + "default_mode": "read-only", + "vendor_writes_enabled": false, + "unknown_firmware_policy": "reject-profile", + "request_topic_subscription_enabled": false, + "notes": [ + "Loading this descriptive profile does not authorize a BLE or MQTT write.", + "The existing reviewed Wi-Fi provisioning procedure remains separately operator-confirmed and is not activated by this profile.", + "Observed LixelGO modeling requests describe the wire contract but remain non-replayable and write-disabled.", + "Unknown firmware, transport, topics, fields, and action responses fail closed." + ] + }, + "evidence_sources": [ + { + "id": "wifi-provisioning-profile", + "kind": "reviewed-profile", + "path": "docs/04_K1_WIFI_PROVISIONING_PROFILE.md", + "scope": "Observed firmware, GATT UUIDs, 99-byte provisioning frame, status read and physical LAN association." + }, + { + "id": "mqtt-stream-profile", + "kind": "reviewed-profile", + "path": "docs/05_K1_MQTT_STREAM_PROFILE.md", + "scope": "Direct-LAN MQTT transport, report topics, bounded point/pose codecs and static modeling-request mapping." + }, + { + "id": "lab-001", + "kind": "redacted-physical-lab-report", + "path": "docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md", + "scope": "Physical BLE-to-Wi-Fi result, MQTT message counts, point/pose decode totals and negative camera observation." + }, + { + "id": "live-viewer-profile", + "kind": "implemented-path-description", + "path": "docs/06_K1_LIVE_VIEWER.md", + "scope": "Raw-first live/replay path for point cloud and pose." + }, + { + "id": "lab-002", + "kind": "redacted-physical-protocol-report", + "path": "docs/lab/002_LIXELGO_IPHONE_LOCAL_PROTOCOL_20260716.redacted.md", + "scope": "Owner-operated LixelGO start/stop mapping, local-only bounded traffic result and left/right RTSP/H.264 camera-preview discovery." + } + ], + "transports": [ + { + "id": "ble.wifi-bootstrap.fw3.v1", + "role": "bootstrap-and-status", + "protocol": "BLE GATT", + "service_uuid": "00007f00-0000-1000-8000-00805f9b34fb", + "characteristics": { + "wifi_request": "00007f01-0000-1000-8000-00805f9b34fb", + "wifi_status": "00007f02-0000-1000-8000-00805f9b34fb" + }, + "reviewed_profile_id": "xgrids-k1-fw3-wifi-v1", + "request_frame_bytes": 99, + "status_semantics": { + "ap_baseline_ipv4": "192.168.56.1", + "lan_acceptance": "A non-AP private IPv4 must be observed and independently confirmed on the intended LAN." + }, + "evidence": { + "observed": true, + "decoded": true, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "wifi-provisioning-profile", + "lab-001" + ] + }, + { + "id": "mqtt.direct-lan.fw3.v1", + "role": "report-data-plane", + "protocol": "MQTT 3.1.1", + "network": { + "transport": "TCP", + "port": 1883, + "tls": false, + "authentication": "none-observed", + "addressing": "confirmed-device-private-ipv4-only" + }, + "subscription_allowlist": [ + "lixel/application/report/#", + "RealtimePointcloud", + "RealtimePath", + "DeviceStatus" + ], + "evidence": { + "observed": true, + "decoded": false, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "mqtt-stream-profile", + "lab-001", + "live-viewer-profile", + "lab-002" + ] + }, + { + "id": "rtsp.camera-preview.fw3.v1", + "role": "camera-preview-data-plane", + "protocol": "RTSP 1.0 with interleaved RTP over TCP", + "network": { + "transport": "TCP", + "port": 8554, + "tls": false, + "authentication": "none-observed", + "addressing": "confirmed-device-private-ipv4-only" + }, + "media": { + "codec": "H.264", + "rtp_payload_type": 96, + "clock_hz": 90000, + "framing": "RTP/AVP/TCP interleaved channels 0-1" + }, + "evidence": { + "observed": true, + "decoded": false, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "lab-002" + ] + } + ], + "channels": [ + { + "id": "spatial.point-cloud.live", + "kind": "point-cloud", + "direction": "device-report", + "topic": "lixel/application/report/lio_pcl", + "wire_format": "protobuf MqttCompressMsg containing raw-LZ4 LioPclReport", + "semantic_payload": "metric XYZ, complete uint32 rgbi and verified low-byte intensity", + "bounds": { + "max_mqtt_payload_bytes": 2097152, + "max_compressed_bytes": 1048576, + "max_decoded_bytes": 8388608, + "max_expansion_ratio": 64, + "max_points_per_frame": 250000 + }, + "unverified_fields": [ + "upper 24 bits of rgbi as RGB", + "sensor timestamp epoch", + "sensor-to-vehicle extrinsics" + ], + "evidence": { + "observed": true, + "decoded": true, + "replay_verified": true, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "mqtt-stream-profile", + "lab-001", + "live-viewer-profile" + ] + }, + { + "id": "spatial.pose.live", + "kind": "pose", + "direction": "device-report", + "topic": "lixel/application/report/lio_pose", + "wire_format": "protobuf LioPoseReport", + "semantic_payload": "position XYZ, quaternion XYZW, distance and pose accuracy", + "unverified_fields": [ + "sensor timestamp epoch", + "coordinate-frame convention", + "sensor-to-vehicle extrinsics" + ], + "evidence": { + "observed": true, + "decoded": true, + "replay_verified": true, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "mqtt-stream-profile", + "lab-001", + "live-viewer-profile" + ] + }, + { + "id": "device.status.live", + "kind": "device-status", + "direction": "device-report", + "topic": "lixel/application/report/device_status", + "wire_format": "opaque bytes", + "semantic_payload": null, + "limitations": [ + "The report was physically observed, but no bounded semantic device-status decoder is implemented.", + "Raw capture is evidence; field names or meanings must not be inferred from payload shape." + ], + "evidence": { + "observed": true, + "decoded": false, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "mqtt-stream-profile", + "lab-001" + ] + }, + { + "id": "device.heartbeat.live", + "kind": "heartbeat", + "direction": "device-report", + "topic": "lixel/application/report/heartbeat", + "wire_format": "opaque bytes", + "semantic_payload": null, + "limitations": [ + "Only channel presence and approximate report cadence are verified.", + "Payload semantics are not decoded." + ], + "evidence": { + "observed": true, + "decoded": false, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "mqtt-stream-profile", + "lab-001" + ] + }, + { + "id": "camera.preview.live", + "kind": "camera-preview", + "direction": "device-report", + "discovery_status": "observed", + "topic": null, + "endpoint_templates": [ + "rtsp://{confirmed-device-private-ipv4}:8554/live/chn_left_main", + "rtsp://{confirmed-device-private-ipv4}:8554/live/chn_right_main" + ], + "wire_format": "RTSP 1.0, interleaved RTP/TCP, H.264 PT96 at 90000 Hz", + "semantic_payload": "compressed live left/right camera preview selected by endpoint", + "limitations": [ + "No full-resolution raw frame, camera calibration or panorama-stitching contract is verified.", + "Left/right optical identity is supported by endpoint labels and operator-selected application views, not an independent image-content fixture.", + "A bounded Mission Core camera decoder and replay fixture are not yet implemented." + ], + "evidence": { + "observed": true, + "decoded": false, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "lab-002" + ] + } + ], + "acquisition_control": { + "mode": "operator-manual", + "write_enabled": false, + "verified_device_control": { + "gesture": "physical-double-click", + "state_dependent_result": "start from steady-green standby; stop during active scanning", + "single_click_result": "not a verified scan-start action", + "evidence": { + "observed": true, + "decoded": false, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "lab-001" + ] + }, + "semantic_actions": [ + { + "id": "acquisition.start", + "execution": "operator-manual", + "operator_control": "physical-double-click from steady-green standby", + "observed_application_control": "owner-operated LixelGO project confirmation", + "vendor_request_mapping": { + "evidence_kind": "owner-controlled-wire-observation", + "transport": "MQTT 3.1.1", + "topic": "lixel/application/request/modeling", + "qos": 2, + "message_type": "ModelingRequest", + "action_field_value": 1, + "required_unresolved_context": [ + "complete device/session/OpenAPI header construction", + "project/record/scan/mount setting semantics and safe defaults", + "timeout, rejection and rollback contract" + ], + "evidence": { + "observed": true, + "decoded": true, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "write_enabled": false + }, + "evidence": { + "observed": true, + "decoded": true, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "mqtt-stream-profile", + "lab-001", + "lab-002" + ] + }, + { + "id": "acquisition.stop", + "execution": "operator-manual", + "operator_control": "physical-double-click during active scanning", + "observed_application_control": "owner-operated LixelGO stop confirmation", + "vendor_request_mapping": { + "evidence_kind": "owner-controlled-wire-observation", + "transport": "MQTT 3.1.1", + "topic": "lixel/application/request/modeling", + "qos": 2, + "message_type": "ModelingRequest", + "action_field_value": 2, + "required_unresolved_context": [ + "complete device/session/OpenAPI header construction", + "save-completion and final-standby state mapping", + "timeout and rollback contract" + ], + "evidence": { + "observed": true, + "decoded": true, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "write_enabled": false + }, + "evidence": { + "observed": true, + "decoded": true, + "replay_verified": false, + "physical_verified": true, + "write_enabled": false + }, + "source_ids": [ + "mqtt-stream-profile", + "lab-001", + "lab-002" + ] + }, + { + "id": "calibration.device.start", + "execution": "unavailable", + "operator_control": null, + "vendor_request_mapping": null, + "observed_behavior": "Static initialization follows acquisition.start; no independent calibration action was observed.", + "limitations": [ + "No standalone calibration command topic, request schema, acknowledgment or state transition is verified." + ], + "evidence": { + "observed": false, + "decoded": false, + "replay_verified": false, + "physical_verified": false, + "write_enabled": false + }, + "source_ids": [ + "lab-002" + ] + } + ] + } +} diff --git a/src/k1link/analyze/stream_summary.py b/src/k1link/analyze/stream_summary.py index ffa1340..9adf504 100644 --- a/src/k1link/analyze/stream_summary.py +++ b/src/k1link/analyze/stream_summary.py @@ -234,8 +234,7 @@ def summarize_mqtt_streams( """Stream a raw MQTT capture into an aggregate-only, coordinate-free summary.""" if not 1 <= max_payload_bytes <= MAX_STREAM_SUMMARY_PAYLOAD_BYTES: raise ValueError( - "max_payload_bytes must be between 1 and " - f"{MAX_STREAM_SUMMARY_PAYLOAD_BYTES}" + f"max_payload_bytes must be between 1 and {MAX_STREAM_SUMMARY_PAYLOAD_BYTES}" ) capture_path = capture.expanduser() diff --git a/src/k1link/ble/wifi_provisioning.py b/src/k1link/ble/wifi_provisioning.py index 833106c..27b0c5a 100644 --- a/src/k1link/ble/wifi_provisioning.py +++ b/src/k1link/ble/wifi_provisioning.py @@ -179,9 +179,7 @@ async def provision_wifi_once( async with BleakClient(device, timeout=timeout_seconds, pair=False) as client: device_name = client.name service = client.services.get_service(SERVICE_UUID) - write_characteristic = client.services.get_characteristic( - WRITE_CHARACTERISTIC_UUID - ) + write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID) status_characteristic = client.services.get_characteristic( STATUS_CHARACTERISTIC_UUID ) @@ -189,13 +187,11 @@ async def provision_wifi_once( raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}") if write_characteristic is None: raise ValueError( - "Reviewed K1 write characteristic not found: " - f"{WRITE_CHARACTERISTIC_UUID}" + f"Reviewed K1 write characteristic not found: {WRITE_CHARACTERISTIC_UUID}" ) if status_characteristic is None: raise ValueError( - "Reviewed K1 status characteristic not found: " - f"{STATUS_CHARACTERISTIC_UUID}" + f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}" ) if write_characteristic.service_uuid != service.uuid: raise ValueError("K1 write characteristic is attached to an unexpected service") @@ -207,9 +203,7 @@ async def provision_wifi_once( raise ValueError("Reviewed K1 status characteristic is not readable") properties = set(write_characteristic.properties) - max_without_response = ( - write_characteristic.max_write_without_response_size - ) + max_without_response = write_characteristic.max_write_without_response_size resolved_write_mode: ResolvedWriteMode if write_mode == "auto": if "write-without-response" in properties: @@ -276,9 +270,7 @@ async def provision_wifi_once( "status_characteristic_uuid": status_characteristic.uuid, "operation": "single_reviewed_wifi_provisioning_write", "write_mode": resolved_write_mode, - "write_without_response_advertised": ( - "write-without-response" in properties - ), + "write_without_response_advertised": ("write-without-response" in properties), "max_write_without_response_size": max_without_response, "frame_length": len(frame), "baseline_status": baseline, diff --git a/src/k1link/data_plane/__init__.py b/src/k1link/data_plane/__init__.py new file mode 100644 index 0000000..ce468dc --- /dev/null +++ b/src/k1link/data_plane/__init__.py @@ -0,0 +1,26 @@ +"""Decoded in-process projections consumed by Mission Core visualizers. + +These views are not wire contracts. Portable plugin/host envelopes live in the +versioned Plugin SDK; an extraction boundary can hydrate those envelopes into +these allocation-conscious representations for local consumers. +""" + +from k1link.data_plane.views import ( + ConsumerFrameContext, + DecodedDataPlaneView, + DecodedDeviceStatusView, + DecodedPointCloudView, + DecodedPoseView, + NormalizationError, + StatusAttribute, +) + +__all__ = [ + "ConsumerFrameContext", + "DecodedDataPlaneView", + "DecodedDeviceStatusView", + "DecodedPointCloudView", + "DecodedPoseView", + "NormalizationError", + "StatusAttribute", +] diff --git a/src/k1link/data_plane/views.py b/src/k1link/data_plane/views.py new file mode 100644 index 0000000..09e52d0 --- /dev/null +++ b/src/k1link/data_plane/views.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass + + +class NormalizationError(ValueError): + """A transport message matched a known channel but could not be normalized.""" + + +@dataclass(frozen=True, slots=True) +class ConsumerFrameContext: + """Transport-neutral provenance shared by decoded consumer views. + + Raw channel names and payloads deliberately do not cross this boundary. The + byte count is retained for operational metrics, while opaque device/session + aliases are optional because older streams do not carry them. They are + deliberately named as source aliases: a vendor header must never become a + Mission Core device identity merely by crossing the decode boundary. + """ + + sequence: int + captured_at_epoch_ns: int + received_monotonic_ns: int | None + processing_started_monotonic_ns: int + encoded_size_bytes: int + live: bool + source_device_alias: str | None = None + source_session_alias: str | None = None + + def __post_init__(self) -> None: + if self.sequence < 1: + raise ValueError("frame sequence must be positive") + if self.captured_at_epoch_ns < 0: + raise ValueError("capture time must be non-negative") + if self.received_monotonic_ns is not None and self.received_monotonic_ns < 0: + raise ValueError("receive monotonic time must be non-negative") + if self.processing_started_monotonic_ns < 0: + raise ValueError("processing start time must be non-negative") + if self.encoded_size_bytes < 0: + raise ValueError("encoded size must be non-negative") + + +@dataclass(frozen=True, slots=True) +class DecodedPointCloudView: + """In-process point-cloud projection in a named Cartesian frame. + + This is a consumer view, not the portable SDK ``PointCloudFrame`` contract. + """ + + context: ConsumerFrameContext + frame_id: str + positions_xyz: tuple[tuple[float, float, float], ...] + intensities: bytes | None = None + colors_rgb: bytes | None = None + + def __post_init__(self) -> None: + if not self.frame_id: + raise ValueError("point-cloud frame_id must not be empty") + point_count = len(self.positions_xyz) + if self.intensities is not None and len(self.intensities) != point_count: + raise ValueError("intensity count must equal point count") + if self.colors_rgb is not None and len(self.colors_rgb) != point_count * 3: + raise ValueError("RGB byte count must equal point count * 3") + if not all( + len(position) == 3 and all(math.isfinite(value) for value in position) + for position in self.positions_xyz + ): + raise ValueError("point positions must contain finite xyz triples") + + @property + def point_count(self) -> int: + return len(self.positions_xyz) + + +@dataclass(frozen=True, slots=True) +class DecodedPoseView: + """In-process pose projection in a named Cartesian coordinate frame.""" + + context: ConsumerFrameContext + frame_id: str + child_frame_id: str + position_xyz: tuple[float, float, float] + orientation_xyzw: tuple[float, float, float, float] + + def __post_init__(self) -> None: + if not self.frame_id or not self.child_frame_id: + raise ValueError("pose frame identifiers must not be empty") + values = (*self.position_xyz, *self.orientation_xyzw) + if not all(math.isfinite(value) for value in values): + raise ValueError("pose values must be finite") + + +@dataclass(frozen=True, slots=True) +class StatusAttribute: + """One stable, normalized status attribute.""" + + name: str + value: bool | int | float | str + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("status attribute name must not be empty") + if isinstance(self.value, float) and not math.isfinite(self.value): + raise ValueError("status attribute float must be finite") + + +@dataclass(frozen=True, slots=True) +class DecodedDeviceStatusView: + """An in-process device-status projection. + + The current verified K1 profile does not yet decode its status topic. The + view exists so future verified status codecs do not alter consumers. + """ + + context: ConsumerFrameContext + state: str + attributes: tuple[StatusAttribute, ...] = () + + def __post_init__(self) -> None: + if not self.state: + raise ValueError("device status state must not be empty") + names = [attribute.name for attribute in self.attributes] + if len(names) != len(set(names)): + raise ValueError("device status attribute names must be unique") + + +DecodedDataPlaneView = DecodedPointCloudView | DecodedPoseView | DecodedDeviceStatusView diff --git a/src/k1link/mqtt/capture.py b/src/k1link/mqtt/capture.py index 6f10282..5530f5e 100644 --- a/src/k1link/mqtt/capture.py +++ b/src/k1link/mqtt/capture.py @@ -130,7 +130,7 @@ class CaptureFrame: @dataclass(frozen=True, slots=True) class CapturedMqttMessage: - """A message made durable by the raw writer and ready for live preview.""" + """A message flushed to the raw writer before it is exposed to live preview.""" sequence: int topic: str diff --git a/src/k1link/protocol/normalizer.py b/src/k1link/protocol/normalizer.py new file mode 100644 index 0000000..0c99005 --- /dev/null +++ b/src/k1link/protocol/normalizer.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from typing import Protocol + +from k1link.data_plane import ( + ConsumerFrameContext, + DecodedDataPlaneView, + DecodedPointCloudView, + DecodedPoseView, + NormalizationError, +) +from k1link.protocol.streams import ( + LegacyPointCloudFrame, + LegacyPoseFrame, + LioPointCloudFrame, + LioPoseFrame, + StreamDecodeError, + decode_legacy_pointcloud, + decode_legacy_pose, + decode_lio_pcl, + decode_lio_pose, +) + +CANONICAL_MAP_FRAME = "map" +CANONICAL_SENSOR_FRAME = "sensor" + + +class K1TransportMessage(Protocol): + """Minimum raw transport shape accepted by the K1 normalizer.""" + + @property + def sequence(self) -> int: ... + + @property + def topic(self) -> str: ... + + @property + def payload(self) -> bytes: ... + + @property + def received_at_epoch_ns(self) -> int: ... + + @property + def received_monotonic_ns(self) -> int | None: ... + + @property + def source(self) -> str: ... + + +def normalize_k1_message( + message: K1TransportMessage, + *, + processing_started_monotonic_ns: int, +) -> DecodedDataPlaneView | None: + """Normalize one verified K1 transport message. + + Unknown channels intentionally return ``None``. A known channel with an + invalid payload raises a transport-neutral ``NormalizationError``. Neither + raw channel names nor raw bytes are retained in the returned consumer view. + """ + + try: + if message.topic.endswith("/lio_pcl"): + return _normalize_lio_points( + decode_lio_pcl(message.payload), + _context(message, processing_started_monotonic_ns), + ) + if message.topic == "RealtimePointcloud": + return _normalize_legacy_points( + decode_legacy_pointcloud(message.payload), + _context(message, processing_started_monotonic_ns), + ) + if message.topic.endswith("/lio_pose"): + return _normalize_lio_pose( + decode_lio_pose(message.payload), + _context(message, processing_started_monotonic_ns), + ) + if message.topic == "RealtimePath": + return _normalize_legacy_pose( + decode_legacy_pose(message.payload), + _context(message, processing_started_monotonic_ns), + ) + except StreamDecodeError as exc: + raise NormalizationError("known K1 data-plane message failed validation") from exc + return None + + +def _context( + message: K1TransportMessage, + processing_started_monotonic_ns: int, +) -> ConsumerFrameContext: + return ConsumerFrameContext( + sequence=message.sequence, + captured_at_epoch_ns=message.received_at_epoch_ns, + received_monotonic_ns=message.received_monotonic_ns, + processing_started_monotonic_ns=processing_started_monotonic_ns, + encoded_size_bytes=len(message.payload), + live=message.source == "live_mqtt", + ) + + +def _with_device_context( + context: ConsumerFrameContext, + *, + source_device_alias: str, + source_session_alias: str, +) -> ConsumerFrameContext: + return ConsumerFrameContext( + sequence=context.sequence, + captured_at_epoch_ns=context.captured_at_epoch_ns, + received_monotonic_ns=context.received_monotonic_ns, + processing_started_monotonic_ns=context.processing_started_monotonic_ns, + encoded_size_bytes=context.encoded_size_bytes, + live=context.live, + source_device_alias=source_device_alias or None, + source_session_alias=source_session_alias or None, + ) + + +def _normalize_lio_points( + frame: LioPointCloudFrame, + context: ConsumerFrameContext, +) -> DecodedPointCloudView: + context = _with_device_context( + context, + source_device_alias=frame.header.device_id, + source_session_alias=frame.header.session_id, + ) + positions = tuple(point.scaled_xyz(frame.header.scaler) for point in frame.points) + intensities = bytes(point.intensity for point in frame.points) + return DecodedPointCloudView( + context=context, + frame_id=CANONICAL_MAP_FRAME, + positions_xyz=positions, + intensities=intensities, + ) + + +def _normalize_legacy_points( + frame: LegacyPointCloudFrame, + context: ConsumerFrameContext, +) -> DecodedPointCloudView: + return DecodedPointCloudView( + context=context, + frame_id=CANONICAL_MAP_FRAME, + positions_xyz=tuple((point.x, point.y, point.z) for point in frame.points), + intensities=bytes(point.intensity for point in frame.points), + colors_rgb=bytes( + channel for point in frame.points for channel in (point.r, point.g, point.b) + ), + ) + + +def _normalize_lio_pose( + frame: LioPoseFrame, + context: ConsumerFrameContext, +) -> DecodedPoseView: + context = _with_device_context( + context, + source_device_alias=frame.header.device_id, + source_session_alias=frame.header.session_id, + ) + return DecodedPoseView( + context=context, + frame_id=CANONICAL_MAP_FRAME, + child_frame_id=CANONICAL_SENSOR_FRAME, + position_xyz=frame.position_xyz, + orientation_xyzw=frame.orientation_xyzw, + ) + + +def _normalize_legacy_pose( + frame: LegacyPoseFrame, + context: ConsumerFrameContext, +) -> DecodedPoseView: + return DecodedPoseView( + context=context, + frame_id=CANONICAL_MAP_FRAME, + child_frame_id=CANONICAL_SENSOR_FRAME, + position_xyz=frame.position_xyz, + orientation_xyzw=frame.orientation_xyzw, + ) diff --git a/src/k1link/viewer/foxglove_bridge.py b/src/k1link/viewer/foxglove_bridge.py index 2049b0d..20a02d8 100644 --- a/src/k1link/viewer/foxglove_bridge.py +++ b/src/k1link/viewer/foxglove_bridge.py @@ -1,13 +1,9 @@ from __future__ import annotations -import math -import statistics import struct -import threading import time from collections import deque from dataclasses import dataclass -from typing import TypedDict import foxglove from foxglove import Channel @@ -45,6 +41,7 @@ from k1link.protocol.streams import ( decode_lio_pose, ) from k1link.viewer.messages import StreamMessage +from k1link.viewer.metrics import BridgeMetrics POINT_STRUCT = struct.Struct(" None: - self._lock = threading.Lock() - self._messages_received = 0 - self._payload_bytes = 0 - self._pcl_frames = 0 - self._pose_frames = 0 - self._points_published = 0 - self._last_point_count = 0 - self._decode_errors = 0 - self._preview_dropped = 0 - self._trajectory_poses = 0 - self._pcl_times: deque[int] = deque() - self._pose_times: deque[int] = deque() - self._latencies_ms: deque[float] = deque(maxlen=512) - self._decode_publish_ms: float | None = None - - def received(self, payload_bytes: int) -> None: - with self._lock: - self._messages_received += 1 - self._payload_bytes += payload_bytes - - def published_pcl(self, point_count: int, now_ns: int, decode_publish_ms: float) -> None: - with self._lock: - self._pcl_frames += 1 - self._points_published += point_count - self._last_point_count = point_count - self._decode_publish_ms = decode_publish_ms - self._pcl_times.append(now_ns) - _trim_rate_window(self._pcl_times, now_ns) - - def published_pose(self, now_ns: int, decode_publish_ms: float, path_size: int) -> None: - with self._lock: - self._pose_frames += 1 - self._decode_publish_ms = decode_publish_ms - self._trajectory_poses = path_size - self._pose_times.append(now_ns) - _trim_rate_window(self._pose_times, now_ns) - - def record_latency(self, milliseconds: float) -> None: - if not math.isfinite(milliseconds) or milliseconds < 0: - return - with self._lock: - self._latencies_ms.append(milliseconds) - - def decode_error(self) -> None: - with self._lock: - self._decode_errors += 1 - - def preview_dropped(self) -> None: - with self._lock: - self._preview_dropped += 1 - - def snapshot(self) -> MetricsSnapshot: - now_ns = time.monotonic_ns() - with self._lock: - _trim_rate_window(self._pcl_times, now_ns) - _trim_rate_window(self._pose_times, now_ns) - latencies = list(self._latencies_ms) - last_latency = latencies[-1] if latencies else None - p50 = statistics.median(latencies) if latencies else None - p95 = _percentile(latencies, 0.95) if latencies else None - return { - "messages_received": self._messages_received, - "payload_bytes": self._payload_bytes, - "pcl_frames": self._pcl_frames, - "pose_frames": self._pose_frames, - "points_published": self._points_published, - "last_point_count": self._last_point_count, - "decode_errors": self._decode_errors, - "preview_dropped": self._preview_dropped, - "pcl_fps": _window_rate(self._pcl_times), - "pose_fps": _window_rate(self._pose_times), - "mqtt_to_publish_ms": _rounded(last_latency), - "mqtt_to_publish_p50_ms": _rounded(p50), - "mqtt_to_publish_p95_ms": _rounded(p95), - "decode_publish_ms": _rounded(self._decode_publish_ms), - "trajectory_poses": self._trajectory_poses, - } - - class FoxgloveBridge: """Decode verified K1 topics and publish Foxglove-native visualization messages.""" @@ -390,28 +286,3 @@ def _identity_pose() -> Pose: def _timestamp(epoch_ns: int) -> Timestamp: return Timestamp(epoch_ns // 1_000_000_000, epoch_ns % 1_000_000_000) - - -def _trim_rate_window(values: deque[int], now_ns: int) -> None: - cutoff = now_ns - 1_000_000_000 - while values and values[0] < cutoff: - values.popleft() - - -def _window_rate(values: deque[int]) -> float: - if len(values) < 2: - return float(len(values)) - elapsed = (values[-1] - values[0]) / 1_000_000_000 - return len(values) / max(elapsed, 1.0) - - -def _percentile(values: list[float], fraction: float) -> float: - if not values: - raise ValueError("cannot calculate a percentile of an empty sample") - ordered = sorted(values) - index = min(len(ordered) - 1, math.ceil(len(ordered) * fraction) - 1) - return ordered[index] - - -def _rounded(value: float | None) -> float | None: - return None if value is None else round(value, 3) diff --git a/src/k1link/viewer/metrics.py b/src/k1link/viewer/metrics.py new file mode 100644 index 0000000..ae9b699 --- /dev/null +++ b/src/k1link/viewer/metrics.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import math +import statistics +import threading +import time +from collections import deque +from typing import TypedDict + + +class MetricsSnapshot(TypedDict): + messages_received: int + payload_bytes: int + pcl_frames: int + pose_frames: int + points_published: int + last_point_count: int + decode_errors: int + preview_dropped: int + pcl_fps: float + pose_fps: float + mqtt_to_publish_ms: float | None + mqtt_to_publish_p50_ms: float | None + mqtt_to_publish_p95_ms: float | None + decode_publish_ms: float | None + trajectory_poses: int + + +class BridgeMetrics: + """Thread-safe counters shared by canonical visualization consumers.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._messages_received = 0 + self._payload_bytes = 0 + self._pcl_frames = 0 + self._pose_frames = 0 + self._points_published = 0 + self._last_point_count = 0 + self._decode_errors = 0 + self._preview_dropped = 0 + self._trajectory_poses = 0 + self._pcl_times: deque[int] = deque() + self._pose_times: deque[int] = deque() + self._latencies_ms: deque[float] = deque(maxlen=512) + self._decode_publish_ms: float | None = None + + def received(self, payload_bytes: int) -> None: + with self._lock: + self._messages_received += 1 + self._payload_bytes += payload_bytes + + def published_pcl(self, point_count: int, now_ns: int, decode_publish_ms: float) -> None: + with self._lock: + self._pcl_frames += 1 + self._points_published += point_count + self._last_point_count = point_count + self._decode_publish_ms = decode_publish_ms + self._pcl_times.append(now_ns) + _trim_rate_window(self._pcl_times, now_ns) + + def published_pose(self, now_ns: int, decode_publish_ms: float, path_size: int) -> None: + with self._lock: + self._pose_frames += 1 + self._decode_publish_ms = decode_publish_ms + self._trajectory_poses = path_size + self._pose_times.append(now_ns) + _trim_rate_window(self._pose_times, now_ns) + + def record_latency(self, milliseconds: float) -> None: + if not math.isfinite(milliseconds) or milliseconds < 0: + return + with self._lock: + self._latencies_ms.append(milliseconds) + + def decode_error(self) -> None: + with self._lock: + self._decode_errors += 1 + + def preview_dropped(self) -> None: + with self._lock: + self._preview_dropped += 1 + + def snapshot(self) -> MetricsSnapshot: + now_ns = time.monotonic_ns() + with self._lock: + _trim_rate_window(self._pcl_times, now_ns) + _trim_rate_window(self._pose_times, now_ns) + latencies = list(self._latencies_ms) + last_latency = latencies[-1] if latencies else None + p50 = statistics.median(latencies) if latencies else None + p95 = _percentile(latencies, 0.95) if latencies else None + return { + "messages_received": self._messages_received, + "payload_bytes": self._payload_bytes, + "pcl_frames": self._pcl_frames, + "pose_frames": self._pose_frames, + "points_published": self._points_published, + "last_point_count": self._last_point_count, + "decode_errors": self._decode_errors, + "preview_dropped": self._preview_dropped, + "pcl_fps": _window_rate(self._pcl_times), + "pose_fps": _window_rate(self._pose_times), + "mqtt_to_publish_ms": _rounded(last_latency), + "mqtt_to_publish_p50_ms": _rounded(p50), + "mqtt_to_publish_p95_ms": _rounded(p95), + "decode_publish_ms": _rounded(self._decode_publish_ms), + "trajectory_poses": self._trajectory_poses, + } + + +def _trim_rate_window(samples: deque[int], now_ns: int) -> None: + cutoff_ns = now_ns - 1_000_000_000 + while samples and samples[0] < cutoff_ns: + samples.popleft() + + +def _window_rate(samples: deque[int]) -> float: + if len(samples) < 2: + return float(len(samples)) + elapsed = (samples[-1] - samples[0]) / 1_000_000_000 + return len(samples) / max(elapsed, 1.0) + + +def _percentile(values: list[float], quantile: float) -> float: + if not values: + raise ValueError("values must not be empty") + ordered = sorted(values) + index = max(0, min(len(ordered) - 1, math.ceil(len(ordered) * quantile) - 1)) + return ordered[index] + + +def _rounded(value: float | None) -> float | None: + return None if value is None else round(value, 3) diff --git a/src/k1link/viewer/rerun_bridge.py b/src/k1link/viewer/rerun_bridge.py index f4e2c34..eb89ff4 100644 --- a/src/k1link/viewer/rerun_bridge.py +++ b/src/k1link/viewer/rerun_bridge.py @@ -11,19 +11,12 @@ import numpy as np import rerun as rr from rerun import blueprint as rrb -from k1link.protocol.streams import ( - LegacyPointCloudFrame, - LegacyPoseFrame, - LioPointCloudFrame, - LioPoseFrame, - StreamDecodeError, - decode_legacy_pointcloud, - decode_legacy_pose, - decode_lio_pcl, - decode_lio_pose, +from k1link.data_plane import ( + DecodedDataPlaneView, + DecodedPointCloudView, + DecodedPoseView, ) -from k1link.viewer.foxglove_bridge import BridgeMetrics -from k1link.viewer.messages import StreamMessage +from k1link.viewer.metrics import BridgeMetrics PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"] PointPalette = Literal["turbo", "viridis", "plasma", "grayscale", "custom"] @@ -59,7 +52,7 @@ SettingsProvider = Callable[[], RerunSceneSettings] class RerunBridge: - """Decode verified device topics into a self-hosted Rerun recording stream.""" + """Publish transport-neutral canonical envelopes to a Rerun recording.""" def __init__( self, @@ -73,9 +66,7 @@ class RerunBridge: self.metrics = metrics or BridgeMetrics() self._settings_provider = settings_provider or RerunSceneSettings self._settings = self._settings_provider() - self._recording = (recording_factory or rr.RecordingStream)( - "nodedc_mission_core_spatial" - ) + self._recording = (recording_factory or rr.RecordingStream)("nodedc_mission_core_spatial") blueprint = _blueprint(self._settings) self._url = self._recording.serve_grpc( grpc_port=grpc_port, @@ -95,9 +86,7 @@ class RerunBridge: rr.TransformAxes3D(axis_length=0.45, show_frame=True), static=True, ) - self._path: deque[tuple[float, float, float]] = deque( - maxlen=MAX_TRAJECTORY_POSES - ) + self._path: deque[tuple[float, float, float]] = deque(maxlen=MAX_TRAJECTORY_POSES) self._last_trajectory_publish_ns = 0 self._last_point_count = 0 self._closed = False @@ -130,33 +119,23 @@ class RerunBridge: make_default=True, ) - def process(self, message: StreamMessage) -> None: - started_ns = time.monotonic_ns() - self.metrics.received(len(message.payload)) + def process(self, envelope: DecodedDataPlaneView) -> None: self._apply_latest_settings() - self._set_message_time(message) + self._set_message_time(envelope) - try: - if message.topic.endswith("/lio_pcl"): - self._publish_lio_pcl(decode_lio_pcl(message.payload)) - point_frame = True - elif message.topic == "RealtimePointcloud": - self._publish_legacy_pcl(decode_legacy_pointcloud(message.payload)) - point_frame = True - elif message.topic.endswith("/lio_pose"): - self._publish_lio_pose(decode_lio_pose(message.payload)) - point_frame = False - elif message.topic == "RealtimePath": - self._publish_legacy_pose(decode_legacy_pose(message.payload)) - point_frame = False - else: - return - except StreamDecodeError: - self.metrics.decode_error() + if isinstance(envelope, DecodedPointCloudView): + self._publish_points(envelope) + point_frame = True + elif isinstance(envelope, DecodedPoseView): + self._publish_pose(envelope) + point_frame = False + else: return published_ns = time.monotonic_ns() - decode_publish_ms = (published_ns - started_ns) / 1_000_000 + decode_publish_ms = ( + published_ns - envelope.context.processing_started_monotonic_ns + ) / 1_000_000 if point_frame: self.metrics.published_pcl( self._last_point_count, @@ -169,10 +148,9 @@ class RerunBridge: decode_publish_ms, len(self._path), ) - if message.source == "live_mqtt" and message.received_monotonic_ns is not None: - self.metrics.record_latency( - (published_ns - message.received_monotonic_ns) / 1_000_000 - ) + context = envelope.context + if context.live and context.received_monotonic_ns is not None: + self.metrics.record_latency((published_ns - context.received_monotonic_ns) / 1_000_000) def close(self) -> None: if self._closed: @@ -187,13 +165,14 @@ class RerunBridge: # instead of waiting for the publisher thread frame to be collected. del self._recording - def _set_message_time(self, message: StreamMessage) -> None: + def _set_message_time(self, envelope: DecodedDataPlaneView) -> None: + context = envelope.context self._recording.set_time("stream_time", timestamp=time.time()) self._recording.set_time( "capture_time", - timestamp=message.received_at_epoch_ns / 1_000_000_000, + timestamp=context.captured_at_epoch_ns / 1_000_000_000, ) - self._recording.set_time("message_sequence", sequence=message.sequence) + self._recording.set_time("message_sequence", sequence=context.sequence) def _apply_latest_settings(self) -> None: settings = self._settings_provider() @@ -202,34 +181,17 @@ class RerunBridge: self._settings = settings self._recording.send_blueprint(_blueprint(settings)) - def _publish_lio_pcl(self, frame: LioPointCloudFrame) -> None: - count = len(frame.points) - positions = np.empty((count, 3), dtype=np.float32) - intensities = np.empty(count, dtype=np.uint8) - scaler = frame.header.scaler - for index, point in enumerate(frame.points): - positions[index] = point.scaled_xyz(scaler) - intensities[index] = point.intensity - self._publish_points(positions, intensities, rgb=None) - - def _publish_legacy_pcl(self, frame: LegacyPointCloudFrame) -> None: - count = len(frame.points) - positions = np.empty((count, 3), dtype=np.float32) - intensities = np.empty(count, dtype=np.uint8) - rgb = np.empty((count, 3), dtype=np.uint8) - for index, point in enumerate(frame.points): - positions[index] = (point.x, point.y, point.z) - intensities[index] = point.intensity - rgb[index] = (point.r, point.g, point.b) - self._publish_points(positions, intensities, rgb=rgb) - - def _publish_points( - self, - positions: np.ndarray, - intensities: np.ndarray, - *, - rgb: np.ndarray | None, - ) -> None: + def _publish_points(self, frame: DecodedPointCloudView) -> None: + positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3)) + if frame.intensities is None: + intensities = np.full(frame.point_count, 255, dtype=np.uint8) + else: + intensities = np.frombuffer(frame.intensities, dtype=np.uint8) + rgb = ( + None + if frame.colors_rgb is None + else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3)) + ) self._last_point_count = int(positions.shape[0]) if not self._settings.show_points: self._recording.log("/world/points", rr.Clear(recursive=False)) @@ -244,25 +206,15 @@ class RerunBridge: ), ) - def _publish_lio_pose(self, frame: LioPoseFrame) -> None: - self._publish_pose(frame.position_xyz, frame.orientation_xyzw) - - def _publish_legacy_pose(self, frame: LegacyPoseFrame) -> None: - self._publish_pose(frame.position_xyz, frame.orientation_xyzw) - - def _publish_pose( - self, - position_xyz: tuple[float, float, float], - orientation_xyzw: tuple[float, float, float, float], - ) -> None: + def _publish_pose(self, frame: DecodedPoseView) -> None: self._recording.log( "/world/sensor_pose", rr.Transform3D( - translation=position_xyz, - quaternion=rr.Quaternion(xyzw=orientation_xyzw), + translation=frame.position_xyz, + quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw), ), ) - self._path.append(position_xyz) + self._path.append(frame.position_xyz) if not self._settings.show_trajectory: self._recording.log("/world/trajectory", rr.Clear(recursive=False)) return @@ -283,6 +235,7 @@ class RerunBridge: ), ) + def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint: accumulation = max(0.0, settings.accumulation_seconds) time_range = rr.VisibleTimeRange( diff --git a/src/k1link/viewer/runtime.py b/src/k1link/viewer/runtime.py index 4d1a170..1158991 100644 --- a/src/k1link/viewer/runtime.py +++ b/src/k1link/viewer/runtime.py @@ -7,12 +7,13 @@ import time from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path -from typing import Literal, TypedDict +from typing import Literal, Protocol, TypedDict from k1link.artifacts import utc_now_iso, write_json_atomic +from k1link.data_plane import DecodedDataPlaneView, NormalizationError from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt -from k1link.viewer.foxglove_bridge import BridgeMetrics, MetricsSnapshot from k1link.viewer.messages import StreamMessage +from k1link.viewer.metrics import BridgeMetrics, MetricsSnapshot from k1link.viewer.replay import iter_replay_messages from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings @@ -29,10 +30,20 @@ StateCallback = Callable[[], None] BridgeFactory = Callable[..., RerunBridge] +class CanonicalNormalizer(Protocol): + def __call__( + self, + message: StreamMessage, + *, + processing_started_monotonic_ns: int, + ) -> DecodedDataPlaneView | None: ... + + class RuntimeSnapshot(TypedDict): phase: RuntimePhase message: str source_mode: SourceMode + source_ready: bool foxglove_ws_url: str | None foxglove_viewer_url: str | None rerun_grpc_url: str | None @@ -49,6 +60,7 @@ class VisualizationRuntime: on_state_change: StateCallback | None = None, grpc_port: int = DEFAULT_GRPC_PORT, bridge_factory: BridgeFactory | None = None, + normalizer: CanonicalNormalizer, ) -> None: self._lock = threading.Lock() self._on_state_change = on_state_change @@ -57,11 +69,13 @@ class VisualizationRuntime: self._phase: RuntimePhase = "idle" self._message = "Готово. Включите устройство и начните с поиска по Bluetooth." self._source_mode: SourceMode = "idle" + self._source_ready = False self._foxglove_ws_url: str | None = None self._foxglove_viewer_url: str | None = None self._rerun_grpc_url: str | None = None self._grpc_port = grpc_port self._bridge_factory = bridge_factory or RerunBridge + self._normalizer = normalizer self._bridge: RerunBridge | None = None self._closed = False self._scene_settings = RerunSceneSettings() @@ -73,6 +87,7 @@ class VisualizationRuntime: "phase": self._phase, "message": self._message, "source_mode": self._source_mode, + "source_ready": self._source_ready, "foxglove_ws_url": self._foxglove_ws_url, "foxglove_viewer_url": self._foxglove_viewer_url, "rerun_grpc_url": self._rerun_grpc_url, @@ -135,6 +150,7 @@ class VisualizationRuntime: if thread is None or not thread.is_alive(): self._phase = "idle" self._source_mode = "idle" + self._source_ready = False self._message = "Активного потока нет." notify_only = True else: @@ -161,6 +177,7 @@ class VisualizationRuntime: else: self._phase = "idle" self._source_mode = "idle" + self._source_ready = False self._message = "Локальный поток завершён." self._notify() @@ -194,17 +211,34 @@ class VisualizationRuntime: self._metrics = BridgeMetrics() self._phase = phase self._source_mode = source_mode + self._source_ready = False self._message = message self._foxglove_ws_url = None self._foxglove_viewer_url = None self._thread = threading.Thread( - target=target, + target=lambda: self._run_target_safely(target, source_mode), name=f"k1-{source_mode}-session", daemon=True, ) self._thread.start() self._notify() + def _run_target_safely( + self, + target: Callable[[], None], + source_mode: SourceMode, + ) -> None: + """Convert setup failures before the pipeline into observable runtime state.""" + + try: + target() + except BaseException as exc: + with self._lock: + closed = self._closed + if closed: + return + self._finish_error(f"Ошибка {source_mode}-источника: {type(exc).__name__}: {exc}") + def _run_replay(self, path: Path, *, speed: float, loop: bool) -> None: def produce(put: Callable[[StreamMessage], None]) -> str: while not self._stop_event.is_set(): @@ -324,8 +358,9 @@ class VisualizationRuntime: publisher_aborted.set() else: self._rerun_grpc_url = bridge.grpc_url - if not self._closed and self._phase != "stopping": + if running_phase == "replay" and not self._closed and self._phase != "stopping": self._phase = running_phase + self._source_ready = True self._message = "Локальный Rerun-мост готов; источник данных запущен." publisher_ready.set() self._notify() @@ -339,7 +374,18 @@ class VisualizationRuntime: except queue.Empty: continue try: - bridge.process(message) + processing_started_ns = time.monotonic_ns() + self._metrics.received(len(message.payload)) + try: + envelope = self._normalizer( + message, + processing_started_monotonic_ns=processing_started_ns, + ) + except NormalizationError: + self._metrics.decode_error() + else: + if envelope is not None: + bridge.process(envelope) finally: messages.task_done() if self._metrics.snapshot()["messages_received"] % 10 == 0: @@ -413,6 +459,7 @@ class VisualizationRuntime: with self._lock: if self._phase != "stopping": self._phase = phase + self._source_ready = True self._message = message self._notify() @@ -420,6 +467,7 @@ class VisualizationRuntime: with self._lock: self._phase = "idle" self._source_mode = "idle" + self._source_ready = False self._message = message self._foxglove_ws_url = None self._foxglove_viewer_url = None @@ -428,6 +476,7 @@ class VisualizationRuntime: def _finish_error(self, message: str) -> None: with self._lock: self._phase = "error" + self._source_ready = False self._message = message self._foxglove_ws_url = None self._foxglove_viewer_url = None diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 8f5e859..d0ea93a 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -6,7 +6,9 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Any -from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import ValidationError @@ -23,6 +25,7 @@ from k1link.web.plugin_runtime import ( ) REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +INVALID_REQUEST_DETAIL = "Некорректные параметры запроса." plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT) @@ -48,6 +51,16 @@ app = FastAPI( ) +@app.exception_handler(RequestValidationError) +async def request_validation_error_handler( + _: Request, + __: RequestValidationError, +) -> JSONResponse: + """Return validation failures without reflecting request values or credentials.""" + + return JSONResponse(status_code=422, content={"detail": INVALID_REQUEST_DETAIL}) + + @app.get("/api/health") def health() -> dict[str, Any]: return { @@ -86,7 +99,7 @@ async def invoke_device_plugin_action( except (PluginNotFoundError, PluginActionNotFoundError) as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except ValidationError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc + raise HTTPException(status_code=422, detail=INVALID_REQUEST_DETAIL) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except PluginExecutionError as exc: diff --git a/src/k1link/web/device_lifecycle.py b/src/k1link/web/device_lifecycle.py new file mode 100644 index 0000000..537c775 --- /dev/null +++ b/src/k1link/web/device_lifecycle.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +import threading +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import Any, Literal +from uuid import UUID, uuid4 + +OperationStatus = Literal[ + "accepted", + "running", + "operator_action_required", + "succeeded", + "failed", + "cancelled", + "timed_out", + "interrupted", +] +AcquisitionState = Literal[ + "preparing", + "prepared", + "awaiting_external_start", + "starting", + "acquiring", + "awaiting_external_stop", + "stopping", + "finalizing", + "completed", + "failed", + "aborted", + "interrupted", +] +ControlMode = Literal["operator-manual", "plugin-commanded", "observe-only"] + +TERMINAL_OPERATION_STATUSES: frozenset[OperationStatus] = frozenset( + {"succeeded", "failed", "cancelled", "timed_out", "interrupted"} +) +TERMINAL_ACQUISITION_STATES: frozenset[AcquisitionState] = frozenset( + {"completed", "failed", "aborted", "interrupted"} +) + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def _iso(value: datetime | None) -> str | None: + return value.isoformat().replace("+00:00", "Z") if value is not None else None + + +def _identifier(value: str | None, *, prefix: str) -> str: + if value is None: + return f"{prefix}-{uuid4()}" + candidate = value.strip() + if not candidate or len(candidate) > 128: + raise ValueError(f"{prefix} id must contain 1..128 characters") + try: + UUID(candidate.removeprefix(f"{prefix}-")) + except ValueError as exc: + raise ValueError(f"{prefix} id must be a generated UUID identifier") from exc + return candidate + + +@dataclass(slots=True) +class OperationRecord: + operation_id: str + action: str + status: OperationStatus + accepted_at: datetime + device_id: str | None = None + device_session_id: str | None = None + idempotency_key: str | None = None + deadline_at: datetime | None = None + stage_code: str = "accepted" + message_code: str = "operation.accepted" + sequence: int = 1 + state_revision: int = 1 + completed_at: datetime | None = None + cancellable: bool = False + cancel_requested: bool = False + result: dict[str, Any] | None = None + error: dict[str, Any] | None = None + evidence_refs: tuple[str, ...] = () + # A keyed, non-reversible digest supplied by the service. It is deliberately + # excluded from API snapshots: callers only need mismatch detection, while + # the journal must never retain action inputs or secret material. + request_fingerprint: str | None = field(default=None, repr=False) + + def as_dict(self) -> dict[str, Any]: + return { + "schema_version": "missioncore.operation-snapshot/v1alpha2", + "operation_id": self.operation_id, + "action": self.action, + "status": self.status, + "accepted_at": _iso(self.accepted_at), + "completed_at": _iso(self.completed_at), + "deadline_at": _iso(self.deadline_at), + "device_id": self.device_id, + "device_session_id": self.device_session_id, + "idempotency_key": self.idempotency_key, + "stage_code": self.stage_code, + "message_code": self.message_code, + "sequence": self.sequence, + "state_revision": self.state_revision, + "cancellable": self.cancellable, + "cancel_requested": self.cancel_requested, + "result": dict(self.result) if self.result is not None else None, + "error": dict(self.error) if self.error is not None else None, + "evidence_refs": list(self.evidence_refs), + } + + +class OperationJournal: + """Bounded, secret-free operation journal for one in-process plugin runtime. + + The journal deliberately stores lifecycle metadata only. Action inputs, BLE + frames, MQTT payloads and credentials never enter operation events. + """ + + def __init__( + self, + *, + max_records: int = 128, + clock: Callable[[], datetime] = utc_now, + ) -> None: + if max_records < 1: + raise ValueError("max_records must be positive") + self._max_records = max_records + self._clock = clock + self._lock = threading.Lock() + self._records: dict[str, OperationRecord] = {} + self._order: list[str] = [] + self._idempotency: dict[str, str] = {} + + def begin( + self, + action: str, + *, + operation_id: str | None = None, + idempotency_key: str | None = None, + device_id: str | None = None, + device_session_id: str | None = None, + deadline_seconds: float | None = None, + cancellable: bool = False, + request_fingerprint: str | None = None, + ) -> tuple[OperationRecord, bool]: + action = action.strip() + if not action: + raise ValueError("operation action cannot be blank") + if idempotency_key is not None: + idempotency_key = idempotency_key.strip() + if not idempotency_key or len(idempotency_key) > 160: + raise ValueError("idempotency key must contain 1..160 characters") + if deadline_seconds is not None and not 0 < deadline_seconds <= 86_400: + raise ValueError("operation deadline must be within 1..86400 seconds") + + with self._lock: + if idempotency_key is not None and idempotency_key in self._idempotency: + existing_idempotent = self._records[self._idempotency[idempotency_key]] + if existing_idempotent.action != action: + raise ValueError("idempotency key is already bound to another action") + if existing_idempotent.request_fingerprint != request_fingerprint: + raise ValueError("idempotency key is already bound to a different request") + return existing_idempotent, False + + resolved_id = _identifier(operation_id, prefix="op") + existing_by_id = self._records.get(resolved_id) + if existing_by_id is not None: + if existing_by_id.action != action: + raise ValueError("operation id is already bound to another action") + if existing_by_id.request_fingerprint != request_fingerprint: + raise ValueError("operation id is already bound to a different request") + return existing_by_id, False + + now = self._clock() + record = OperationRecord( + operation_id=resolved_id, + action=action, + status="accepted", + accepted_at=now, + device_id=device_id, + device_session_id=device_session_id, + idempotency_key=idempotency_key, + deadline_at=( + now + timedelta(seconds=deadline_seconds) + if deadline_seconds is not None + else None + ), + cancellable=cancellable, + request_fingerprint=request_fingerprint, + ) + self._records[resolved_id] = record + self._order.append(resolved_id) + if idempotency_key is not None: + self._idempotency[idempotency_key] = resolved_id + self._trim_locked() + return record, True + + def transition( + self, + operation_id: str, + status: OperationStatus, + *, + stage_code: str, + message_code: str, + result: Mapping[str, Any] | None = None, + error: Mapping[str, Any] | None = None, + evidence_refs: Iterable[str] = (), + ) -> OperationRecord: + with self._lock: + record = self._require_locked(operation_id) + if record.status in TERMINAL_OPERATION_STATUSES: + if record.status == status: + return record + raise ValueError(f"operation {operation_id} is already terminal") + record.status = status + record.stage_code = stage_code + record.message_code = message_code + record.sequence += 1 + record.state_revision += 1 + record.result = dict(result) if result is not None else None + record.error = dict(error) if error is not None else None + record.evidence_refs = tuple(evidence_refs) + if status in TERMINAL_OPERATION_STATUSES: + record.completed_at = self._clock() + self._trim_locked() + return record + + def request_cancel(self, operation_id: str) -> OperationRecord: + with self._lock: + record = self._require_locked(operation_id) + if record.status in TERMINAL_OPERATION_STATUSES: + return record + if not record.cancellable: + raise ValueError(f"operation {operation_id} is not cancellable") + record.cancel_requested = True + record.sequence += 1 + record.state_revision += 1 + record.stage_code = "cancellation-requested" + record.message_code = "operation.cancellation_requested" + return record + + def transition_if_pending( + self, + operation_id: str | None, + status: OperationStatus, + *, + stage_code: str, + message_code: str, + result: Mapping[str, Any] | None = None, + error: Mapping[str, Any] | None = None, + evidence_refs: Iterable[str] = (), + ) -> OperationRecord | None: + """Atomically transition an existing non-terminal operation. + + Lifecycle reconciliation and explicit stop/abort paths can race. This + helper makes terminalization idempotent without exposing mutable journal + records or turning an already-completed operation into an error. + """ + + with self._lock: + if operation_id is None: + return None + record = self._records.get(operation_id) + if record is None or record.status in TERMINAL_OPERATION_STATUSES: + return record + record.status = status + record.stage_code = stage_code + record.message_code = message_code + record.sequence += 1 + record.state_revision += 1 + record.result = dict(result) if result is not None else None + record.error = dict(error) if error is not None else None + record.evidence_refs = tuple(evidence_refs) + if status in TERMINAL_OPERATION_STATUSES: + record.completed_at = self._clock() + self._trim_locked() + return record + + def get(self, operation_id: str) -> OperationRecord: + with self._lock: + return self._require_locked(operation_id) + + def latest(self) -> OperationRecord | None: + with self._lock: + return self._records[self._order[-1]] if self._order else None + + def snapshot(self, *, limit: int = 20) -> list[dict[str, Any]]: + if limit < 1: + return [] + with self._lock: + return [self._records[item].as_dict() for item in self._order[-limit:]] + + def _require_locked(self, operation_id: str) -> OperationRecord: + try: + return self._records[operation_id] + except KeyError as exc: + raise KeyError(f"unknown operation: {operation_id}") from exc + + def _trim_locked(self) -> None: + while len(self._order) > self._max_records: + oldest_id = next( + ( + operation_id + for operation_id in self._order + if self._records[operation_id].status in TERMINAL_OPERATION_STATUSES + ), + None, + ) + # Never evict an operation that still needs reconciliation. A brief + # overrun is safer than turning a later device observation into an + # unknown-operation failure. + if oldest_id is None: + return + self._order.remove(oldest_id) + oldest = self._records.pop(oldest_id) + if oldest.idempotency_key is not None: + self._idempotency.pop(oldest.idempotency_key, None) + + +@dataclass(slots=True) +class AcquisitionRecord: + acquisition_id: str + device_id: str + device_session_id: str + compatibility_profile_id: str + control_mode: ControlMode + requested_streams: tuple[str, ...] + target_host: str + duration_seconds: float + evidence_policy: Literal["required", "best-effort", "disabled"] + state: AcquisitionState = "preparing" + state_revision: int = 1 + created_at: datetime = field(default_factory=utc_now) + updated_at: datetime = field(default_factory=utc_now) + message_code: str = "acquisition.preparing" + operator_instructions: tuple[str, ...] = () + result: dict[str, Any] | None = None + + def transition( + self, + state: AcquisitionState, + *, + message_code: str, + operator_instructions: Iterable[str] = (), + result: Mapping[str, Any] | None = None, + ) -> None: + if self.state in TERMINAL_ACQUISITION_STATES: + if self.state == state: + return + raise ValueError(f"acquisition {self.acquisition_id} is already terminal") + self.state = state + self.state_revision += 1 + self.updated_at = utc_now() + self.message_code = message_code + self.operator_instructions = tuple(operator_instructions) + self.result = dict(result) if result is not None else None + + def as_dict(self) -> dict[str, Any]: + return { + "schema_version": "missioncore.acquisition-snapshot/v1alpha2", + "acquisition_id": self.acquisition_id, + "device_id": self.device_id, + "device_session_id": self.device_session_id, + "compatibility_profile_id": self.compatibility_profile_id, + "control_mode": self.control_mode, + "requested_streams": list(self.requested_streams), + "target_host": self.target_host, + "duration_seconds": self.duration_seconds, + "evidence_policy": self.evidence_policy, + "state": self.state, + "state_revision": self.state_revision, + "created_at": _iso(self.created_at), + "updated_at": _iso(self.updated_at), + "message_code": self.message_code, + "operator_instructions": list(self.operator_instructions), + "result": dict(self.result) if self.result is not None else None, + } + + +def new_acquisition_id() -> str: + return f"acq-{uuid4()}" + + +def new_device_id() -> str: + return f"device-{uuid4()}" + + +def new_device_session_id() -> str: + return f"device-session-{uuid4()}" diff --git a/src/k1link/web/plugin_catalog.py b/src/k1link/web/plugin_catalog.py index d22a466..4aa5e9f 100644 --- a/src/k1link/web/plugin_catalog.py +++ b/src/k1link/web/plugin_catalog.py @@ -1,13 +1,29 @@ from __future__ import annotations +import json +import re from pathlib import Path from typing import Annotated, Any, Literal -from pydantic import AfterValidator, BaseModel, ConfigDict, Field, ValidationError +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + ValidationError, + model_validator, +) from k1link.web.plugin_runtime import STATE_READ_ACTION_ID -PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1" +PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha2" +SUPPORTED_PLUGIN_API_VERSIONS = frozenset( + { + "missioncore.nodedc/v1alpha1", + PLUGIN_API_VERSION, + } +) +_V1ALPHA2_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$") def _reject_blank(value: str) -> str: @@ -16,6 +32,11 @@ def _reject_blank(value: str) -> str: return value +def _validate_v1alpha2_identifier(value: str, path: str) -> None: + if _V1ALPHA2_IDENTIFIER.fullmatch(value) is None: + raise ValueError(f"{path} must be a v1alpha2 identifier") + + ShortText = Annotated[ str, Field(min_length=1, max_length=160), @@ -31,6 +52,11 @@ EntrypointText = Annotated[ Field(min_length=1, max_length=256), AfterValidator(_reject_blank), ] +ProfilePathText = Annotated[ + str, + Field(min_length=1, max_length=512), + AfterValidator(_reject_blank), +] class CapabilityManifest(BaseModel): @@ -75,6 +101,14 @@ class PluginActionManifest(BaseModel): secretFields: list[ShortText] +class CompatibilityProfileLinkManifest(BaseModel): + model_config = ConfigDict(extra="forbid") + + profileId: ShortText + path: ProfilePathText + modelId: ShortText + + class PluginMetadata(BaseModel): model_config = ConfigDict(extra="forbid") @@ -94,26 +128,164 @@ class PluginMetadata(BaseModel): class PluginSpec(BaseModel): model_config = ConfigDict(extra="forbid") - hostApiRange: Literal["v1alpha1"] + hostApiRange: Literal["v1alpha1", "v1alpha2"] runtime: PluginRuntimeManifest permissions: list[ShortText] actions: list[PluginActionManifest] - models: list[DeviceModelManifest] = Field(min_length=1, max_length=1) + models: list[DeviceModelManifest] = Field(min_length=1) + compatibilityProfiles: list[CompatibilityProfileLinkManifest] | None = None class DevicePluginManifest(BaseModel): model_config = ConfigDict(extra="forbid") - apiVersion: Literal["missioncore.nodedc/v1alpha1"] + apiVersion: Literal[ + "missioncore.nodedc/v1alpha1", + "missioncore.nodedc/v1alpha2", + ] kind: Literal["DevicePlugin"] metadata: PluginMetadata spec: PluginSpec + @model_validator(mode="after") + def validate_contract_version(self) -> DevicePluginManifest: + expected_host_range = self.apiVersion.rsplit("/", maxsplit=1)[-1] + if self.spec.hostApiRange != expected_host_range: + raise ValueError("apiVersion and hostApiRange must declare the same contract") + if self.apiVersion == "missioncore.nodedc/v1alpha1": + if len(self.spec.models) != 1: + raise ValueError("v1alpha1 must declare exactly one device model") + if self.spec.compatibilityProfiles is not None: + raise ValueError("v1alpha1 must not declare compatibilityProfiles") + else: + if not self.spec.compatibilityProfiles: + raise ValueError("v1alpha2 must declare at least one compatibility profile") + identifiers: list[tuple[str, str]] = [ + ("metadata.id", self.metadata.id), + *( + (f"spec.permissions[{index}]", permission) + for index, permission in enumerate(self.spec.permissions) + ), + ] + for action_index, action in enumerate(self.spec.actions): + identifiers.append((f"spec.actions[{action_index}].id", action.id)) + identifiers.extend( + ( + f"spec.actions[{action_index}].secretFields[{field_index}]", + field, + ) + for field_index, field in enumerate(action.secretFields) + ) + for model_index, model in enumerate(self.spec.models): + identifiers.append((f"spec.models[{model_index}].id", model.id)) + identifiers.extend( + ( + f"spec.models[{model_index}].capabilities[{capability_index}].id", + capability.id, + ) + for capability_index, capability in enumerate(model.capabilities) + ) + for profile_index, profile in enumerate(self.spec.compatibilityProfiles): + identifiers.extend( + ( + ( + f"spec.compatibilityProfiles[{profile_index}].profileId", + profile.profileId, + ), + ( + f"spec.compatibilityProfiles[{profile_index}].modelId", + profile.modelId, + ), + ) + ) + for path, value in identifiers: + _validate_v1alpha2_identifier(value, path) + return self + class PluginCatalogError(RuntimeError): """An installed manifest is invalid or conflicts with another manifest.""" +def _reject_duplicate_profile_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise PluginCatalogError(f"Duplicate JSON key in compatibility profile: {key}") + result[key] = value + return result + + +def _validate_profile_links( + manifest: DevicePluginManifest, + manifest_path: Path, +) -> None: + links = manifest.spec.compatibilityProfiles + if links is None: + return + + plugin_directory = manifest_path.parent.resolve() + model_ids = {model.id for model in manifest.spec.models} + linked_model_ids: set[str] = set() + profile_ids: set[str] = set() + profile_paths: set[Path] = set() + + for link in links: + if link.profileId in profile_ids: + raise PluginCatalogError( + f"Duplicate compatibility profile id in {manifest.metadata.id}: {link.profileId}" + ) + profile_ids.add(link.profileId) + + if link.modelId not in model_ids: + raise PluginCatalogError( + f"Compatibility profile {link.profileId} references unknown model {link.modelId}" + ) + linked_model_ids.add(link.modelId) + + relative_path = Path(link.path) + if relative_path.is_absolute(): + raise PluginCatalogError( + f"Compatibility profile path must be plugin-relative: {link.path}" + ) + profile_path = (plugin_directory / relative_path).resolve() + try: + profile_path.relative_to(plugin_directory) + except ValueError as exc: + raise PluginCatalogError( + f"Compatibility profile path escapes plugin directory: {link.path}" + ) from exc + if profile_path in profile_paths: + raise PluginCatalogError( + f"Duplicate compatibility profile path in {manifest.metadata.id}: {link.path}" + ) + profile_paths.add(profile_path) + if not profile_path.is_file(): + raise PluginCatalogError(f"Compatibility profile does not exist: {link.path}") + + try: + profile_document = json.loads( + profile_path.read_text(encoding="utf-8"), + object_pairs_hook=_reject_duplicate_profile_keys, + ) + except (OSError, json.JSONDecodeError) as exc: + raise PluginCatalogError( + f"Invalid compatibility profile {profile_path}: {exc}" + ) from exc + if not isinstance(profile_document, dict): + raise PluginCatalogError( + f"Compatibility profile must contain a JSON object: {link.path}" + ) + if profile_document.get("profile_id") != link.profileId: + raise PluginCatalogError( + f"Compatibility profile id mismatch for {link.path}: expected {link.profileId}" + ) + + if linked_model_ids != model_ids: + missing = ", ".join(sorted(model_ids - linked_model_ids)) + raise PluginCatalogError(f"Device models without compatibility profiles: {missing}") + + class DevicePluginCatalog: """Read-only catalog of statically reviewed device-plugin manifests.""" @@ -141,6 +313,8 @@ class DevicePluginCatalog: raise PluginCatalogError(f"Duplicate device-plugin id: {plugin_id}") plugin_ids.add(plugin_id) + _validate_profile_links(manifest, path) + action_ids: set[str] = set() for action in manifest.spec.actions: if action.id in action_ids: @@ -178,7 +352,9 @@ class DevicePluginCatalog: return list(self._validated_manifests) def plugin_documents(self) -> list[dict[str, Any]]: - return [manifest.model_dump(mode="json") for manifest in self.manifests()] + return [ + manifest.model_dump(mode="json", exclude_none=True) for manifest in self.manifests() + ] def model_documents(self) -> list[dict[str, Any]]: models: list[dict[str, Any]] = [] diff --git a/src/k1link/web/xgrids_k1_facade.py b/src/k1link/web/xgrids_k1_facade.py index 518ecc0..4415b6d 100644 --- a/src/k1link/web/xgrids_k1_facade.py +++ b/src/k1link/web/xgrids_k1_facade.py @@ -1,21 +1,35 @@ from __future__ import annotations import asyncio +import hashlib +import hmac +import importlib.util +import json +import secrets import threading from collections.abc import Mapping from datetime import UTC, datetime from pathlib import Path -from typing import Any, Literal, Protocol +from typing import Any, Literal, Protocol, cast from bleak.exc import BleakError -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError from k1link.artifacts import write_json_atomic from k1link.ble.scanner import scan from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once from k1link.mqtt import validate_private_ipv4 +from k1link.protocol.normalizer import normalize_k1_message from k1link.viewer.rerun_bridge import RerunSceneSettings from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir +from k1link.web.device_lifecycle import ( + TERMINAL_ACQUISITION_STATES, + AcquisitionRecord, + OperationJournal, + new_acquisition_id, + new_device_id, + new_device_session_id, +) from k1link.web.plugin_runtime import ( DevicePluginRuntimeContribution, PluginActionNotFoundError, @@ -23,15 +37,39 @@ from k1link.web.plugin_runtime import ( ) XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1" +XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1" +XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" ACTION_STATE_READ = "state.read" ACTION_DISCOVERY_SCAN = "discovery.scan" +ACTION_DEVICE_INSPECT = "device.inspect" +ACTION_SENSOR_CATALOG_READ = "sensor.catalog.read" +ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ = "calibration.device-snapshot.read" ACTION_NETWORK_PROVISION = "network.provision" +ACTION_CONNECTION_VERIFY = "connection.verify" +ACTION_ACQUISITION_PREPARE = "acquisition.prepare" +ACTION_ACQUISITION_START = "acquisition.start" +ACTION_ACQUISITION_STOP = "acquisition.stop" +ACTION_ACQUISITION_ABORT = "acquisition.abort" +ACTION_ACQUISITION_STATE_READ = "acquisition.state.read" ACTION_STREAM_START_LIVE = "stream.start-live" ACTION_STREAM_START_REPLAY = "stream.start-replay" ACTION_STREAM_STOP = "stream.stop" ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update" +RequestedStreamId = Literal[ + "spatial.point-cloud.live", + "spatial.pose.live", + "device.status.live", + "device.heartbeat.live", +] +DEFAULT_LIVE_STREAMS: tuple[RequestedStreamId, ...] = ( + "spatial.point-cloud.live", + "spatial.pose.live", + "device.status.live", + "device.heartbeat.live", +) + class StrictRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -41,19 +79,64 @@ class EmptyRequest(StrictRequest): pass +class CompatibilityProfileLoader(Protocol): + def load_compatibility_profile(self, path: Path) -> dict[str, Any]: ... + + class BleScanRequest(StrictRequest): duration_seconds: float = Field(default=6.0, ge=1.0, le=60.0) +class CompatibilityAttestationRequest(StrictRequest): + """Explicit operator claim required before using the exact lab profile.""" + + firmware_version: Literal["3.0.2"] + topology: Literal["direct-lan"] + operator_confirmed: Literal[True] + + class ConnectRequest(StrictRequest): device_id: str = Field(min_length=1, max_length=128) ssid: str = Field(min_length=1, max_length=128) - password: str = Field(min_length=1, max_length=256) + password: SecretStr = Field(min_length=1, max_length=256) + compatibility_attestation: CompatibilityAttestationRequest + operation_id: str | None = Field(default=None, min_length=1, max_length=128) + idempotency_key: str | None = Field(default=None, min_length=1, max_length=160) class LiveRequest(StrictRequest): host: str | None = Field(default=None, max_length=15) duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0) + compatibility_attestation: CompatibilityAttestationRequest + + +class OperationContextRequest(StrictRequest): + operation_id: str | None = Field(default=None, min_length=1, max_length=128) + idempotency_key: str | None = Field(default=None, min_length=1, max_length=160) + deadline_seconds: float | None = Field(default=None, ge=1.0, le=86_400.0) + + +class PrepareAcquisitionRequest(OperationContextRequest): + host: str | None = Field(default=None, max_length=15) + duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0) + requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS + evidence_policy: Literal["required", "best-effort", "disabled"] = "required" + compatibility_attestation: CompatibilityAttestationRequest + + +class StartAcquisitionRequest(OperationContextRequest): + acquisition_id: str | None = Field(default=None, min_length=1, max_length=128) + expected_state_revision: int | None = Field(default=None, ge=1) + + +class StopAcquisitionRequest(OperationContextRequest): + acquisition_id: str | None = Field(default=None, min_length=1, max_length=128) + mode: Literal["graceful", "capture-only"] = "capture-only" + operator_confirmed: bool = False + + +class AbortAcquisitionRequest(OperationContextRequest): + acquisition_id: str | None = Field(default=None, min_length=1, max_length=128) class ReplayRequest(StrictRequest): @@ -79,15 +162,37 @@ class XgridsK1CompatibilityService: def __init__(self, repository_root: Path) -> None: self.repository_root = repository_root.resolve() self._lock = threading.Lock() + self._provisioning_gate = threading.Lock() + self._provisioning_active = False + self._fingerprint_key = secrets.token_bytes(32) self._devices: list[dict[str, Any]] = [] self._selected_device_id: str | None = None self._k1_ip: str | None = None + self._device_ids_by_transport_ref: dict[str, str] = {} + self._device_id: str | None = None + self._device_session_id: str | None = None + self._device_session_opened_at: str | None = None + self._connection_verification: dict[str, Any] = { + "status": "not-probed", + "endpoint_validation": "not-performed", + "network_reachability": "unknown", + "observed_at": None, + } + self._compatibility_attestation: dict[str, Any] | None = None self._operation_phase: str | None = None self._operation_message: str | None = None - self.runtime = VisualizationRuntime() + self._operations = OperationJournal() + self._acquisition: AcquisitionRecord | None = None + self._acquisition_out_dir: Path | None = None + self._acquisition_start_operation_id: str | None = None + self._acquisition_stop_operation_id: str | None = None + # The host-owned visual runtime receives the vendor normalizer + # explicitly. There is no implicit K1 decoder in the visual layer. + self.runtime = VisualizationRuntime(normalizer=normalize_k1_message) def state(self) -> dict[str, Any]: runtime = self.runtime.snapshot() + self._reconcile_acquisition(runtime) metrics = runtime["metrics"] with self._lock: operation_phase = self._operation_phase @@ -95,6 +200,16 @@ class XgridsK1CompatibilityService: devices = list(self._devices) selected_device_id = self._selected_device_id k1_ip = self._k1_ip + device_id = self._device_id + device_session_id = self._device_session_id + device_session_opened_at = self._device_session_opened_at + connection_verification = dict(self._connection_verification) + compatibility_attestation = ( + dict(self._compatibility_attestation) + if self._compatibility_attestation is not None + else None + ) + acquisition = self._acquisition.as_dict() if self._acquisition is not None else None runtime_active = runtime["source_mode"] != "idle" or runtime["phase"] in { "starting_live", @@ -124,12 +239,64 @@ class XgridsK1CompatibilityService: phase = "idle" message = runtime["message"] + operation_documents = self._operations.snapshot() + active_profile_id = ( + XGRIDS_K1_COMPATIBILITY_PROFILE_ID if compatibility_attestation is not None else None + ) return { + "contract_version": "missioncore.device-plugin-state/v1alpha2", "phase": phase, "message": message, "devices": devices, "selected_device_id": selected_device_id, "k1_ip": k1_ip, + "compatibility": { + "profile_id": active_profile_id, + "decision": "limited" if active_profile_id is not None else "unknown", + "permitted_mode": ( + "read-only" if active_profile_id is not None else "evidence-only" + ), + "firmware_claim": ( + "operator-attested-exact-3.0.2" + if compatibility_attestation is not None + else "exact-3.0.2-profile-not-attested" + ), + "attestation": compatibility_attestation, + "vendor_writes_enabled": False, + "camera_preview": ( + "observed-runtime-adapter-pending" + if active_profile_id is not None + else "unverified" + ), + }, + "device_ref": ( + { + "device_id": device_id, + "model_id": XGRIDS_K1_MODEL_ID, + "identity_stability": "provisional", + "identity_basis": "plugin-derived", + "transport_alias": selected_device_id, + } + if device_id is not None + else None + ), + "device_session": ( + { + "device_session_id": device_session_id, + "device_id": device_id, + "opened_at": device_session_opened_at, + "compatibility_profile_id": active_profile_id, + "connectivity": "connected" if k1_ip is not None else "unknown", + } + if device_id is not None and device_session_id is not None + else None + ), + "connection_verification": connection_verification, + "sensor_catalog": _sensor_catalog(active_profile_id), + "device_calibration": _device_calibration_snapshot(active_profile_id), + "acquisition": acquisition, + "operations": operation_documents, + "last_operation": operation_documents[-1] if operation_documents else None, "foxglove_ws_url": runtime["foxglove_ws_url"], "foxglove_viewer_url": runtime["foxglove_viewer_url"], "rerun_grpc_url": runtime["rerun_grpc_url"], @@ -148,6 +315,16 @@ class XgridsK1CompatibilityService: } async def scan_ble(self, duration_seconds: float) -> dict[str, Any]: + operation, _ = self._operations.begin( + ACTION_DISCOVERY_SCAN, + deadline_seconds=duration_seconds + 10.0, + ) + self._operations.transition( + operation.operation_id, + "running", + stage_code="ble-discovery", + message_code="discovery.scan.running", + ) self._set_operation("scanning", "Сканируем все устройства Bluetooth (BLE)…") try: result = await scan(duration_seconds) @@ -165,6 +342,22 @@ class XgridsK1CompatibilityService: with self._lock: self._devices = devices self._operation_message = f"Поиск завершён. Найдено BLE-устройств: {len(devices)}." + self._operations.transition( + operation.operation_id, + "succeeded", + stage_code="completed", + message_code="discovery.scan.completed", + result={"candidate_count": len(devices)}, + ) + except Exception as exc: + self._operations.transition( + operation.operation_id, + "failed", + stage_code="failed", + message_code="discovery.scan.failed", + error=_operation_error(exc, category="transport", side_effect_status="none"), + ) + raise finally: with self._lock: self._operation_phase = None @@ -174,17 +367,81 @@ class XgridsK1CompatibilityService: known_ids = {str(item["device_id"]) for item in self.state()["devices"]} if request.device_id not in known_ids: raise ValueError("сначала найдите и выберите устройство через Bluetooth") - self._set_operation( - "provisioning", - "Передаём устройству настройки Wi-Fi одним подтверждённым запросом.", + # Unwrap once at the provisioning service boundary. The plain value is + # kept only in this stack frame, included in a keyed request digest, and + # passed to the reviewed BLE write boundary; it is never journaled. + password = request.password.get_secret_value() + request_fingerprint = self._request_fingerprint( + ACTION_NETWORK_PROVISION, + { + "device_id": request.device_id, + "ssid": request.ssid, + "password": password, + "compatibility_attestation": request.compatibility_attestation.model_dump( + mode="json" + ), + }, ) - session_dir = _new_operation_session_dir( - self.repository_root, - "viewer_wifi_provisioning", + operation, created = self._operations.begin( + ACTION_NETWORK_PROVISION, + operation_id=request.operation_id, + idempotency_key=request.idempotency_key, + device_id=self._device_id, + device_session_id=self._device_session_id, + deadline_seconds=60.0, + request_fingerprint=request_fingerprint, ) - session_dir.mkdir(parents=True, exist_ok=False) - password = request.password + if not created: + password = "" + return self.state() + + if not self._provisioning_gate.acquire(blocking=False): + password = "" + self._operations.transition( + operation.operation_id, + "failed", + stage_code="busy", + message_code="network.provision.already_running", + error={ + "category": "conflict", + "code": "provisioning-already-running", + "retryable": True, + "safe_to_retry": True, + "side_effect_status": "none", + }, + ) + raise RuntimeError("другая операция настройки Wi-Fi уже выполняется") + + session_dir: Path | None = None + write_attempted = False try: + with self._lock: + active_acquisition = self._acquisition + if ( + active_acquisition is not None + and active_acquisition.state not in TERMINAL_ACQUISITION_STATES + ): + raise RuntimeError( + "нельзя менять устройство или его сеть во время активной acquisition-сессии" + ) + self._provisioning_active = True + + self._set_operation( + "provisioning", + "Передаём устройству настройки Wi-Fi одним подтверждённым запросом.", + ) + session_dir = _new_operation_session_dir( + self.repository_root, + "viewer_wifi_provisioning", + ) + session_dir.mkdir(parents=True, exist_ok=False) + self._operations.transition( + operation.operation_id, + "running", + stage_code="ble-provisioning-write", + message_code="network.provision.running", + ) + write_attempted = True result = await provision_wifi_once( request.device_id, request.ssid, @@ -214,27 +471,484 @@ class XgridsK1CompatibilityService: with self._lock: self._selected_device_id = request.device_id self._k1_ip = ipv4 + self._device_id = self._device_ids_by_transport_ref.setdefault( + request.device_id, + new_device_id(), + ) + self._device_session_id = new_device_session_id() + self._device_session_opened_at = _utc_now_iso() + self._compatibility_attestation = _attestation_snapshot( + request.compatibility_attestation + ) self._operation_message = ( "Устройство подключено к Wi-Fi и сообщило локальный адрес." ) + self._operations.transition( + operation.operation_id, + "succeeded", + stage_code="connected", + message_code="network.provision.completed", + result={ + "device_id": self._device_id, + "device_session_id": self._device_session_id, + "lan_address_observed": True, + }, + evidence_refs=(f"evidence-session-{session_dir.name}",), + ) + except Exception as exc: + self._operations.transition_if_pending( + operation.operation_id, + "failed", + stage_code="failed", + message_code="network.provision.failed", + error=_operation_error( + exc, + category="device", + side_effect_status="unknown" if write_attempted else "none", + safe_to_retry=not write_attempted, + ), + evidence_refs=( + (f"evidence-session-{session_dir.name}",) if session_dir is not None else () + ), + ) + raise finally: password = "" with self._lock: + self._provisioning_active = False self._operation_phase = None + self._provisioning_gate.release() return self.state() - def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]: - target = host or self.state()["k1_ip"] + def inspect_device(self) -> dict[str, Any]: + """Return profile-scoped facts without probing or mutating the device.""" + + return self.state() + + def verify_connection(self) -> dict[str, Any]: + """Validate the recorded endpoint only; no network packet is emitted.""" + + with self._lock: + target = self._k1_ip + if target is None: + raise ValueError("у плагина нет подтверждённого локального адреса устройства") + validate_private_ipv4(target) + with self._lock: + self._connection_verification = { + "status": "endpoint-valid", + "endpoint_validation": "private-ip-syntax-only", + "network_reachability": "unknown", + "observed_at": _utc_now_iso(), + } + return self.state() + + def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]: + requested_streams = _validated_requested_streams(request) + target = request.host or self.state()["k1_ip"] if not isinstance(target, str) or not target: raise ValueError( "сначала подключите устройство к Wi-Fi или укажите его локальный адрес" ) target = validate_private_ipv4(target) - out_dir = new_live_session_dir(self.repository_root) - self.runtime.start_live(target, out_dir, duration_seconds=duration_seconds) + if target == AP_FALLBACK_IPV4: + raise ValueError( + "адрес точки доступа устройства нельзя использовать как direct-LAN target" + ) + device_id, device_session_id = self._ensure_device_context() + request_fingerprint = self._request_fingerprint( + ACTION_ACQUISITION_PREPARE, + { + "target": target, + "duration_seconds": request.duration_seconds, + "requested_streams": requested_streams, + "evidence_policy": request.evidence_policy, + "compatibility_attestation": request.compatibility_attestation.model_dump( + mode="json" + ), + }, + ) + operation, created = self._operations.begin( + ACTION_ACQUISITION_PREPARE, + operation_id=request.operation_id, + idempotency_key=request.idempotency_key, + device_id=device_id, + device_session_id=device_session_id, + deadline_seconds=request.deadline_seconds or 30.0, + cancellable=True, + request_fingerprint=request_fingerprint, + ) + if not created: + return self.state() + self._operations.transition( + operation.operation_id, + "running", + stage_code="compatibility-check", + message_code="acquisition.prepare.compatibility_check", + ) + + try: + with self._lock: + if self._provisioning_active: + raise RuntimeError( + "нельзя готовить acquisition во время настройки Wi-Fi устройства" + ) + current = self._acquisition + if current is not None and current.state not in TERMINAL_ACQUISITION_STATES: + raise RuntimeError( + "существующая acquisition-сессия ещё не завершена; сначала остановите её" + ) + acquisition = AcquisitionRecord( + acquisition_id=new_acquisition_id(), + device_id=device_id, + device_session_id=device_session_id, + compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID, + control_mode="operator-manual", + requested_streams=requested_streams, + target_host=target, + duration_seconds=request.duration_seconds, + evidence_policy=request.evidence_policy, + ) + acquisition.transition( + "prepared", + message_code="acquisition.prepared", + result={ + "write_operations_enabled": False, + "camera_preview": "observed-runtime-adapter-pending", + }, + ) + self._acquisition = acquisition + self._acquisition_out_dir = new_live_session_dir(self.repository_root) + self._acquisition_start_operation_id = None + self._acquisition_stop_operation_id = None + self._compatibility_attestation = _attestation_snapshot( + request.compatibility_attestation + ) + self._operations.transition( + operation.operation_id, + "succeeded", + stage_code="prepared", + message_code="acquisition.prepare.completed", + result={"acquisition_id": acquisition.acquisition_id}, + ) + except Exception as exc: + self._operations.transition( + operation.operation_id, + "failed", + stage_code="failed", + message_code="acquisition.prepare.failed", + error=_operation_error(exc, category="validation", side_effect_status="none"), + ) + raise return self.state() + def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]: + acquisition = self._require_acquisition(request.acquisition_id) + if ( + request.expected_state_revision is not None + and request.expected_state_revision != acquisition.state_revision + ): + raise ValueError("состояние acquisition изменилось; обновите данные перед запуском") + request_fingerprint = self._request_fingerprint( + ACTION_ACQUISITION_START, + { + "acquisition_id": acquisition.acquisition_id, + "expected_state_revision": request.expected_state_revision, + }, + ) + operation, created = self._operations.begin( + ACTION_ACQUISITION_START, + operation_id=request.operation_id, + idempotency_key=request.idempotency_key, + device_id=acquisition.device_id, + device_session_id=acquisition.device_session_id, + deadline_seconds=request.deadline_seconds or 120.0, + cancellable=True, + request_fingerprint=request_fingerprint, + ) + if not created: + return self.state() + self._operations.transition( + operation.operation_id, + "running", + stage_code="arming-read-only-receiver", + message_code="acquisition.start.arming_receiver", + ) + + try: + with self._lock: + if acquisition.state != "prepared": + raise RuntimeError( + f"acquisition нельзя запустить из состояния {acquisition.state}" + ) + out_dir = self._acquisition_out_dir + if out_dir is None: + raise RuntimeError("для acquisition не выделена evidence-сессия") + acquisition.transition( + "starting", + message_code="acquisition.start.waiting_receiver_ready", + ) + self._acquisition_start_operation_id = operation.operation_id + self.runtime.start_live( + acquisition.target_host, + out_dir, + duration_seconds=acquisition.duration_seconds, + ) + except Exception as exc: + with self._lock: + if acquisition.state not in TERMINAL_ACQUISITION_STATES: + acquisition.transition("failed", message_code="acquisition.start.failed") + self._operations.transition( + operation.operation_id, + "failed", + stage_code="failed", + message_code="acquisition.start.failed", + error=_operation_error(exc, category="stream", side_effect_status="none"), + ) + raise + return self.state() + + def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]: + acquisition = self._require_acquisition(request.acquisition_id) + with self._lock: + acquisition_state = acquisition.state + expected_stop_operation_id = self._acquisition_stop_operation_id + + if request.operator_confirmed: + if request.mode != "graceful": + raise ValueError("operator_confirmed допустим только для graceful stop") + if acquisition_state != "awaiting_external_stop": + raise ValueError("acquisition не ожидает подтверждения физической остановки") + if request.operation_id is None or request.operation_id != expected_stop_operation_id: + raise ValueError( + "подтверждение остановки должно ссылаться на исходную stop-operation" + ) + elif request.mode == "graceful" and acquisition_state not in { + "acquiring", + "awaiting_external_stop", + }: + raise ValueError( + "graceful stop допустим только после подтверждённого потока point cloud" + ) + + request_fingerprint = self._request_fingerprint( + ACTION_ACQUISITION_STOP, + { + "acquisition_id": acquisition.acquisition_id, + "mode": request.mode, + }, + ) + operation, created = self._operations.begin( + ACTION_ACQUISITION_STOP, + operation_id=request.operation_id, + idempotency_key=request.idempotency_key, + device_id=acquisition.device_id, + device_session_id=acquisition.device_session_id, + deadline_seconds=request.deadline_seconds or 60.0, + cancellable=False, + request_fingerprint=request_fingerprint, + ) + if ( + request.mode == "graceful" + and not request.operator_confirmed + and acquisition_state == "awaiting_external_stop" + ): + if operation.operation_id == expected_stop_operation_id: + return self.state() + self._operations.transition_if_pending( + operation.operation_id, + "failed", + stage_code="conflict", + message_code="acquisition.stop.already_awaiting_operator", + error={ + "category": "conflict", + "code": "graceful-stop-already-awaiting-operator", + "retryable": False, + "safe_to_retry": True, + "side_effect_status": "none", + }, + ) + raise ValueError("acquisition уже ожидает подтверждения graceful stop") + if not created and not request.operator_confirmed: + return self.state() + if acquisition.state in TERMINAL_ACQUISITION_STATES: + self._operations.transition_if_pending( + operation.operation_id, + "succeeded", + stage_code="already-terminal", + message_code="acquisition.stop.already_terminal", + result={"acquisition_id": acquisition.acquisition_id}, + ) + return self.state() + + if request.mode == "graceful" and not request.operator_confirmed: + with self._lock: + acquisition.transition( + "awaiting_external_stop", + message_code="acquisition.stop.operator_action_required", + operator_instructions=( + "Дважды нажмите физическую кнопку устройства и подтвердите остановку.", + ), + ) + self._acquisition_stop_operation_id = operation.operation_id + self._operations.transition( + operation.operation_id, + "operator_action_required", + stage_code="awaiting-external-stop", + message_code="acquisition.stop.operator_action_required", + result={"acquisition_id": acquisition.acquisition_id}, + ) + return self.state() + + self._operations.transition( + operation.operation_id, + "running", + stage_code="stopping-receiver", + message_code="acquisition.stop.stopping_receiver", + ) + try: + with self._lock: + acquisition.transition("stopping", message_code="acquisition.stopping") + self.runtime.stop() + self._cancel_pending_acquisition_operations( + exclude_operation_id=operation.operation_id, + reason_code="superseded-by-stop", + ) + with self._lock: + if acquisition.state not in TERMINAL_ACQUISITION_STATES: + acquisition.transition("finalizing", message_code="acquisition.finalizing") + acquisition.transition( + "completed", + message_code="acquisition.completed", + result={ + "receiver_stopped": True, + "device_stop": ( + "operator-confirmed" + if request.mode == "graceful" and request.operator_confirmed + else "unknown" + ), + }, + ) + if self._acquisition_stop_operation_id == operation.operation_id: + self._acquisition_stop_operation_id = None + self._operations.transition( + operation.operation_id, + "succeeded", + stage_code="completed", + message_code="acquisition.stop.completed", + result={ + "acquisition_id": acquisition.acquisition_id, + "mode": request.mode, + }, + ) + except Exception as exc: + with self._lock: + if acquisition.state not in TERMINAL_ACQUISITION_STATES: + acquisition.transition("failed", message_code="acquisition.stop.failed") + self._cancel_pending_acquisition_operations( + exclude_operation_id=operation.operation_id, + reason_code="acquisition-stop-failed", + ) + self._operations.transition_if_pending( + operation.operation_id, + "failed", + stage_code="failed", + message_code="acquisition.stop.failed", + error=_operation_error(exc, category="stream", side_effect_status="unknown"), + ) + raise + return self.state() + + def abort_acquisition(self, request: AbortAcquisitionRequest) -> dict[str, Any]: + acquisition = self._require_acquisition(request.acquisition_id) + request_fingerprint = self._request_fingerprint( + ACTION_ACQUISITION_ABORT, + {"acquisition_id": acquisition.acquisition_id}, + ) + operation, created = self._operations.begin( + ACTION_ACQUISITION_ABORT, + operation_id=request.operation_id, + idempotency_key=request.idempotency_key, + device_id=acquisition.device_id, + device_session_id=acquisition.device_session_id, + deadline_seconds=request.deadline_seconds or 30.0, + request_fingerprint=request_fingerprint, + ) + if not created: + return self.state() + try: + if acquisition.state not in TERMINAL_ACQUISITION_STATES: + self.runtime.stop() + self._cancel_pending_acquisition_operations( + exclude_operation_id=operation.operation_id, + reason_code="superseded-by-abort", + ) + with self._lock: + acquisition.transition( + "aborted", + message_code="acquisition.aborted", + result={"receiver_stopped": True, "device_state": "unknown"}, + ) + self._operations.transition( + operation.operation_id, + "succeeded", + stage_code="aborted", + message_code="acquisition.abort.completed", + result={"acquisition_id": acquisition.acquisition_id}, + ) + except Exception as exc: + with self._lock: + if acquisition.state not in TERMINAL_ACQUISITION_STATES: + acquisition.transition( + "failed", + message_code="acquisition.abort.failed", + result={"receiver_stopped": False, "device_state": "unknown"}, + ) + self._cancel_pending_acquisition_operations( + exclude_operation_id=operation.operation_id, + reason_code="acquisition-abort-failed", + ) + self._operations.transition_if_pending( + operation.operation_id, + "failed", + stage_code="failed", + message_code="acquisition.abort.failed", + error=_operation_error(exc, category="stream", side_effect_status="unknown"), + ) + raise + return self.state() + + def start_live( + self, + host: str | None, + duration_seconds: float, + compatibility_attestation: CompatibilityAttestationRequest, + ) -> dict[str, Any]: + """Deprecated compatibility shim over prepare + operator-manual start.""" + + prepared = self.prepare_acquisition( + PrepareAcquisitionRequest( + host=host, + duration_seconds=duration_seconds, + compatibility_attestation=compatibility_attestation, + ) + ) + acquisition = prepared.get("acquisition") + if not isinstance(acquisition, dict) or not isinstance( + acquisition.get("acquisition_id"), str + ): + raise RuntimeError("acquisition prepare did not return an acquisition id") + return self.start_acquisition( + StartAcquisitionRequest(acquisition_id=acquisition["acquisition_id"]) + ) + def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: + with self._lock: + acquisition = self._acquisition + if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES: + raise RuntimeError( + "нельзя запускать replay во время активной acquisition-сессии; " + "сначала остановите или отмените её" + ) replay_path = Path(path).expanduser().resolve() if not replay_path.is_relative_to(self.repository_root): raise ValueError("файл записи должен находиться внутри репозитория") @@ -242,8 +956,19 @@ class XgridsK1CompatibilityService: return self.state() def stop(self) -> dict[str, Any]: - self.runtime.stop() - return self.state() + """Deprecated capture-only shim; it never claims that K1 stopped scanning.""" + + with self._lock: + acquisition = self._acquisition + if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES: + self.runtime.stop() + return self.state() + return self.stop_acquisition( + StopAcquisitionRequest( + acquisition_id=acquisition.acquisition_id, + mode="capture-only", + ) + ) def update_viewer_settings(self, request: ViewerSettingsRequest) -> dict[str, Any]: self.runtime.update_scene_settings( @@ -265,6 +990,197 @@ class XgridsK1CompatibilityService: self._operation_phase = phase self._operation_message = message + def _request_fingerprint(self, action: str, payload: Mapping[str, Any]) -> str: + serialized = json.dumps( + {"action": action, "payload": payload}, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hmac.new(self._fingerprint_key, serialized, hashlib.sha256).hexdigest() + + def _cancel_pending_acquisition_operations( + self, + *, + exclude_operation_id: str, + reason_code: str, + ) -> None: + with self._lock: + pending = ( + ("start", self._acquisition_start_operation_id), + ("stop", self._acquisition_stop_operation_id), + ) + for action_kind, operation_id in pending: + if operation_id is None or operation_id == exclude_operation_id: + continue + self._operations.transition_if_pending( + operation_id, + "cancelled", + stage_code=reason_code, + message_code=f"acquisition.{action_kind}.{reason_code}", + ) + with self._lock: + if self._acquisition_start_operation_id != exclude_operation_id: + self._acquisition_start_operation_id = None + if self._acquisition_stop_operation_id != exclude_operation_id: + self._acquisition_stop_operation_id = None + + def _ensure_device_context(self) -> tuple[str, str]: + with self._lock: + if self._device_id is None: + self._device_id = new_device_id() + if self._device_session_id is None: + self._device_session_id = new_device_session_id() + self._device_session_opened_at = _utc_now_iso() + return self._device_id, self._device_session_id + + def _require_acquisition(self, acquisition_id: str | None) -> AcquisitionRecord: + with self._lock: + acquisition = self._acquisition + if acquisition is None: + raise ValueError("acquisition ещё не подготовлена") + if acquisition_id is not None and acquisition_id != acquisition.acquisition_id: + raise ValueError("указана неизвестная acquisition-сессия") + return acquisition + + def _reconcile_acquisition(self, runtime: Mapping[str, Any]) -> None: + completed_operation_id: str | None = None + failed_operation_id: str | None = None + failed_stop_operation_id: str | None = None + unconfirmed_stop_operation_id: str | None = None + receiver_ready_operation_id: str | None = None + acquisition_id: str | None = None + with self._lock: + acquisition = self._acquisition + if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES: + return + metrics = runtime.get("metrics") + point_frames = metrics.get("pcl_frames", 0) if isinstance(metrics, dict) else 0 + phase = runtime.get("phase") + source_mode = runtime.get("source_mode") + source_ready = runtime.get("source_ready") is True + if acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0: + acquisition.transition("acquiring", message_code="acquisition.acquiring") + completed_operation_id = self._acquisition_start_operation_id + acquisition_id = acquisition.acquisition_id + elif acquisition.state == "starting" and source_ready: + acquisition.transition( + "awaiting_external_start", + message_code="acquisition.start.operator_action_required", + operator_instructions=( + "Приёмник готов. Дважды нажмите физическую кнопку устройства.", + ), + ) + receiver_ready_operation_id = self._acquisition_start_operation_id + acquisition_id = acquisition.acquisition_id + elif phase == "error": + waiting_for_start = acquisition.state in { + "starting", + "awaiting_external_start", + } + waiting_for_stop = acquisition.state in { + "awaiting_external_stop", + "stopping", + "finalizing", + } + acquisition.transition("failed", message_code="acquisition.runtime_failed") + if waiting_for_start: + failed_operation_id = self._acquisition_start_operation_id + if waiting_for_stop: + failed_stop_operation_id = self._acquisition_stop_operation_id + elif ( + acquisition.state in {"starting", "awaiting_external_start"} + and source_mode == "idle" + ): + acquisition.transition( + "failed", + message_code="acquisition.receiver_completed_without_point_data", + result={"receiver_stopped": True, "device_state": "unknown"}, + ) + failed_operation_id = self._acquisition_start_operation_id + elif acquisition.state == "acquiring" and source_mode == "idle": + acquisition.transition( + "completed", + message_code="acquisition.receiver_completed", + result={"receiver_stopped": True, "device_state": "unknown"}, + ) + elif acquisition.state == "awaiting_external_stop" and source_mode == "idle": + acquisition.transition( + "failed", + message_code="acquisition.receiver_completed_before_stop_confirmation", + result={"receiver_stopped": True, "device_state": "unknown"}, + ) + unconfirmed_stop_operation_id = self._acquisition_stop_operation_id + + if receiver_ready_operation_id is not None: + self._operations.transition_if_pending( + receiver_ready_operation_id, + "operator_action_required", + stage_code="awaiting-external-start", + message_code="acquisition.start.operator_action_required", + result={"acquisition_id": acquisition_id}, + ) + if completed_operation_id is not None: + self._operations.transition_if_pending( + completed_operation_id, + "succeeded", + stage_code="first-point-frame", + message_code="acquisition.start.confirmed_by_data", + result={"acquisition_id": acquisition_id, "confirmation": "point-frame"}, + ) + if failed_operation_id is not None: + self._operations.transition_if_pending( + failed_operation_id, + "failed", + stage_code="runtime-failed", + message_code="acquisition.start.runtime_failed", + error={ + "category": "stream", + "code": "runtime-failed", + "retryable": False, + "safe_to_retry": True, + "side_effect_status": "none", + }, + ) + if failed_stop_operation_id is not None: + self._operations.transition_if_pending( + failed_stop_operation_id, + "failed", + stage_code="runtime-failed", + message_code="acquisition.stop.runtime_failed", + error={ + "category": "stream", + "code": "runtime-failed", + "retryable": False, + "safe_to_retry": False, + "side_effect_status": "unknown", + }, + ) + if unconfirmed_stop_operation_id is not None: + self._operations.transition_if_pending( + unconfirmed_stop_operation_id, + "failed", + stage_code="receiver-completed-unconfirmed", + message_code="acquisition.stop.receiver_completed_unconfirmed", + error={ + "category": "stream", + "code": "receiver-completed-before-device-stop-confirmation", + "retryable": False, + "safe_to_retry": False, + "side_effect_status": "unknown", + }, + ) + with self._lock: + if self._acquisition_start_operation_id in { + completed_operation_id, + failed_operation_id, + }: + self._acquisition_start_operation_id = None + if self._acquisition_stop_operation_id == failed_stop_operation_id: + self._acquisition_stop_operation_id = None + if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id: + self._acquisition_stop_operation_id = None + class XgridsK1ServicePort(Protocol): def state(self) -> dict[str, Any]: ... @@ -273,7 +1189,24 @@ class XgridsK1ServicePort(Protocol): async def connect(self, request: ConnectRequest) -> dict[str, Any]: ... - def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]: ... + def inspect_device(self) -> dict[str, Any]: ... + + def verify_connection(self) -> dict[str, Any]: ... + + def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]: ... + + def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]: ... + + def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]: ... + + def abort_acquisition(self, request: AbortAcquisitionRequest) -> dict[str, Any]: ... + + def start_live( + self, + host: str | None, + duration_seconds: float, + compatibility_attestation: CompatibilityAttestationRequest, + ) -> dict[str, Any]: ... def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: ... @@ -290,7 +1223,16 @@ class XgridsK1PluginFacade: { ACTION_STATE_READ, ACTION_DISCOVERY_SCAN, + ACTION_DEVICE_INSPECT, + ACTION_SENSOR_CATALOG_READ, + ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ, ACTION_NETWORK_PROVISION, + ACTION_CONNECTION_VERIFY, + ACTION_ACQUISITION_PREPARE, + ACTION_ACQUISITION_START, + ACTION_ACQUISITION_STOP, + ACTION_ACQUISITION_ABORT, + ACTION_ACQUISITION_STATE_READ, ACTION_STREAM_START_LIVE, ACTION_STREAM_START_REPLAY, ACTION_STREAM_STOP, @@ -320,15 +1262,41 @@ class XgridsK1PluginFacade: if action_id == ACTION_DISCOVERY_SCAN: scan_request = BleScanRequest.model_validate(payload) return await self.service.scan_ble(scan_request.duration_seconds) + if action_id in { + ACTION_DEVICE_INSPECT, + ACTION_SENSOR_CATALOG_READ, + ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ, + ACTION_ACQUISITION_STATE_READ, + }: + EmptyRequest.model_validate(payload) + if action_id == ACTION_DEVICE_INSPECT: + return await asyncio.to_thread(self.service.inspect_device) + return await asyncio.to_thread(self.service.state) if action_id == ACTION_NETWORK_PROVISION: connect_request = ConnectRequest.model_validate(payload) return await self.service.connect(connect_request) + if action_id == ACTION_CONNECTION_VERIFY: + EmptyRequest.model_validate(payload) + return await asyncio.to_thread(self.service.verify_connection) + if action_id == ACTION_ACQUISITION_PREPARE: + prepare_request = PrepareAcquisitionRequest.model_validate(payload) + return await asyncio.to_thread(self.service.prepare_acquisition, prepare_request) + if action_id == ACTION_ACQUISITION_START: + start_request = StartAcquisitionRequest.model_validate(payload) + return await asyncio.to_thread(self.service.start_acquisition, start_request) + if action_id == ACTION_ACQUISITION_STOP: + stop_request = StopAcquisitionRequest.model_validate(payload) + return await asyncio.to_thread(self.service.stop_acquisition, stop_request) + if action_id == ACTION_ACQUISITION_ABORT: + abort_request = AbortAcquisitionRequest.model_validate(payload) + return await asyncio.to_thread(self.service.abort_acquisition, abort_request) if action_id == ACTION_STREAM_START_LIVE: live_request = LiveRequest.model_validate(payload) return await asyncio.to_thread( self.service.start_live, live_request.host, live_request.duration_seconds, + live_request.compatibility_attestation, ) if action_id == ACTION_STREAM_START_REPLAY: replay_request = ReplayRequest.model_validate(payload) @@ -350,6 +1318,120 @@ class XgridsK1PluginFacade: raise PluginActionNotFoundError(f"Unsupported XGRIDS K1 action: {action_id}") +def _utc_now_iso() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +def _operation_error( + exc: Exception, + *, + category: str, + side_effect_status: Literal["none", "possible", "confirmed", "unknown"], + safe_to_retry: bool = False, +) -> dict[str, Any]: + return { + "category": category, + "code": type(exc).__name__, + "retryable": False, + "safe_to_retry": safe_to_retry, + "side_effect_status": side_effect_status, + } + + +def _validated_requested_streams( + request: PrepareAcquisitionRequest, +) -> tuple[RequestedStreamId, ...]: + if request.evidence_policy != "required": + raise ValueError("текущий raw-first runtime поддерживает только evidence_policy=required") + requested = tuple(request.requested_streams) + if len(requested) != len(set(requested)) or set(requested) != set(DEFAULT_LIVE_STREAMS): + raise ValueError( + "текущий runtime поддерживает только полный проверенный набор live-потоков" + ) + return DEFAULT_LIVE_STREAMS + + +def _attestation_snapshot( + attestation: CompatibilityAttestationRequest, +) -> dict[str, Any]: + return { + "firmware_version": attestation.firmware_version, + "topology": attestation.topology, + "basis": "operator-attested", + "observed_at": _utc_now_iso(), + } + + +def _sensor_catalog(active_profile_id: str | None) -> dict[str, Any]: + camera_profile_active = active_profile_id is not None + return { + "schema_version": "missioncore.sensor-catalog/v1alpha2", + "revision": active_profile_id or "unprofiled-evidence-only", + "streams": [ + { + "stream_id": "spatial.point-cloud.live", + "sensor_kind": "lidar", + "modality": "point-cloud", + "availability": "observed", + "decode_status": "replay-and-physical-verified", + "frame_id": "map", + "coordinate_convention": "unverified", + }, + { + "stream_id": "spatial.pose.live", + "sensor_kind": "pose-engine", + "modality": "pose", + "availability": "observed", + "decode_status": "replay-and-physical-verified", + "frame_id": "map", + "coordinate_convention": "unverified", + }, + { + "stream_id": "device.status.live", + "sensor_kind": "status", + "modality": "device-status", + "availability": "observed", + "decode_status": "raw-only", + "frame_id": None, + "coordinate_convention": None, + }, + { + "stream_id": "device.heartbeat.live", + "sensor_kind": "status", + "modality": "health", + "availability": "observed", + "decode_status": "raw-only", + "frame_id": None, + "coordinate_convention": None, + }, + { + "stream_id": "camera.preview.live", + "sensor_kind": "camera-rig", + "modality": "encoded-video", + "availability": "observed" if camera_profile_active else "unverified", + "decode_status": ( + "transport-observed-runtime-adapter-pending" + if camera_profile_active + else "profile-not-attested" + ), + "frame_id": None, + "coordinate_convention": None, + }, + ], + } + + +def _device_calibration_snapshot(active_profile_id: str | None) -> dict[str, Any]: + return { + "schema_version": "missioncore.device-calibration/v1alpha2", + "status": "unavailable", + "compatibility_profile_id": active_profile_id, + "device_internal_calibration": None, + "vehicle_extrinsics": "host-domain-not-owned-by-plugin", + "reason_code": "device-calibration-not-observed", + } + + def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path: stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") base = repository_root / "sessions" / f"{stamp}_{suffix}" @@ -380,11 +1462,37 @@ def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None: return None +def _validate_installed_compatibility_profile(repository_root: Path) -> None: + """Run the plugin-owned, fail-closed profile validator before activation.""" + + plugin_root = repository_root.resolve() / "plugins" / "xgrids-k1" + loader_path = plugin_root / "profile_loader.py" + profile_path = plugin_root / "profiles" / "fw-3.0.2" / "direct-lan.v1.json" + spec = importlib.util.spec_from_file_location( + "missioncore_installed_xgrids_k1_profile_loader", + loader_path, + ) + if spec is None or spec.loader is None: + raise RuntimeError("XGRIDS compatibility profile validator is unavailable") + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + loader = cast(CompatibilityProfileLoader, module) + profile = loader.load_compatibility_profile(profile_path) + except (AttributeError, OSError, ValueError) as exc: + raise RuntimeError("XGRIDS compatibility profile failed validation") from exc + if not isinstance(profile, dict) or profile.get("profile_id") != ( + XGRIDS_K1_COMPATIBILITY_PROFILE_ID + ): + raise RuntimeError("XGRIDS compatibility profile identity does not match the runtime") + + def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribution: """Manifest entrypoint for the reviewed XGRIDS compatibility adapter.""" from k1link.web.xgrids_k1_legacy_api import build_xgrids_k1_legacy_router + _validate_installed_compatibility_profile(repository_root) service = XgridsK1CompatibilityService(repository_root) adapter = XgridsK1PluginFacade(service) return DevicePluginRuntimeContribution( diff --git a/tests/test_ble_scanner.py b/tests/test_ble_scanner.py index 83fbec5..2a2b3cf 100644 --- a/tests/test_ble_scanner.py +++ b/tests/test_ble_scanner.py @@ -23,10 +23,10 @@ def test_advertisement_record_marks_k1_candidate() -> None: assert record["service_uuids"] == ["B", "a"] -def test_advertisement_record_marks_observed_xgr_serial_name_as_k1_candidate() -> None: - device = BLEDevice("F89438FA-55ED-85AD-EED7-734AC84746D8", "XGR-A46BE7", None) +def test_advertisement_record_marks_synthetic_xgr_name_as_k1_candidate() -> None: + device = BLEDevice("00000000-0000-0000-0000-000000000001", "XGR-TEST01", None) advertisement = AdvertisementData( - local_name="XGR-A46BE7", + local_name="XGR-TEST01", manufacturer_data={}, service_data={}, service_uuids=[], diff --git a/tests/test_canonical_pipeline.py b/tests/test_canonical_pipeline.py new file mode 100644 index 0000000..590e90c --- /dev/null +++ b/tests/test_canonical_pipeline.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import inspect +import struct +import time +from pathlib import Path + +import lz4.block +import pytest + +import k1link.viewer.runtime as runtime_module +from k1link.data_plane import ( + ConsumerFrameContext, + DecodedDeviceStatusView, + DecodedPointCloudView, + DecodedPoseView, + NormalizationError, +) +from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC +from k1link.protocol.normalizer import normalize_k1_message +from k1link.viewer.messages import StreamMessage +from k1link.viewer.rerun_bridge import RerunBridge +from k1link.viewer.runtime import VisualizationRuntime + + +class FakeRecording: + def __init__(self) -> None: + self.logs: list[tuple[str, object, bool]] = [] + + def serve_grpc(self, **_: object) -> str: + return "rerun+http://127.0.0.1:9876/proxy" + + def log(self, path: str, entity: object, *, static: bool = False) -> None: + self.logs.append((path, entity, static)) + + def set_time(self, _timeline: str, **_value: object) -> None: + return + + def send_blueprint(self, _blueprint: object, **_: object) -> None: + return + + def disconnect(self) -> None: + return + + def flush(self, **_: object) -> None: + return + + +def _message( + topic: str, + payload: bytes, + *, + source: str = "live_mqtt", +) -> StreamMessage: + return StreamMessage( + sequence=9, + topic=topic, + payload=payload, + received_at_epoch_ns=1_784_124_315_186_225_000, + received_monotonic_ns=100, + source=source, + ) + + +def _varint(value: int) -> bytes: + encoded = bytearray() + while value > 0x7F: + encoded.append((value & 0x7F) | 0x80) + value >>= 7 + encoded.append(value) + return bytes(encoded) + + +def _key(number: int, wire_type: int) -> bytes: + return _varint((number << 3) | wire_type) + + +def _uint(number: int, value: int) -> bytes: + return _key(number, 0) + _varint(value) + + +def _sint(number: int, value: int) -> bytes: + zigzag = (value << 1) ^ (value >> 63) + return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF) + + +def _bytes(number: int, value: bytes) -> bytes: + return _key(number, 2) + _varint(len(value)) + value + + +def _fixed32(number: int, value: float) -> bytes: + return _key(number, 5) + struct.pack(" bytes: + return _key(number, 1) + struct.pack(" bytes: + return b"".join( + ( + _uint(1, 7), + _sint(2, 123456), + _sint(3, 1000), + _bytes(4, b"device-redacted"), + _bytes(5, b"session-redacted"), + ) + ) + + +def _lio_point_payload() -> bytes: + point = _sint(1, 1000) + _sint(2, -2000) + _sint(3, 500) + _uint(4, 0x44) + report = _bytes(1, _lio_header()) + _bytes(2, point) + compressed = lz4.block.compress(report, store_size=False) + return _uint(3, len(report)) + _bytes(4, compressed) + + +def _lio_pose_payload() -> bytes: + position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75) + orientation = _fixed64(1, 0.0) + _fixed64(2, 0.0) + _fixed64(3, 0.0) + _fixed64(4, 1.0) + pose = _bytes(1, position) + _bytes(2, orientation) + stamped = _sint(1, 987654321) + _bytes(2, pose) + return _bytes(1, _lio_header()) + _bytes(2, stamped) + _fixed32(3, 12.5) + _fixed32(4, 0.001) + + +def _context() -> ConsumerFrameContext: + return ConsumerFrameContext( + sequence=1, + captured_at_epoch_ns=1, + received_monotonic_ns=None, + processing_started_monotonic_ns=1, + encoded_size_bytes=1, + live=False, + ) + + +def test_k1_normalizer_emits_transport_neutral_point_clouds() -> None: + modern = normalize_k1_message( + _message("lixel/application/report/lio_pcl", _lio_point_payload()), + processing_started_monotonic_ns=50, + ) + legacy_payload = struct.pack(" None: + modern = normalize_k1_message( + _message("lixel/application/report/lio_pose", _lio_pose_payload()), + processing_started_monotonic_ns=50, + ) + legacy_payload = struct.pack(" None: + with pytest.raises(NormalizationError) as caught: + normalize_k1_message( + _message("RealtimePointcloud", b"short"), + processing_started_monotonic_ns=1, + ) + assert "RealtimePointcloud" not in str(caught.value) + + assert ( + normalize_k1_message( + _message("lixel/application/report/unverified", b"opaque"), + processing_started_monotonic_ns=1, + ) + is None + ) + # The status channel is observed but its payload schema is not verified; + # fail closed instead of inventing normalized status fields. + assert ( + normalize_k1_message( + _message("lixel/application/report/device_status", b"opaque"), + processing_started_monotonic_ns=1, + ) + is None + ) + + +def test_canonical_contracts_reject_misaligned_data_and_support_status() -> None: + with pytest.raises(ValueError, match="RGB byte count"): + DecodedPointCloudView( + context=_context(), + frame_id="map", + positions_xyz=((0.0, 0.0, 0.0),), + colors_rgb=b"\x00\x01", + ) + + status = DecodedDeviceStatusView(context=_context(), state="ready") + assert status.state == "ready" + + +def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge() -> None: + source = inspect.getsource(__import__("k1link.viewer.rerun_bridge", fromlist=["*"])) + for forbidden in ( + "k1link.protocol", + "StreamMessage", + "RealtimePointcloud", + "RealtimePath", + "lio_pcl", + "lio_pose", + ".topic", + ".payload", + ): + assert forbidden not in source + + +def test_visual_runtime_has_no_implicit_vendor_normalizer() -> None: + source = inspect.getsource(__import__("k1link.viewer.runtime", fromlist=["*"])) + + assert "k1link.protocol" not in source + assert "normalize_k1_message" not in source + assert "normalizer: CanonicalNormalizer" in source + + +def test_runtime_counts_transport_and_normalization_failures_before_rerun( + tmp_path: Path, +) -> None: + topic = b"RealtimePointcloud" + payload = b"short" + capture = tmp_path / "mqtt.raw.k1mqtt" + capture.write_bytes(RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload) + recording = FakeRecording() + + def bridge_factory(**kwargs: object) -> RerunBridge: + return RerunBridge( + recording_factory=lambda _: recording, # type: ignore[arg-type] + **kwargs, # type: ignore[arg-type] + ) + + runtime = VisualizationRuntime( + bridge_factory=bridge_factory, + normalizer=normalize_k1_message, + ) + runtime.start_replay(capture, speed=0.0) + deadline = time.monotonic() + 5.0 + while runtime.snapshot()["phase"] != "idle" and time.monotonic() < deadline: + time.sleep(0.01) + + snapshot = runtime.snapshot() + assert snapshot["phase"] == "idle" + assert snapshot["metrics"]["messages_received"] == 1 + assert snapshot["metrics"]["payload_bytes"] == len(payload) + assert snapshot["metrics"]["decode_errors"] == 1 + assert snapshot["metrics"]["pcl_frames"] == 0 + assert not any(path == "/world/points" for path, _, _ in recording.logs) + runtime.close() + + +def test_live_runtime_surfaces_preamble_failure_as_terminal_error( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + def fail_preamble(*_: object, **__: object) -> None: + raise OSError("synthetic evidence directory failure") + + monkeypatch.setattr(runtime_module, "_write_live_session_preamble", fail_preamble) + runtime = VisualizationRuntime(normalizer=normalize_k1_message) + + runtime.start_live( + "192.168.1.20", + tmp_path / "unwritable-evidence", + duration_seconds=1.0, + ) + deadline = time.monotonic() + 2.0 + snapshot = runtime.snapshot() + while snapshot["phase"] == "starting_live" and time.monotonic() < deadline: + time.sleep(0.01) + snapshot = runtime.snapshot() + + assert snapshot["phase"] == "error" + assert snapshot["source_mode"] == "live" + assert snapshot["source_ready"] is False + assert "synthetic evidence directory failure" in snapshot["message"] + runtime.close() diff --git a/tests/test_device_lifecycle.py b/tests/test_device_lifecycle.py new file mode 100644 index 0000000..89fc04b --- /dev/null +++ b/tests/test_device_lifecycle.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from k1link.web.device_lifecycle import ( + AcquisitionRecord, + OperationJournal, + new_acquisition_id, +) + + +def test_operation_journal_reuses_an_idempotent_request_without_storing_input() -> None: + journal = OperationJournal(clock=lambda: datetime(2026, 7, 16, 12, 0, tzinfo=UTC)) + + first, created = journal.begin( + "network.provision", + idempotency_key="provision-owned-k1-once", + device_id="device-test", + device_session_id="device-session-test", + deadline_seconds=45, + ) + repeated, repeated_created = journal.begin( + "network.provision", + idempotency_key="provision-owned-k1-once", + device_id="device-test", + device_session_id="device-session-test", + deadline_seconds=45, + ) + + assert created is True + assert repeated_created is False + assert repeated is first + assert "password" not in str(journal.snapshot()).lower() + + +def test_operation_journal_records_ack_progress_and_terminal_result() -> None: + journal = OperationJournal() + operation, _ = journal.begin("acquisition.prepare", cancellable=True) + + journal.transition( + operation.operation_id, + "running", + stage_code="compatibility-check", + message_code="acquisition.compatibility_check", + ) + journal.transition( + operation.operation_id, + "succeeded", + stage_code="prepared", + message_code="acquisition.prepared", + result={"acquisition_id": "acq-test"}, + evidence_refs=("evidence-manifest-test",), + ) + + document = journal.get(operation.operation_id).as_dict() + assert document["schema_version"] == "missioncore.operation-snapshot/v1alpha2" + assert document["status"] == "succeeded" + assert document["sequence"] == 3 + assert document["result"] == {"acquisition_id": "acq-test"} + assert document["evidence_refs"] == ["evidence-manifest-test"] + assert document["completed_at"] is not None + + with pytest.raises(ValueError, match="already terminal"): + journal.transition( + operation.operation_id, + "failed", + stage_code="late-failure", + message_code="operation.failed", + ) + + +def test_operation_id_and_idempotency_key_cannot_be_rebound() -> None: + journal = OperationJournal() + operation, _ = journal.begin("device.inspect", idempotency_key="same-key") + + with pytest.raises(ValueError, match="another action"): + journal.begin("network.provision", idempotency_key="same-key") + with pytest.raises(ValueError, match="another action"): + journal.begin("sensor.catalog.read", operation_id=operation.operation_id) + + +def test_idempotency_identity_cannot_be_reused_for_different_request_fingerprint() -> None: + journal = OperationJournal() + operation, _ = journal.begin( + "network.provision", + idempotency_key="same-request-only", + request_fingerprint="fingerprint-a", + ) + + repeated, created = journal.begin( + "network.provision", + idempotency_key="same-request-only", + request_fingerprint="fingerprint-a", + ) + assert created is False + assert repeated is operation + + with pytest.raises(ValueError, match="different request"): + journal.begin( + "network.provision", + idempotency_key="same-request-only", + request_fingerprint="fingerprint-b", + ) + with pytest.raises(ValueError, match="different request"): + journal.begin( + "network.provision", + operation_id=operation.operation_id, + request_fingerprint="fingerprint-b", + ) + + assert "fingerprint-a" not in str(journal.snapshot()) + + +def test_bounded_journal_never_evicts_an_operation_that_is_still_active() -> None: + journal = OperationJournal(max_records=1) + active, _ = journal.begin("acquisition.start", request_fingerprint="start") + completed, _ = journal.begin("device.inspect", request_fingerprint="inspect") + journal.transition( + completed.operation_id, + "succeeded", + stage_code="completed", + message_code="device.inspect.completed", + ) + + assert journal.get(active.operation_id).status == "accepted" + assert [item["operation_id"] for item in journal.snapshot(limit=10)] == [active.operation_id] + + +def test_non_cancellable_operation_rejects_cancel_request() -> None: + journal = OperationJournal() + operation, _ = journal.begin("network.provision", cancellable=False) + + with pytest.raises(ValueError, match="not cancellable"): + journal.request_cancel(operation.operation_id) + + +def test_acquisition_record_keeps_device_session_profile_and_manual_control_separate() -> None: + acquisition = AcquisitionRecord( + acquisition_id=new_acquisition_id(), + device_id="device-test", + device_session_id="device-session-test", + compatibility_profile_id="xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1", + control_mode="operator-manual", + requested_streams=("spatial.point-cloud.live", "spatial.pose.live"), + target_host="192.168.1.20", + duration_seconds=60, + evidence_policy="required", + ) + + acquisition.transition("prepared", message_code="acquisition.prepared") + acquisition.transition( + "awaiting_external_start", + message_code="acquisition.operator_start_required", + operator_instructions=("Дважды нажмите физическую кнопку устройства.",), + ) + + document = acquisition.as_dict() + assert document["schema_version"] == "missioncore.acquisition-snapshot/v1alpha2" + assert document["control_mode"] == "operator-manual" + assert document["state"] == "awaiting_external_start" + assert document["device_id"] != document["device_session_id"] + assert document["operator_instructions"] diff --git a/tests/test_frontend_plugin_boundaries.py b/tests/test_frontend_plugin_boundaries.py index 324d038..7940da1 100644 --- a/tests/test_frontend_plugin_boundaries.py +++ b/tests/test_frontend_plugin_boundaries.py @@ -90,3 +90,46 @@ def test_model_switch_is_guarded_by_plugin_deactivation() -> None: assert "catch" in host_source assert "selectionTransitionError" in host_source assert "return controller.stop();" in xgrids_runtime + + +def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity() -> None: + repository_root = Path(__file__).resolve().parents[1] + plugin_root = ( + repository_root / "apps" / "control-station" / "src" / "device-plugins" / "xgrids-k1" + ) + manifest_source = (plugin_root / "manifest.ts").read_text("utf-8") + api_source = (plugin_root / "api.ts").read_text("utf-8") + hook_source = (plugin_root / "useXgridsK1Runtime.ts").read_text("utf-8") + runtime_source = (plugin_root / "runtimeContext.tsx").read_text("utf-8") + + for action in ("acquisition.prepare", "acquisition.start", "acquisition.stop"): + assert action in manifest_source + assert "prepareAcquisition" in api_source + assert "startAcquisition" in api_source + assert hook_source.index("xgridsK1Api.prepareAcquisition") < hook_source.index( + "xgridsK1Api.startAcquisition" + ) + assert 'mode: "capture-only"' in hook_source + assert "state.device_ref" in runtime_source + assert "instanceId: deviceRef.device_id" in runtime_source + assert "acquisition?.acquisition_id" in runtime_source + assert "instanceId: state.selected_device_id" not in runtime_source + assert 'id: "xgrids-k1-rerun-live"' not in runtime_source + + +def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner() -> None: + repository_root = Path(__file__).resolve().parents[1] + connection_source = ( + repository_root + / "apps" + / "control-station" + / "src" + / "device-plugins" + / "xgrids-k1" + / "XgridsK1Connection.tsx" + ).read_text("utf-8") + + assert "Подготовить приём данных" in connection_source + assert "Программная команда запуска на K1 пока не отправляется" in connection_source + assert "Остановить локальный приём" in connection_source + assert "Физическое состояние сканера остаётся неизвестным" in connection_source diff --git a/tests/test_plugin_catalog.py b/tests/test_plugin_catalog.py index 3aa5684..86192ae 100644 --- a/tests/test_plugin_catalog.py +++ b/tests/test_plugin_catalog.py @@ -45,12 +45,38 @@ def _manifest(plugin_id: str, model_id: str) -> dict[str, Any]: } +def _v1alpha2_manifest(plugin_id: str, model_id: str) -> dict[str, Any]: + document = _manifest(plugin_id, model_id) + document["apiVersion"] = "missioncore.nodedc/v1alpha2" + document["metadata"]["version"] = "0.2.0" + document["spec"]["hostApiRange"] = "v1alpha2" + document["spec"]["compatibilityProfiles"] = [ + { + "profileId": f"{model_id}.fw-1.direct-lan.v1", + "path": "profiles/fw-1/direct-lan.v1.json", + "modelId": model_id, + } + ] + return document + + def _write_manifest(root: Path, directory: str, document: dict[str, Any]) -> None: target = root / "plugins" / directory / "plugin.manifest.json" target.parent.mkdir(parents=True) target.write_text(json.dumps(document), encoding="utf-8") +def _write_profile( + root: Path, + directory: str, + profile_id: str, + relative_path: str = "profiles/fw-1/direct-lan.v1.json", +) -> None: + target = root / "plugins" / directory / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps({"profile_id": profile_id}), encoding="utf-8") + + def test_repository_catalog_exposes_xgrids_model() -> None: repository_root = Path(__file__).resolve().parents[1] catalog = DevicePluginCatalog(repository_root) @@ -58,10 +84,40 @@ def test_repository_catalog_exposes_xgrids_model() -> None: plugins = catalog.plugin_documents() models = catalog.model_documents() - assert any(item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1" for item in plugins) + plugin = next( + item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1" + ) + assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2" + assert plugin["metadata"]["version"] == "0.2.0" + assert plugin["spec"]["hostApiRange"] == "v1alpha2" + assert plugin["spec"]["compatibilityProfiles"] == [ + { + "profileId": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1", + "path": "profiles/fw-3.0.2/direct-lan.v1.json", + "modelId": "xgrids.lixelkity-k1", + } + ] + action_ids = {action["id"] for action in plugin["spec"]["actions"]} + assert { + "device.inspect", + "sensor.catalog.read", + "calibration.device-snapshot.read", + "connection.verify", + "acquisition.prepare", + "acquisition.start", + "acquisition.stop", + "acquisition.abort", + "acquisition.state.read", + } <= action_ids + assert { + "stream.start-live", + "stream.start-replay", + "stream.stop", + "viewer.settings.update", + } <= action_ids assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == { "pluginId": "nodedc.device.xgrids-lixelkity-k1", - "pluginVersion": "0.1.0", + "pluginVersion": "0.2.0", "id": "xgrids.lixelkity-k1", "vendor": "XGRIDS", "displayName": "XGRIDS LixelKity K1", @@ -105,6 +161,152 @@ def test_catalog_accepts_multiple_distinct_plugins_and_models(tmp_path: Path) -> } +def test_catalog_accepts_v1alpha2_profile_link(tmp_path: Path) -> None: + document = _v1alpha2_manifest("example.next", "example.next-model") + profile_id = document["spec"]["compatibilityProfiles"][0]["profileId"] + _write_manifest(tmp_path, "next", document) + _write_profile(tmp_path, "next", profile_id) + + plugin = DevicePluginCatalog(tmp_path).plugin_documents()[0] + + assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2" + assert plugin["spec"]["compatibilityProfiles"][0]["profileId"] == profile_id + + +@pytest.mark.parametrize( + "mutate", + [ + lambda document: document["metadata"].update({"id": "invalid plugin"}), + lambda document: document["spec"]["permissions"].append("invalid permission"), + lambda document: document["spec"]["actions"][0].update({"id": "invalid action"}), + lambda document: document["spec"]["models"][0].update({"id": "invalid model"}), + lambda document: document["spec"]["compatibilityProfiles"][0].update( + {"profileId": "invalid profile"} + ), + ], +) +def test_v1alpha2_rejects_non_identifier_contract_fields( + tmp_path: Path, + mutate: Any, +) -> None: + document = _v1alpha2_manifest("example.strict", "example.strict-model") + mutate(document) + _write_manifest(tmp_path, "strict", document) + + with pytest.raises(PluginCatalogError, match="v1alpha2 identifier"): + DevicePluginCatalog(tmp_path).manifests() + + +def test_v1alpha1_keeps_legacy_nonblank_identifier_compatibility(tmp_path: Path) -> None: + document = _manifest("legacy plugin", "legacy model") + _write_manifest(tmp_path, "legacy", document) + + plugin = DevicePluginCatalog(tmp_path).plugin_documents()[0] + + assert plugin["metadata"]["id"] == "legacy plugin" + assert plugin["spec"]["models"][0]["id"] == "legacy model" + + +def test_catalog_accepts_multiple_profiled_models_in_v1alpha2(tmp_path: Path) -> None: + document = _v1alpha2_manifest("example.family", "example.model-a") + model_b = { + **document["spec"]["models"][0], + "id": "example.model-b", + "displayName": "Example model B", + } + document["spec"]["models"].append(model_b) + profile_b = "example.model-b.fw-1.direct-lan.v1" + document["spec"]["compatibilityProfiles"].append( + { + "profileId": profile_b, + "path": "profiles/fw-1/model-b.v1.json", + "modelId": "example.model-b", + } + ) + _write_manifest(tmp_path, "family", document) + profile_a = document["spec"]["compatibilityProfiles"][0]["profileId"] + _write_profile(tmp_path, "family", profile_a) + _write_profile( + tmp_path, + "family", + profile_b, + relative_path="profiles/fw-1/model-b.v1.json", + ) + + plugin = DevicePluginCatalog(tmp_path).plugin_documents()[0] + + assert [model["id"] for model in plugin["spec"]["models"]] == [ + "example.model-a", + "example.model-b", + ] + + +def test_catalog_rejects_api_and_host_contract_mismatch(tmp_path: Path) -> None: + document = _manifest("example.mismatch", "example.mismatch-model") + document["spec"]["hostApiRange"] = "v1alpha2" + _write_manifest(tmp_path, "mismatch", document) + + with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"): + DevicePluginCatalog(tmp_path).manifests() + + +def test_v1alpha2_catalog_rejects_missing_profile_file(tmp_path: Path) -> None: + document = _v1alpha2_manifest("example.missing", "example.missing-model") + _write_manifest(tmp_path, "missing", document) + + with pytest.raises(PluginCatalogError, match="Compatibility profile does not exist"): + DevicePluginCatalog(tmp_path).manifests() + + +def test_v1alpha2_catalog_rejects_profile_path_outside_plugin(tmp_path: Path) -> None: + document = _v1alpha2_manifest("example.escape", "example.escape-model") + profile_id = document["spec"]["compatibilityProfiles"][0]["profileId"] + document["spec"]["compatibilityProfiles"][0]["path"] = "../outside.json" + outside = tmp_path / "plugins" / "outside.json" + outside.parent.mkdir(parents=True, exist_ok=True) + outside.write_text(json.dumps({"profile_id": profile_id}), encoding="utf-8") + _write_manifest(tmp_path, "escape", document) + + with pytest.raises(PluginCatalogError, match="escapes plugin directory"): + DevicePluginCatalog(tmp_path).manifests() + + +def test_v1alpha2_catalog_rejects_profile_id_mismatch(tmp_path: Path) -> None: + document = _v1alpha2_manifest("example.mismatch", "example.mismatch-model") + _write_manifest(tmp_path, "mismatch", document) + _write_profile(tmp_path, "mismatch", "different.profile.id") + + with pytest.raises(PluginCatalogError, match="Compatibility profile id mismatch"): + DevicePluginCatalog(tmp_path).manifests() + + +def test_v1alpha2_catalog_rejects_duplicate_profile_json_keys(tmp_path: Path) -> None: + document = _v1alpha2_manifest("example.duplicate", "example.duplicate-model") + profile_id = document["spec"]["compatibilityProfiles"][0]["profileId"] + _write_manifest(tmp_path, "duplicate", document) + target = tmp_path / "plugins" / "duplicate" / "profiles" / "fw-1" / "direct-lan.v1.json" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + f'{{"profile_id": "{profile_id}", "profile_id": "{profile_id}"}}', + encoding="utf-8", + ) + + with pytest.raises(PluginCatalogError, match="Duplicate JSON key"): + DevicePluginCatalog(tmp_path).manifests() + + +def test_v1alpha2_catalog_rejects_profile_for_unknown_model(tmp_path: Path) -> None: + document = _v1alpha2_manifest("example.unknown", "example.known-model") + link = document["spec"]["compatibilityProfiles"][0] + profile_id = link["profileId"] + link["modelId"] = "example.unknown-model" + _write_manifest(tmp_path, "unknown", document) + _write_profile(tmp_path, "unknown", profile_id) + + with pytest.raises(PluginCatalogError, match="references unknown model"): + DevicePluginCatalog(tmp_path).manifests() + + def test_catalog_rejects_duplicate_model_ids(tmp_path: Path) -> None: _write_manifest(tmp_path, "first", _manifest("example.first", "example.model")) _write_manifest(tmp_path, "second", _manifest("example.second", "example.model")) diff --git a/tests/test_plugin_runtime.py b/tests/test_plugin_runtime.py index dfac68a..2a7b789 100644 --- a/tests/test_plugin_runtime.py +++ b/tests/test_plugin_runtime.py @@ -31,6 +31,7 @@ from k1link.web.xgrids_k1_facade import ( ACTION_STREAM_STOP, ACTION_VIEWER_SETTINGS_UPDATE, XGRIDS_K1_PLUGIN_ID, + CompatibilityAttestationRequest, ConnectRequest, ViewerSettingsRequest, XgridsK1PluginFacade, @@ -53,8 +54,13 @@ class FakeXgridsService: self.calls.append(("connect", request)) return {"phase": "connected", "k1_ip": "192.168.1.20"} - def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]: - self.calls.append(("live", (host, duration_seconds))) + def start_live( + self, + host: str | None, + duration_seconds: float, + compatibility_attestation: CompatibilityAttestationRequest, + ) -> dict[str, Any]: + self.calls.append(("live", (host, duration_seconds, compatibility_attestation))) return {"phase": "live"} def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]: @@ -271,7 +277,12 @@ def test_facade_validates_payload_before_calling_service() -> None: dispatcher.invoke( XGRIDS_K1_PLUGIN_ID, ACTION_NETWORK_PROVISION, - {"device_id": "id", "ssid": "network", "password": "secret", "extra": True}, + { + "device_id": "id", + "ssid": "network", + "password": "x" * 24, + "extra": True, + }, ) ) @@ -281,7 +292,18 @@ def test_facade_validates_payload_before_calling_service() -> None: @pytest.mark.parametrize( ("action_id", "payload", "expected_call"), [ - (ACTION_STREAM_START_LIVE, {"host": "192.168.1.20"}, "live"), + ( + ACTION_STREAM_START_LIVE, + { + "host": "192.168.1.20", + "compatibility_attestation": { + "firmware_version": "3.0.2", + "topology": "direct-lan", + "operator_confirmed": True, + }, + }, + "live", + ), ( ACTION_STREAM_START_REPLAY, {"path": "sessions/capture.k1mqtt", "speed": 1, "loop": False}, diff --git a/tests/test_rerun_bridge.py b/tests/test_rerun_bridge.py index aa40836..a4879dd 100644 --- a/tests/test_rerun_bridge.py +++ b/tests/test_rerun_bridge.py @@ -10,7 +10,9 @@ from pathlib import Path import numpy as np import pytest +from k1link.data_plane import DecodedDataPlaneView, NormalizationError from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC +from k1link.protocol.normalizer import normalize_k1_message from k1link.viewer.messages import StreamMessage from k1link.viewer.rerun_bridge import RerunBridge, RerunSceneSettings, _point_colors from k1link.viewer.runtime import VisualizationRuntime @@ -53,6 +55,15 @@ def _message(topic: str, payload: bytes, *, sequence: int = 7) -> StreamMessage: ) +def _envelope(topic: str, payload: bytes, *, sequence: int = 7) -> DecodedDataPlaneView: + envelope = normalize_k1_message( + _message(topic, payload, sequence=sequence), + processing_started_monotonic_ns=time.monotonic_ns(), + ) + assert envelope is not None + return envelope + + def test_legacy_points_and_pose_are_logged_to_rerun() -> None: recording = FakeRecording() bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type] @@ -61,8 +72,8 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> None: " None: assert recording.disconnected is True -def test_bad_frame_is_counted_without_publishing() -> None: +def test_bad_frame_is_rejected_before_rerun_without_publishing() -> None: recording = FakeRecording() bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type] - bridge.process(_message("RealtimePointcloud", b"short")) + with pytest.raises(NormalizationError): + normalize_k1_message( + _message("RealtimePointcloud", b"short"), + processing_started_monotonic_ns=time.monotonic_ns(), + ) snapshot = bridge.metrics.snapshot() - assert snapshot["messages_received"] == 1 - assert snapshot["decode_errors"] == 1 + assert snapshot["messages_received"] == 0 + assert snapshot["decode_errors"] == 0 assert not any(path == "/world/points" for path, _, _ in recording.logs) @@ -193,6 +208,7 @@ def test_runtime_exposes_rerun_url_and_stops_cleanly(tmp_path: Path) -> None: runtime = VisualizationRuntime( bridge_factory=bridge_factory, + normalizer=normalize_k1_message, ) runtime.start_replay(capture, speed=1.0) deadline = time.monotonic() + 5.0 @@ -258,7 +274,10 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) -> **kwargs, # type: ignore[arg-type] ) - runtime = VisualizationRuntime(bridge_factory=blocked_factory) + runtime = VisualizationRuntime( + bridge_factory=blocked_factory, + normalizer=normalize_k1_message, + ) runtime.start_replay(capture, speed=0.0) assert factory_entered.wait(timeout=2.0) @@ -306,7 +325,10 @@ def test_stop_fails_closed_when_runtime_thread_misses_deadline(tmp_path: Path) - **kwargs, # type: ignore[arg-type] ) - runtime = VisualizationRuntime(bridge_factory=blocked_factory) + runtime = VisualizationRuntime( + bridge_factory=blocked_factory, + normalizer=normalize_k1_message, + ) runtime.start_replay(capture, speed=0.0) assert factory_entered.wait(timeout=2.0) diff --git a/tests/test_stream_decoders.py b/tests/test_stream_decoders.py index ed8b42e..bd13179 100644 --- a/tests/test_stream_decoders.py +++ b/tests/test_stream_decoders.py @@ -113,20 +113,10 @@ def test_decode_lio_pcl_rejects_unverified_or_unsafe_frames() -> None: def test_decode_lio_pose() -> None: position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75) - orientation = ( - _fixed64(1, 0.1) - + _fixed64(2, 0.2) - + _fixed64(3, 0.3) - + _fixed64(4, 0.9) - ) + orientation = _fixed64(1, 0.1) + _fixed64(2, 0.2) + _fixed64(3, 0.3) + _fixed64(4, 0.9) pose = _bytes(1, position) + _bytes(2, orientation) stamped = _sint(1, 987654321) + _bytes(2, pose) - payload = ( - _bytes(1, _header()) - + _bytes(2, stamped) - + _fixed32(3, 12.5) - + _fixed32(4, 0.001) - ) + payload = _bytes(1, _header()) + _bytes(2, stamped) + _fixed32(3, 12.5) + _fixed32(4, 0.001) frame = decode_lio_pose(payload) assert frame.pose_stamp == 987654321 diff --git a/tests/test_stream_summary.py b/tests/test_stream_summary.py index f7508f7..b6faadf 100644 --- a/tests/test_stream_summary.py +++ b/tests/test_stream_summary.py @@ -77,15 +77,9 @@ def _pcl_payload(*, scaler: int, point_count: int) -> bytes: def _pose_payload(position_xyz: tuple[float, float, float]) -> bytes: position = b"".join( - _fixed64(field_number, value) - for field_number, value in enumerate(position_xyz, start=1) - ) - orientation = ( - _fixed64(1, 0.0) - + _fixed64(2, 0.0) - + _fixed64(3, 0.0) - + _fixed64(4, 1.0) + _fixed64(field_number, value) for field_number, value in enumerate(position_xyz, start=1) ) + orientation = _fixed64(1, 0.0) + _fixed64(2, 0.0) + _fixed64(3, 0.0) + _fixed64(4, 1.0) pose = _bytes(1, position) + _bytes(2, orientation) stamped = _sint(1, 987654321) + _bytes(2, pose) return _bytes(1, _header(scaler=1000)) + _bytes(2, stamped) diff --git a/tests/test_web_console.py b/tests/test_web_console.py index 24aa133..f0c1417 100644 --- a/tests/test_web_console.py +++ b/tests/test_web_console.py @@ -19,8 +19,8 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1( "devices": [ { "macos_uuid": "K1-UUID", - "name": "XGR-A46BE7", - "local_name": "XGR-A46BE7", + "name": "XGR-TEST01", + "local_name": "XGR-TEST01", "rssi": -51, "k1_name_candidate": True, }, diff --git a/tests/test_web_validation_security.py b/tests/test_web_validation_security.py new file mode 100644 index 0000000..f8b6bc9 --- /dev/null +++ b/tests/test_web_validation_security.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest + +from k1link.web.app import INVALID_REQUEST_DETAIL, app +from k1link.web.xgrids_k1_facade import XGRIDS_K1_PLUGIN_ID + + +async def _post_json(path: str, payload: dict[str, Any]) -> tuple[int, str]: + body = json.dumps(payload).encode() + request_sent = False + response_messages: list[dict[str, Any]] = [] + + async def receive() -> dict[str, Any]: + nonlocal request_sent + if request_sent: + return {"type": "http.disconnect"} + request_sent = True + return {"type": "http.request", "body": body, "more_body": False} + + async def send(message: dict[str, Any]) -> None: + response_messages.append(message) + + scope: dict[str, Any] = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "root_path": "", + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + "client": ("127.0.0.1", 41000), + "server": ("127.0.0.1", 8765), + } + await app(scope, receive, send) + + start = next( + message for message in response_messages if message["type"] == "http.response.start" + ) + response_body = b"".join( + message.get("body", b"") + for message in response_messages + if message["type"] == "http.response.body" + ) + return int(start["status"]), response_body.decode() + + +@pytest.mark.parametrize( + ("path", "wrap_input"), + [ + ( + f"/api/v1/device-plugins/{XGRIDS_K1_PLUGIN_ID}/actions/network.provision", + True, + ), + ("/api/connect", False), + ], +) +def test_validation_errors_do_not_echo_sensitive_request_values( + path: str, + wrap_input: bool, +) -> None: + sensitive_value = "x" * 300 + action_input = { + "device_id": "synthetic-device", + "ssid": "synthetic-network", + "password": sensitive_value, + "compatibility_attestation": { + "firmware_version": "3.0.2", + "topology": "direct-lan", + "operator_confirmed": True, + }, + } + payload = {"input": action_input} if wrap_input else action_input + + status_code, response_text = asyncio.run(_post_json(path, payload)) + + assert status_code == 422 + assert json.loads(response_text) == {"detail": INVALID_REQUEST_DETAIL} + assert sensitive_value not in response_text + assert sensitive_value[:32] not in response_text + assert "input_value" not in response_text diff --git a/tests/test_wifi_provisioning.py b/tests/test_wifi_provisioning.py index dcf8ae2..fb3b0b7 100644 --- a/tests/test_wifi_provisioning.py +++ b/tests/test_wifi_provisioning.py @@ -8,31 +8,34 @@ from k1link.ble.wifi_provisioning import ( def test_build_wifi_provisioning_frame_layout() -> None: - frame = build_wifi_provisioning_frame("LabNet", "correct horse") + credential = "x" * 13 + frame = build_wifi_provisioning_frame("LabNet", credential) assert len(frame) == FRAME_LENGTH assert frame[0] == 6 assert frame[1:7] == b"LabNet" assert frame[7:33] == bytes(26) assert frame[33] == 13 - assert frame[34:47] == b"correct horse" + assert frame[34:47] == b"x" * 13 assert frame[47:98] == bytes(51) assert frame[98] == 0 def test_build_wifi_provisioning_frame_uses_utf8_byte_lengths() -> None: - frame = build_wifi_provisioning_frame("Сеть", "пароль") + ssid = "Ж" * 4 + credential = "я" * 6 + frame = build_wifi_provisioning_frame(ssid, credential) - assert frame[0] == len("Сеть".encode()) - assert frame[33] == len("пароль".encode()) + assert frame[0] == len(ssid.encode()) + assert frame[33] == len(credential.encode()) @pytest.mark.parametrize( ("ssid", "password", "message"), [ - ("", "password", "SSID must not be empty"), + ("", "x" * 8, "SSID must not be empty"), ("network", "", "password must not be empty"), - ("x" * 33, "password", "at most 32 UTF-8 bytes"), + ("x" * 33, "y" * 8, "at most 32 UTF-8 bytes"), ("network", "x" * 65, "at most 64 UTF-8 bytes"), ], ) diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py new file mode 100644 index 0000000..a950dd6 --- /dev/null +++ b/tests/test_xgrids_acquisition_lifecycle.py @@ -0,0 +1,697 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import pytest +from pydantic import SecretStr + +import k1link.web.xgrids_k1_facade as facade_module +from k1link.web.xgrids_k1_facade import ( + DEFAULT_LIVE_STREAMS, + XGRIDS_K1_COMPATIBILITY_PROFILE_ID, + AbortAcquisitionRequest, + CompatibilityAttestationRequest, + ConnectRequest, + PrepareAcquisitionRequest, + StartAcquisitionRequest, + StopAcquisitionRequest, + XgridsK1CompatibilityService, +) + +ATTESTATION = CompatibilityAttestationRequest( + firmware_version="3.0.2", + topology="direct-lan", + operator_confirmed=True, +) +PRIMARY_TEST_CREDENTIAL = "x" * 24 +SECONDARY_TEST_CREDENTIAL = "y" * 24 + + +class FakeVisualizationRuntime: + def __init__(self) -> None: + self.phase = "idle" + self.source_mode = "idle" + self.source_ready = False + self.pcl_frames = 0 + self.start_calls: list[tuple[str, Path, float]] = [] + self.stop_calls = 0 + self.stop_error: Exception | None = None + + def snapshot(self) -> dict[str, Any]: + return { + "phase": self.phase, + "message": "test runtime", + "source_mode": self.source_mode, + "source_ready": self.source_ready, + "foxglove_ws_url": None, + "foxglove_viewer_url": None, + "rerun_grpc_url": None, + "viewer_settings": {}, + "metrics": { + "messages_received": self.pcl_frames, + "payload_bytes": 0, + "pcl_frames": self.pcl_frames, + "pose_frames": 0, + "points_published": 0, + "last_point_count": 0, + "decode_errors": 0, + "preview_dropped": 0, + "pcl_fps": 0.0, + "pose_fps": 0.0, + "mqtt_to_publish_ms": None, + "mqtt_to_publish_p50_ms": None, + "mqtt_to_publish_p95_ms": None, + "decode_publish_ms": None, + "trajectory_poses": 0, + }, + } + + def start_live(self, host: str, out_dir: Path, *, duration_seconds: float) -> None: + self.start_calls.append((host, out_dir, duration_seconds)) + self.phase = "starting_live" + self.source_mode = "live" + + def mark_ready(self) -> None: + self.phase = "live" + self.source_ready = True + + def stop(self) -> None: + self.stop_calls += 1 + if self.stop_error is not None: + raise self.stop_error + self.phase = "idle" + self.source_mode = "idle" + self.source_ready = False + + +def service_with_fake_runtime( + tmp_path: Path, +) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]: + service = XgridsK1CompatibilityService(tmp_path) + runtime = FakeVisualizationRuntime() + service.runtime = runtime # type: ignore[assignment] + return service, runtime + + +def test_prepare_creates_provisional_device_session_and_profiled_acquisition( + tmp_path: Path, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + + state = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + duration_seconds=60, + compatibility_attestation=ATTESTATION, + ) + ) + + assert runtime.start_calls == [] + assert state["device_ref"]["identity_stability"] == "provisional" + assert state["device_ref"]["device_id"] != state["device_session"]["device_session_id"] + assert state["acquisition"]["state"] == "prepared" + assert state["acquisition"]["compatibility_profile_id"] == (XGRIDS_K1_COMPATIBILITY_PROFILE_ID) + assert state["compatibility"]["vendor_writes_enabled"] is False + + +def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Path) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + duration_seconds=60, + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + + starting = service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + + assert starting["acquisition"]["state"] == "starting" + assert starting["last_operation"]["status"] == "running" + runtime.mark_ready() + awaiting = service.state() + assert awaiting["acquisition"]["state"] == "awaiting_external_start" + assert awaiting["last_operation"]["status"] == "operator_action_required" + assert len(runtime.start_calls) == 1 + + runtime.pcl_frames = 1 + acquiring = service.state() + + assert acquiring["acquisition"]["state"] == "acquiring" + assert acquiring["last_operation"]["status"] == "succeeded" + assert acquiring["last_operation"]["result"]["confirmation"] == "point-frame" + + +def test_receiver_completion_without_point_data_fails_start_operation(tmp_path: Path) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + duration_seconds=60, + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + + runtime.phase = "idle" + runtime.source_mode = "idle" + failed = service.state() + + assert failed["acquisition"]["state"] == "failed" + assert failed["last_operation"]["status"] == "failed" + assert failed["acquisition"]["result"]["device_state"] == "unknown" + + +def test_capture_only_stop_never_claims_that_physical_k1_stopped(tmp_path: Path) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + duration_seconds=60, + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + + stopped = service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="capture-only") + ) + + assert runtime.stop_calls == 1 + assert stopped["acquisition"]["state"] == "completed" + assert stopped["acquisition"]["result"] == { + "receiver_stopped": True, + "device_stop": "unknown", + } + operations = {item["action"]: item for item in stopped["operations"]} + assert operations["acquisition.start"]["status"] == "cancelled" + assert operations["acquisition.stop"]["status"] == "succeeded" + + +def test_graceful_stop_waits_for_explicit_operator_confirmation(tmp_path: Path) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + duration_seconds=60, + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.mark_ready() + service.state() + runtime.pcl_frames = 1 + acquiring = service.state() + assert acquiring["acquisition"]["state"] == "acquiring" + + awaiting = service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful") + ) + operation_id = awaiting["last_operation"]["operation_id"] + + assert runtime.stop_calls == 0 + assert awaiting["acquisition"]["state"] == "awaiting_external_stop" + assert awaiting["last_operation"]["status"] == "operator_action_required" + + retried = service.stop_acquisition( + StopAcquisitionRequest( + acquisition_id=acquisition_id, + operation_id=operation_id, + mode="graceful", + ) + ) + assert retried["acquisition"]["state"] == "awaiting_external_stop" + assert retried["last_operation"]["operation_id"] == operation_id + assert retried["last_operation"]["status"] == "operator_action_required" + + with pytest.raises(ValueError, match="исходную stop-operation"): + service.stop_acquisition( + StopAcquisitionRequest( + acquisition_id=acquisition_id, + mode="graceful", + operator_confirmed=True, + ) + ) + + completed = service.stop_acquisition( + StopAcquisitionRequest( + acquisition_id=acquisition_id, + operation_id=operation_id, + mode="graceful", + operator_confirmed=True, + ) + ) + + assert runtime.stop_calls == 1 + assert completed["acquisition"]["result"]["device_stop"] == "operator-confirmed" + stop_operations = [ + item for item in completed["operations"] if item["action"] == "acquisition.stop" + ] + assert len(stop_operations) == 1 + assert stop_operations[0]["operation_id"] == operation_id + assert stop_operations[0]["status"] == "succeeded" + + +def test_graceful_stop_retry_by_idempotency_key_reuses_original_operation( + tmp_path: Path, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.mark_ready() + service.state() + runtime.pcl_frames = 1 + service.state() + + first = service.stop_acquisition( + StopAcquisitionRequest( + acquisition_id=acquisition_id, + idempotency_key="graceful-stop-once", + mode="graceful", + ) + ) + first_operation_id = first["last_operation"]["operation_id"] + retried = service.stop_acquisition( + StopAcquisitionRequest( + acquisition_id=acquisition_id, + idempotency_key="graceful-stop-once", + mode="graceful", + ) + ) + + assert retried["last_operation"]["operation_id"] == first_operation_id + assert retried["last_operation"]["status"] == "operator_action_required" + assert ( + len([item for item in retried["operations"] if item["action"] == "acquisition.stop"]) == 1 + ) + + +def test_unrelated_graceful_stop_is_rejected_while_confirmation_is_pending( + tmp_path: Path, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.mark_ready() + service.state() + runtime.pcl_frames = 1 + service.state() + first = service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful") + ) + expected_operation_id = first["last_operation"]["operation_id"] + + with pytest.raises(ValueError, match="уже ожидает"): + service.stop_acquisition( + StopAcquisitionRequest( + acquisition_id=acquisition_id, + idempotency_key="unrelated-stop", + mode="graceful", + ) + ) + + original = next( + item + for item in service.state()["operations"] + if item["operation_id"] == expected_operation_id + ) + assert original["status"] == "operator_action_required" + + +def test_graceful_stop_is_rejected_until_point_data_confirms_acquisition( + tmp_path: Path, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + + with pytest.raises(ValueError, match="подтверждённого потока point cloud"): + service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful") + ) + + state = service.state() + assert runtime.stop_calls == 0 + assert state["acquisition"]["state"] == "starting" + assert state["last_operation"]["action"] == "acquisition.start" + assert state["last_operation"]["status"] == "running" + + +@pytest.mark.parametrize("evidence_policy", ["best-effort", "disabled"]) +def test_prepare_rejects_unsupported_evidence_policies( + tmp_path: Path, + evidence_policy: str, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + + with pytest.raises(ValueError, match="evidence_policy=required"): + service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + evidence_policy=evidence_policy, # type: ignore[arg-type] + compatibility_attestation=ATTESTATION, + ) + ) + + assert service.state()["compatibility"]["profile_id"] is None + + +@pytest.mark.parametrize( + "requested_streams", + [ + DEFAULT_LIVE_STREAMS[:-1], + (*DEFAULT_LIVE_STREAMS, DEFAULT_LIVE_STREAMS[0]), + ], +) +def test_prepare_rejects_stream_subsets_and_duplicates( + tmp_path: Path, + requested_streams: tuple[str, ...], +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + + with pytest.raises(ValueError, match="полный проверенный набор"): + service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + requested_streams=requested_streams, # type: ignore[arg-type] + compatibility_attestation=ATTESTATION, + ) + ) + + +def test_exact_profile_is_inactive_until_explicit_operator_attestation( + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + + initial = service.state() + assert initial["compatibility"] == { + "profile_id": None, + "decision": "unknown", + "permitted_mode": "evidence-only", + "firmware_claim": "exact-3.0.2-profile-not-attested", + "attestation": None, + "vendor_writes_enabled": False, + "camera_preview": "unverified", + } + assert initial["device_calibration"]["compatibility_profile_id"] is None + + attested = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + + assert attested["compatibility"]["profile_id"] == XGRIDS_K1_COMPATIBILITY_PROFILE_ID + assert attested["compatibility"]["decision"] == "limited" + assert attested["compatibility"]["attestation"]["basis"] == "operator-attested" + assert attested["device_session"]["compatibility_profile_id"] == ( + XGRIDS_K1_COMPATIBILITY_PROFILE_ID + ) + + +def test_prepare_rejects_device_ap_fallback_as_direct_lan_target(tmp_path: Path) -> None: + service, _ = service_with_fake_runtime(tmp_path) + + with pytest.raises(ValueError, match="точки доступа"): + service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.56.1", + compatibility_attestation=ATTESTATION, + ) + ) + + state = service.state() + assert state["device_ref"] is None + assert state["compatibility"]["profile_id"] is None + + +def test_runtime_error_terminalizes_pending_graceful_stop(tmp_path: Path) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.mark_ready() + service.state() + runtime.pcl_frames = 1 + service.state() + awaiting = service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful") + ) + stop_operation_id = awaiting["last_operation"]["operation_id"] + + runtime.phase = "error" + failed = service.state() + + stop_operation = next( + item for item in failed["operations"] if item["operation_id"] == stop_operation_id + ) + assert failed["acquisition"]["state"] == "failed" + assert stop_operation["status"] == "failed" + assert stop_operation["error"]["side_effect_status"] == "unknown" + + +def test_receiver_completion_terminalizes_unconfirmed_graceful_stop( + tmp_path: Path, +) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.mark_ready() + service.state() + runtime.pcl_frames = 1 + service.state() + awaiting = service.stop_acquisition( + StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful") + ) + stop_operation_id = awaiting["last_operation"]["operation_id"] + + runtime.phase = "idle" + runtime.source_mode = "idle" + runtime.source_ready = False + failed = service.state() + + stop_operation = next( + item for item in failed["operations"] if item["operation_id"] == stop_operation_id + ) + assert failed["acquisition"]["state"] == "failed" + assert failed["acquisition"]["result"] == { + "receiver_stopped": True, + "device_state": "unknown", + } + assert stop_operation["status"] == "failed" + assert stop_operation["error"]["code"] == ("receiver-completed-before-device-stop-confirmation") + + +def test_abort_failure_terminalizes_acquisition_and_pending_start(tmp_path: Path) -> None: + service, runtime = service_with_fake_runtime(tmp_path) + prepared = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + acquisition_id = prepared["acquisition"]["acquisition_id"] + service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id)) + runtime.stop_error = RuntimeError("synthetic receiver stop failure") + + with pytest.raises(RuntimeError, match="synthetic receiver stop failure"): + service.abort_acquisition(AbortAcquisitionRequest(acquisition_id=acquisition_id)) + + state = service.state() + operations = {item["action"]: item for item in state["operations"]} + assert state["acquisition"]["state"] == "failed" + assert state["acquisition"]["result"] == { + "receiver_stopped": False, + "device_state": "unknown", + } + assert operations["acquisition.start"]["status"] == "cancelled" + assert operations["acquisition.abort"]["status"] == "failed" + assert state["source_mode"] == "live" + + +def test_replay_is_rejected_during_nonterminal_acquisition(tmp_path: Path) -> None: + service, _ = service_with_fake_runtime(tmp_path) + service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + + with pytest.raises(RuntimeError, match="активной acquisition-сессии"): + service.start_replay("sessions/fixture.k1mqtt", speed=1.0, loop=False) + + assert service.state()["acquisition"]["state"] == "prepared" + + +def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_boundary( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + service._devices = [ # noqa: SLF001 - deliberate white-box concurrency fixture + {"device_id": "k1-a"}, + {"device_id": "k1-b"}, + ] + boundary_calls: list[tuple[str, str, str]] = [] + + async def scenario() -> dict[str, Any]: + entered = asyncio.Event() + release = asyncio.Event() + + async def fake_provision( + device_id: str, + ssid: str, + password: str, + **_: object, + ) -> dict[str, Any]: + boundary_calls.append((device_id, ssid, password)) + entered.set() + await release.wait() + return { + "started_at_utc": "2026-07-16T12:00:00Z", + "completed_at_utc": "2026-07-16T12:00:01Z", + "profile_id": "xgrids-k1-fw3-wifi-v1", + "outcome": "lan_address_observed", + "observations": [{"status": {"ipv4": "192.168.1.20"}}], + } + + monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision) + first = asyncio.create_task( + service.connect( + ConnectRequest( + device_id="k1-a", + ssid="lab-network", + password=SecretStr(PRIMARY_TEST_CREDENTIAL), + compatibility_attestation=ATTESTATION, + ) + ) + ) + await asyncio.wait_for(entered.wait(), timeout=1.0) + with pytest.raises(RuntimeError, match="уже выполняется"): + await service.connect( + ConnectRequest( + device_id="k1-b", + ssid="other-network", + password=SecretStr(SECONDARY_TEST_CREDENTIAL), + compatibility_attestation=ATTESTATION, + ) + ) + release.set() + return await asyncio.wait_for(first, timeout=1.0) + + connected = asyncio.run(scenario()) + + assert boundary_calls == [("k1-a", "lab-network", PRIMARY_TEST_CREDENTIAL)] + assert connected["k1_ip"] == "192.168.1.20" + assert PRIMARY_TEST_CREDENTIAL not in str(connected) + provision_operations = [ + item for item in connected["operations"] if item["action"] == "network.provision" + ] + assert {item["status"] for item in provision_operations} == {"succeeded", "failed"} + + +def test_provisioning_cannot_switch_device_during_active_acquisition( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + service._devices = [{"device_id": "k1-a"}] # noqa: SLF001 + service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + called = False + + async def should_not_run(*_: object, **__: object) -> dict[str, Any]: + nonlocal called + called = True + raise AssertionError("provisioning boundary must not be reached") + + monkeypatch.setattr(facade_module, "provision_wifi_once", should_not_run) + + with pytest.raises(RuntimeError, match="активной acquisition-сессии"): + asyncio.run( + service.connect( + ConnectRequest( + device_id="k1-a", + ssid="lab-network", + password=SecretStr(PRIMARY_TEST_CREDENTIAL), + compatibility_attestation=ATTESTATION, + ) + ) + ) + + assert called is False + + +def test_sensor_catalog_exposes_observed_camera_only_after_profile_attestation( + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + + initial = service.state() + initial_camera = next( + stream + for stream in initial["sensor_catalog"]["streams"] + if stream["stream_id"] == "camera.preview.live" + ) + assert initial_camera["availability"] == "unverified" + + state = service.prepare_acquisition( + PrepareAcquisitionRequest( + host="192.168.1.20", + compatibility_attestation=ATTESTATION, + ) + ) + camera = next( + stream + for stream in state["sensor_catalog"]["streams"] + if stream["stream_id"] == "camera.preview.live" + ) + + assert camera["availability"] == "observed" + assert camera["modality"] == "encoded-video" + assert camera["decode_status"] == "transport-observed-runtime-adapter-pending" + assert state["connection_verification"]["network_reachability"] == "unknown" + assert state["device_calibration"]["status"] == "unavailable" + assert state["device_calibration"]["vehicle_extrinsics"] == ("host-domain-not-owned-by-plugin") diff --git a/tests/test_xgrids_compatibility_profile.py b/tests/test_xgrids_compatibility_profile.py new file mode 100644 index 0000000..16988ab --- /dev/null +++ b/tests/test_xgrids_compatibility_profile.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import copy +import importlib.util +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +REPOSITORY_ROOT = Path(__file__).parents[1] +LOADER_PATH = REPOSITORY_ROOT / "plugins" / "xgrids-k1" / "profile_loader.py" + + +def _load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("xgrids_k1_profile_loader", LOADER_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +LOADER = _load_module() + + +def _by_id(items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + return {item["id"]: item for item in items} + + +def _boolean_write_flags(value: Any) -> list[bool]: + flags: list[bool] = [] + if isinstance(value, dict): + write_enabled = value.get("write_enabled") + if isinstance(write_enabled, bool): + flags.append(write_enabled) + for child in value.values(): + flags.extend(_boolean_write_flags(child)) + elif isinstance(value, list): + for child in value: + flags.extend(_boolean_write_flags(child)) + return flags + + +def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None: + profile = LOADER.load_compatibility_profile() + + assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" + assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"} + assert profile["scope"]["topology"] == "direct-lan" + assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan") + assert not LOADER.matches_target(profile, firmware="3.0.3", topology="direct-lan") + assert not LOADER.matches_target(profile, firmware="3.0.2", topology="device-ap") + + for source in profile["evidence_sources"]: + assert (REPOSITORY_ROOT / source["path"]).is_file() + + +def test_xgrids_compatibility_profile_keeps_evidence_levels_independent() -> None: + profile = LOADER.load_compatibility_profile() + channels = _by_id(profile["channels"]) + + verified_stream = { + "observed": True, + "decoded": True, + "replay_verified": True, + "physical_verified": True, + "write_enabled": False, + } + raw_status = { + "observed": True, + "decoded": False, + "replay_verified": False, + "physical_verified": True, + "write_enabled": False, + } + observed_raw = { + "observed": True, + "decoded": False, + "replay_verified": False, + "physical_verified": True, + "write_enabled": False, + } + + assert channels["spatial.point-cloud.live"]["evidence"] == verified_stream + assert channels["spatial.pose.live"]["evidence"] == verified_stream + assert channels["device.status.live"]["evidence"] == raw_status + assert channels["device.heartbeat.live"]["evidence"] == raw_status + assert channels["device.status.live"]["semantic_payload"] is None + + camera = channels["camera.preview.live"] + assert camera["discovery_status"] == "observed" + assert camera["topic"] is None + assert set(camera["endpoint_templates"]) == { + "rtsp://{confirmed-device-private-ipv4}:8554/live/chn_left_main", + "rtsp://{confirmed-device-private-ipv4}:8554/live/chn_right_main", + } + assert "H.264" in camera["wire_format"] + assert camera["evidence"] == observed_raw + + +def test_xgrids_compatibility_profile_declares_only_reviewed_transports() -> None: + profile = LOADER.load_compatibility_profile() + transports = _by_id(profile["transports"]) + + ble = transports["ble.wifi-bootstrap.fw3.v1"] + assert ble["service_uuid"] == "00007f00-0000-1000-8000-00805f9b34fb" + assert ble["characteristics"] == { + "wifi_request": "00007f01-0000-1000-8000-00805f9b34fb", + "wifi_status": "00007f02-0000-1000-8000-00805f9b34fb", + } + assert ble["request_frame_bytes"] == 99 + assert ble["evidence"]["observed"] is True + assert ble["evidence"]["physical_verified"] is True + assert ble["evidence"]["write_enabled"] is False + + mqtt = transports["mqtt.direct-lan.fw3.v1"] + assert mqtt["protocol"] == "MQTT 3.1.1" + assert mqtt["network"]["transport"] == "TCP" + assert mqtt["network"]["port"] == 1883 + assert mqtt["network"]["tls"] is False + assert "lixel/application/report/#" in mqtt["subscription_allowlist"] + assert not any("/request/" in topic for topic in mqtt["subscription_allowlist"]) + assert mqtt["evidence"]["observed"] is True + assert mqtt["evidence"]["decoded"] is False + assert mqtt["evidence"]["write_enabled"] is False + + rtsp = transports["rtsp.camera-preview.fw3.v1"] + assert rtsp["protocol"] == "RTSP 1.0 with interleaved RTP over TCP" + assert rtsp["network"]["port"] == 8554 + assert rtsp["media"]["codec"] == "H.264" + assert rtsp["media"]["rtp_payload_type"] == 96 + assert rtsp["evidence"]["observed"] is True + assert rtsp["evidence"]["write_enabled"] is False + + +def test_xgrids_compatibility_profile_maps_actions_without_enabling_writes() -> None: + profile = LOADER.load_compatibility_profile() + control = profile["acquisition_control"] + actions = _by_id(control["semantic_actions"]) + + assert profile["safety"]["default_mode"] == "read-only" + assert profile["safety"]["vendor_writes_enabled"] is False + assert control["mode"] == "operator-manual" + assert control["verified_device_control"]["gesture"] == "physical-double-click" + + for action_id, action_code in (("acquisition.start", 1), ("acquisition.stop", 2)): + action = actions[action_id] + mapping = action["vendor_request_mapping"] + assert action["execution"] == "operator-manual" + assert mapping["evidence_kind"] == "owner-controlled-wire-observation" + assert mapping["topic"] == "lixel/application/request/modeling" + assert mapping["qos"] == 2 + assert mapping["message_type"] == "ModelingRequest" + assert mapping["action_field_value"] == action_code + assert mapping["required_unresolved_context"] + assert mapping["evidence"]["observed"] is True + assert mapping["evidence"]["decoded"] is True + assert mapping["evidence"]["physical_verified"] is True + assert mapping["evidence"]["replay_verified"] is False + assert mapping["write_enabled"] is False + + calibration = actions["calibration.device.start"] + assert calibration["execution"] == "unavailable" + assert calibration["vendor_request_mapping"] is None + assert calibration["evidence"]["observed"] is False + assert _boolean_write_flags(profile) + assert not any(_boolean_write_flags(profile)) + + +def test_xgrids_compatibility_profile_rejects_vendor_write_promotion() -> None: + profile = LOADER.load_compatibility_profile() + modified = copy.deepcopy(profile) + actions = _by_id(modified["acquisition_control"]["semantic_actions"]) + actions["acquisition.start"]["vendor_request_mapping"]["write_enabled"] = True + + with pytest.raises(LOADER.CompatibilityProfileError, match="must remain false"): + LOADER.validate_compatibility_profile(modified) + + +def test_xgrids_compatibility_profile_rejects_claimed_camera_endpoint() -> None: + profile = LOADER.load_compatibility_profile() + modified = copy.deepcopy(profile) + channels = _by_id(modified["channels"]) + channels["camera.preview.live"]["endpoint_templates"].append("rtsp://unverified") + + with pytest.raises(LOADER.CompatibilityProfileError, match="camera endpoint templates"): + LOADER.validate_compatibility_profile(modified) + + +def test_xgrids_compatibility_profile_rejects_duplicate_json_keys(tmp_path: Path) -> None: + profile_path = tmp_path / "duplicate.json" + profile_path.write_text('{"schema_version": 1, "schema_version": 1}', encoding="utf-8") + + with pytest.raises(LOADER.CompatibilityProfileError, match="duplicate JSON key"): + LOADER.load_compatibility_profile(profile_path)