feat(device-plugins): add profiled K1 lifecycle and canonical data plane

This commit is contained in:
DCCONSTRUCTIONS 2026-07-16 19:44:06 +03:00
parent 19ab973110
commit e6f7648b84
45 changed files with 6401 additions and 440 deletions

View File

@ -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": {

View File

@ -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"
}
}

View File

@ -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;
}

View File

@ -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<string, unknown> {
@ -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<string>();
const profilePaths = new Set<string>();
const coveredModels = new Set<string>();
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,28 +233,31 @@ 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",
} as const;
const commonSpec = {
runtime: {
backendEntrypoint: text(
runtime.backendEntrypoint,
@ -160,14 +267,38 @@ export function parseDevicePluginManifest(document: unknown): DevicePluginManife
isolation,
},
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
text(entry, `spec.permissions[${index}]`),
contractText(entry, `spec.permissions[${index}]`, strictIdentifiers),
),
actions: list(spec.actions, "spec.actions").map((entry, index) =>
action(entry, `spec.actions[${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(

View File

@ -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<string>();
const profilePaths = new Set<string>();
const declaredModelIds = new Set(manifest.spec.models.map((model) => model.id));
const coveredModelIds = new Set<string>();
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]));

View File

@ -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;

View File

@ -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 (
<div className="detail-row">
@ -92,7 +104,7 @@ function DeviceRow({
<div>
<span className="device-row__name">
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
{device.likely_k1 ? <small>Кандидат по имени; профиль не подтверждён</small> : null}
</span>
<code>{device.device_id}</code>
</div>
@ -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<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
const provisioningIntentRef = useRef<string | null>(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)
</div>
<div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
Повторить
Обновить состояние
</Button>
<Button size="compact" variant="ghost" onClick={clearError}>
Закрыть
@ -248,7 +322,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
</div>
</section>
@ -286,7 +360,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span>
<h2>Подключите устройство к сети</h2>
</div>
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
</header>
<div className="wizard-list">
@ -303,7 +377,13 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
onChange={setPowerConfirmed}
onChange={(checked) => {
setPowerConfirmed(checked);
if (!checked) {
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}
}}
/>
</div>
</WizardStep>
@ -327,8 +407,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
}
>
<p className="step-copy">
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
профиль только подсказка; окончательный выбор всегда делает оператор.
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата
основана только на имени и не подтверждает модель или прошивку; окончательный выбор
и аттестацию всегда делает оператор.
</p>
<Button
width="full"
@ -337,6 +418,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
disabled={!powerConfirmed || isBusy}
onClick={() => {
setSelectedDeviceId("");
setProfileConfirmed(false);
provisioningIntentRef.current = null;
void scan();
}}
>
@ -351,7 +434,11 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => setSelectedDeviceId(device.device_id)}
onSelect={() => {
setSelectedDeviceId(device.device_id);
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}}
/>
))
) : (
@ -370,11 +457,28 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">
Mission Core не определяет прошивку автоматически. Сверьте её на устройстве
или в официальном приложении и подтвердите только точное соответствие профилю.
</span>
<Checker
checked={profileConfirmed}
label="Я вручную подтвердил FW 3.0.2 и прямое подключение в локальной сети"
onChange={(checked) => {
setProfileConfirmed(checked);
provisioningIntentRef.current = null;
}}
/>
</div>
<TextField
label="Название сети WiFi"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
onChange={(event) => {
setSsid(event.target.value);
provisioningIntentRef.current = null;
}}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
@ -384,7 +488,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onChange={(event) => {
setPassword(event.target.value);
provisioningIntentRef.current = null;
}}
autoComplete="off"
placeholder="Введите пароль"
/>
@ -415,23 +522,25 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
<header className="panel-heading">
<div>
<span className="section-eyebrow">
{sessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
{effectiveSessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</span>
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
<StatusBadge tone={sourceTone}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={sessionIntent}
items={sessionItems}
onChange={setSessionIntent}
value={effectiveSessionIntent}
items={selectableSessionItems}
onChange={(intent) => {
if (!sessionLocked) setSessionIntent(intent);
}}
/>
{sessionIntent === "live" ? (
{effectiveSessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
@ -439,19 +548,35 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к WiFi"}
placeholder={
state?.k1_ip ||
preparedAcquisition?.target_host ||
"Сначала подключите устройство к WiFi"
}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady}
disabled={
isBusy ||
!profileConfirmed ||
!liveTargetReady ||
sourceRuntimeBusy ||
(activeAcquisition !== null && preparedAcquisition === null)
}
onClick={() => void submitLive()}
>
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
{pendingAction === "live"
? "Подготавливаем приём…"
: preparedAcquisition
? "Продолжить подготовленный приём"
: "Подготовить приём данных"}
</Button>
<p className="live-instruction">
{liveTargetReady
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
{!profileConfirmed
? "Сначала вручную подтвердите точную прошивку 3.0.2 и direct-LAN топологию. Интерфейс не аттестует устройство автоматически."
: liveTargetReady
? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
: "Сначала подключите устройство к WiFi или укажите его локальный адрес."}
</p>
</div>
@ -485,7 +610,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
<Button
variant="primary"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
disabled={isBusy || sessionLocked || replayPath.trim().length === 0}
onClick={() => void submitReplay()}
>
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
@ -494,14 +619,41 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
)}
<div className="session-footer">
<p>Статус изменится только после ответа локального сервиса.</p>
<p>
{state?.source_mode === "replay"
? "Остановка завершит фактически запущенный повтор записи."
: activeAcquisition || state?.source_mode === "live"
? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."}
</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
{pendingAction === "stop"
? state?.source_mode === "replay"
? "Останавливаем повтор…"
: "Останавливаем локальный приём…"
: state?.source_mode === "replay"
? "Остановить повтор"
: preparedAcquisition
? "Завершить подготовленный приём"
: "Остановить локальный приём"}
</Button>
{activeAcquisition ? (
<Button
variant="ghost"
disabled={isBusy}
onClick={() => void abort()}
>
{pendingAction === "abort"
? "Прерываем локальную операцию…"
: preparedAcquisition
? "Отменить подготовку"
: "Аварийно завершить локальный приём"}
</Button>
) : null}
</div>
</GlassSurface>

View File

@ -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<string, unknown> | 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<string, unknown> | null;
error?: Record<string, unknown> | 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<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStartLive, body);
prepareAcquisition(body: PrepareAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionPrepare, body);
},
startAcquisition(body: StartAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionStart, body);
},
stopAcquisition(body: StopAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionStop, body);
},
abortAcquisition(body: AbortAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionAbort, body);
},
startReplay(body: ReplayRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStartReplay, body);
},
stopSession(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStop);
startLiveCompatibility(body: CompatibilityLiveRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.compatibilityStreamStartLive, body);
},
stopSessionCompatibility(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.compatibilityStreamStop);
},
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {

View File

@ -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<AcquisitionState>([
"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()}`;
}

View File

@ -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"),
});

View File

@ -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<typeof useXgridsK1Runtime>;
const XgridsK1RuntimeContext = createContext<XgridsK1Controller | null>(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),

View File

@ -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,
};
}

View File

@ -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);
});

View File

@ -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": [] },

View File

@ -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())

View File

@ -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.

View File

@ -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"
]
}
]
}
}

View File

@ -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()

View File

@ -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,

View File

@ -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",
]

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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("<fffB3x")
POINT_STRIDE = POINT_STRUCT.size
@ -52,113 +49,12 @@ FRAME_ID = "map"
MAX_TRAJECTORY_POSES = 20_000
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
@dataclass(frozen=True, slots=True)
class PackedPointCloud:
data: bytes
point_count: int
class BridgeMetrics:
"""Thread-safe counters shared with the local control API."""
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,
}
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)

View File

@ -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)

View File

@ -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))
if isinstance(envelope, DecodedPointCloudView):
self._publish_points(envelope)
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))
elif isinstance(envelope, DecodedPoseView):
self._publish_pose(envelope)
point_frame = False
else:
return
except StreamDecodeError:
self.metrics.decode_error()
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(

View File

@ -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

View File

@ -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:

View File

@ -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()}"

View File

@ -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]] = []

File diff suppressed because it is too large Load Diff

View File

@ -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=[],

View File

@ -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("<f", value)
def _fixed64(number: int, value: float) -> bytes:
return _key(number, 1) + struct.pack("<d", value)
def _lio_header() -> 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("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
legacy = normalize_k1_message(
_message("RealtimePointcloud", legacy_payload, source="legacy_tsv"),
processing_started_monotonic_ns=60,
)
assert isinstance(modern, DecodedPointCloudView)
assert modern.positions_xyz == ((1.0, -2.0, 0.5),)
assert modern.intensities == bytes((0x44,))
assert modern.colors_rgb is None
assert modern.context.source_device_alias == "device-redacted"
assert modern.context.source_session_alias == "session-redacted"
assert modern.context.live is True
assert not hasattr(modern, "topic")
assert not hasattr(modern, "payload")
assert isinstance(legacy, DecodedPointCloudView)
assert legacy.positions_xyz == ((1.0, -2.0, 3.0),)
assert legacy.intensities == bytes((40,))
assert legacy.colors_rgb == bytes((10, 20, 30))
assert legacy.context.source_device_alias is None
assert legacy.context.live is False
def test_k1_normalizer_emits_transport_neutral_poses() -> None:
modern = normalize_k1_message(
_message("lixel/application/report/lio_pose", _lio_pose_payload()),
processing_started_monotonic_ns=50,
)
legacy_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
legacy = normalize_k1_message(
_message("RealtimePath", legacy_payload, source="legacy_tsv"),
processing_started_monotonic_ns=60,
)
assert isinstance(modern, DecodedPoseView)
assert modern.frame_id == "map"
assert modern.child_frame_id == "sensor"
assert modern.position_xyz == (1.25, -2.5, 3.75)
assert modern.orientation_xyzw == (0.0, 0.0, 0.0, 1.0)
assert modern.context.source_device_alias == "device-redacted"
assert modern.context.source_session_alias == "session-redacted"
assert isinstance(legacy, DecodedPoseView)
assert legacy.position_xyz == (1.0, 2.0, 3.0)
assert legacy.orientation_xyzw == (0.0, 0.0, 0.0, 1.0)
assert legacy.context.source_device_alias is None
def test_known_invalid_and_unknown_channels_have_distinct_outcomes() -> 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()

View File

@ -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"]

View File

@ -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

View File

@ -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"))

View File

@ -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},

View File

@ -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:
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
pose_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
bridge.process(_message("RealtimePointcloud", point_payload))
bridge.process(_message("RealtimePath", pose_payload, sequence=8))
bridge.process(_envelope("RealtimePointcloud", point_payload))
bridge.process(_envelope("RealtimePath", pose_payload, sequence=8))
paths = [path for path, _, _ in recording.logs]
snapshot = bridge.metrics.snapshot()
@ -83,15 +94,19 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> 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)

View File

@ -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

View File

@ -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)

View File

@ -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,
},

View File

@ -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

View File

@ -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"),
],
)

View File

@ -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")

View File

@ -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)