feat(device-plugins): add profiled K1 lifecycle and canonical data plane
This commit is contained in:
parent
19ab973110
commit
e6f7648b84
|
|
@ -25,7 +25,7 @@
|
||||||
"vite-plugin-wasm": "^3.6.0"
|
"vite-plugin-wasm": "^3.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
|
"../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test:unit": "node --test test/*.test.mjs",
|
||||||
"typecheck": "tsc -b --pretty false"
|
"typecheck": "tsc -b --pretty false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -27,6 +28,6 @@
|
||||||
"vite-plugin-wasm": "^3.6.0"
|
"vite-plugin-wasm": "^3.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import type { ComponentType, ReactNode } from "react";
|
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 const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
|
||||||
|
|
||||||
|
export type DevicePluginApiVersion =
|
||||||
|
(typeof SUPPORTED_DEVICE_PLUGIN_API_VERSIONS)[number];
|
||||||
|
|
||||||
export interface DeviceCapability {
|
export interface DeviceCapability {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -28,14 +37,24 @@ export interface DeviceModelDefinition {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DevicePluginManifest {
|
export interface DeviceCompatibilityProfileDefinition {
|
||||||
apiVersion: typeof DEVICE_PLUGIN_API_VERSION;
|
profileId: string;
|
||||||
|
path: string;
|
||||||
|
modelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DevicePluginManifestBase {
|
||||||
|
apiVersion: DevicePluginApiVersion;
|
||||||
kind: "DevicePlugin";
|
kind: "DevicePlugin";
|
||||||
metadata: {
|
metadata: {
|
||||||
id: string;
|
id: string;
|
||||||
version: string;
|
version: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevicePluginManifestV1Alpha1 extends DevicePluginManifestBase {
|
||||||
|
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA1;
|
||||||
spec: {
|
spec: {
|
||||||
hostApiRange: "v1alpha1";
|
hostApiRange: "v1alpha1";
|
||||||
runtime: {
|
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 {
|
export interface DevicePluginHostActions {
|
||||||
openSpatialScene: () => void;
|
openSpatialScene: () => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import {
|
import {
|
||||||
DEVICE_PLUGIN_API_VERSION,
|
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||||
|
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
|
||||||
type DeviceCapability,
|
type DeviceCapability,
|
||||||
|
type DeviceCompatibilityProfileDefinition,
|
||||||
type DeviceModelDefinition,
|
type DeviceModelDefinition,
|
||||||
type DevicePluginActionDefinition,
|
type DevicePluginActionDefinition,
|
||||||
type DevicePluginManifest,
|
type DevicePluginManifest,
|
||||||
|
type DevicePluginManifestV1Alpha1,
|
||||||
|
type DevicePluginManifestV1Alpha2,
|
||||||
} from "./contracts";
|
} from "./contracts";
|
||||||
|
|
||||||
function record(value: unknown, path: string): Record<string, unknown> {
|
function record(value: unknown, path: string): Record<string, unknown> {
|
||||||
|
|
@ -36,6 +40,14 @@ function text(value: unknown, path: string, maxLength = 160): string {
|
||||||
return value;
|
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 {
|
function flag(value: unknown, path: string): boolean {
|
||||||
if (typeof value !== "boolean") {
|
if (typeof value !== "boolean") {
|
||||||
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
|
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
|
||||||
|
|
@ -50,25 +62,44 @@ function list(value: unknown, path: string): unknown[] {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function capability(value: unknown, path: string): DeviceCapability {
|
function contractText(value: unknown, path: string, strictIdentifiers: boolean): string {
|
||||||
const item = record(value, path);
|
return strictIdentifiers ? identifier(value, path) : text(value, path);
|
||||||
exactKeys(item, path, ["id", "label"]);
|
|
||||||
return { id: text(item.id, `${path}.id`), label: text(item.label, `${path}.label`) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
const item = record(value, path);
|
||||||
exactKeys(item, path, ["id", "mutating", "secretFields"]);
|
exactKeys(item, path, ["id", "mutating", "secretFields"]);
|
||||||
return {
|
return {
|
||||||
id: text(item.id, `${path}.id`),
|
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||||
mutating: flag(item.mutating, `${path}.mutating`),
|
mutating: flag(item.mutating, `${path}.mutating`),
|
||||||
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
|
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);
|
const item = record(value, path);
|
||||||
exactKeys(item, path, [
|
exactKeys(item, path, [
|
||||||
"id",
|
"id",
|
||||||
|
|
@ -87,14 +118,14 @@ function model(value: unknown, path: string): DeviceModelDefinition {
|
||||||
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
|
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: text(item.id, `${path}.id`),
|
id: contractText(item.id, `${path}.id`, strictIdentifiers),
|
||||||
vendor: text(item.vendor, `${path}.vendor`),
|
vendor: text(item.vendor, `${path}.vendor`),
|
||||||
displayName: text(item.displayName, `${path}.displayName`),
|
displayName: text(item.displayName, `${path}.displayName`),
|
||||||
category: text(item.category, `${path}.category`),
|
category: text(item.category, `${path}.category`),
|
||||||
description: text(item.description, `${path}.description`, 1024),
|
description: text(item.description, `${path}.description`, 1024),
|
||||||
verified: flag(item.verified, `${path}.verified`),
|
verified: flag(item.verified, `${path}.verified`),
|
||||||
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
|
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
|
||||||
capability(entry, `${path}.capabilities[${index}]`),
|
capability(entry, `${path}.capabilities[${index}]`, strictIdentifiers),
|
||||||
),
|
),
|
||||||
ui: {
|
ui: {
|
||||||
slot,
|
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 {
|
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
|
||||||
const root = record(document, "root");
|
const root = record(document, "root");
|
||||||
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
|
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.");
|
throw new Error("Manifest использует несовместимую версию или kind.");
|
||||||
}
|
}
|
||||||
const metadata = record(root.metadata, "metadata");
|
const metadata = record(root.metadata, "metadata");
|
||||||
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
|
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
|
||||||
const spec = record(root.spec, "spec");
|
const spec = record(root.spec, "spec");
|
||||||
exactKeys(spec, "spec", [
|
const specKeys = [
|
||||||
"hostApiRange",
|
"hostApiRange",
|
||||||
"runtime",
|
"runtime",
|
||||||
"permissions",
|
"permissions",
|
||||||
"actions",
|
"actions",
|
||||||
"models",
|
"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)}.`);
|
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
|
||||||
}
|
}
|
||||||
const runtime = record(spec.runtime, "spec.runtime");
|
const runtime = record(spec.runtime, "spec.runtime");
|
||||||
|
|
@ -129,45 +233,72 @@ export function parseDevicePluginManifest(document: unknown): DevicePluginManife
|
||||||
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
|
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const strictIdentifiers = apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
|
||||||
const models = list(spec.models, "spec.models").map((entry, index) =>
|
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 должен объявлять ровно одну модель.");
|
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");
|
const version = text(metadata.version, "metadata.version");
|
||||||
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
|
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
|
||||||
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
|
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const common = {
|
||||||
apiVersion: DEVICE_PLUGIN_API_VERSION,
|
|
||||||
kind: "DevicePlugin",
|
kind: "DevicePlugin",
|
||||||
metadata: {
|
metadata: {
|
||||||
id: text(metadata.id, "metadata.id"),
|
id: contractText(metadata.id, "metadata.id", strictIdentifiers),
|
||||||
version,
|
version,
|
||||||
displayName: text(metadata.displayName, "metadata.displayName"),
|
displayName: text(metadata.displayName, "metadata.displayName"),
|
||||||
},
|
},
|
||||||
spec: {
|
} as const;
|
||||||
hostApiRange: "v1alpha1",
|
const commonSpec = {
|
||||||
runtime: {
|
runtime: {
|
||||||
backendEntrypoint: text(
|
backendEntrypoint: text(
|
||||||
runtime.backendEntrypoint,
|
runtime.backendEntrypoint,
|
||||||
"spec.runtime.backendEntrypoint",
|
"spec.runtime.backendEntrypoint",
|
||||||
256,
|
256,
|
||||||
),
|
|
||||||
isolation,
|
|
||||||
},
|
|
||||||
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
|
|
||||||
text(entry, `spec.permissions[${index}]`),
|
|
||||||
),
|
),
|
||||||
actions: list(spec.actions, "spec.actions").map((entry, index) =>
|
isolation,
|
||||||
action(entry, `spec.actions[${index}]`),
|
|
||||||
),
|
|
||||||
models,
|
|
||||||
},
|
},
|
||||||
};
|
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
|
||||||
|
contractText(entry, `spec.permissions[${index}]`, strictIdentifiers),
|
||||||
|
),
|
||||||
|
actions: list(spec.actions, "spec.actions").map((entry, index) =>
|
||||||
|
action(entry, `spec.actions[${index}]`, strictIdentifiers),
|
||||||
|
),
|
||||||
|
models,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1) {
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
apiVersion,
|
||||||
|
spec: {
|
||||||
|
...commonSpec,
|
||||||
|
hostApiRange: "v1alpha1",
|
||||||
|
},
|
||||||
|
} satisfies DevicePluginManifestV1Alpha1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = list(spec.compatibilityProfiles, "spec.compatibilityProfiles").map(
|
||||||
|
(entry, index) => compatibilityProfile(entry, `spec.compatibilityProfiles[${index}]`),
|
||||||
|
);
|
||||||
|
validateV1Alpha2Profiles(models, profiles);
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
apiVersion,
|
||||||
|
spec: {
|
||||||
|
...commonSpec,
|
||||||
|
hostApiRange: "v1alpha2",
|
||||||
|
compatibilityProfiles: profiles,
|
||||||
|
},
|
||||||
|
} satisfies DevicePluginManifestV1Alpha2;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requirePluginAction(
|
export function requirePluginAction(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import {
|
import {
|
||||||
DEVICE_PLUGIN_API_VERSION,
|
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
|
||||||
DEVICE_STATE_READ_ACTION_ID,
|
DEVICE_STATE_READ_ACTION_ID,
|
||||||
|
SUPPORTED_DEVICE_PLUGIN_API_VERSIONS,
|
||||||
|
isDevicePluginManifestV1Alpha2,
|
||||||
type DeviceUiPlugin,
|
type DeviceUiPlugin,
|
||||||
type RegisteredDeviceModel,
|
type RegisteredDeviceModel,
|
||||||
} from "./contracts";
|
} from "./contracts";
|
||||||
|
|
@ -20,7 +22,7 @@ export function createDevicePluginRegistry(
|
||||||
|
|
||||||
for (const plugin of installedPlugins) {
|
for (const plugin of installedPlugins) {
|
||||||
const { manifest } = plugin;
|
const { manifest } = plugin;
|
||||||
if (manifest.apiVersion !== DEVICE_PLUGIN_API_VERSION) {
|
if (!SUPPORTED_DEVICE_PLUGIN_API_VERSIONS.includes(manifest.apiVersion)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
|
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
|
||||||
);
|
);
|
||||||
|
|
@ -35,11 +37,17 @@ export function createDevicePluginRegistry(
|
||||||
}
|
}
|
||||||
pluginIds.add(manifest.metadata.id);
|
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(
|
throw new Error(
|
||||||
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
|
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!manifest.spec.models.length) {
|
||||||
|
throw new Error(`Плагин ${manifest.metadata.id} не объявляет ни одной модели.`);
|
||||||
|
}
|
||||||
|
|
||||||
const permissions = new Set(manifest.spec.permissions);
|
const permissions = new Set(manifest.spec.permissions);
|
||||||
if (permissions.size !== manifest.spec.permissions.length) {
|
if (permissions.size !== manifest.spec.permissions.length) {
|
||||||
|
|
@ -84,6 +92,37 @@ export function createDevicePluginRegistry(
|
||||||
}
|
}
|
||||||
models.push({ plugin, model, ConnectionView });
|
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]));
|
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,32 @@ export interface ActiveDeviceSnapshot {
|
||||||
endpointLabel?: string | null;
|
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 {
|
export interface SpatialSourceDescriptor {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -50,6 +76,9 @@ export interface MissionRuntimeState {
|
||||||
phase: RuntimePhase;
|
phase: RuntimePhase;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
activeDevice?: ActiveDeviceSnapshot | null;
|
activeDevice?: ActiveDeviceSnapshot | null;
|
||||||
|
deviceSession?: RuntimeDeviceSessionSnapshot | null;
|
||||||
|
acquisition?: RuntimeAcquisitionSnapshot | null;
|
||||||
|
operations?: readonly RuntimeOperationSnapshot[];
|
||||||
spatialSource?: SpatialSourceDescriptor | null;
|
spatialSource?: SpatialSourceDescriptor | null;
|
||||||
viewerSettings?: ViewerSettings | null;
|
viewerSettings?: ViewerSettings | null;
|
||||||
sourceMode: SourceMode;
|
sourceMode: SourceMode;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checker,
|
Checker,
|
||||||
|
|
@ -12,7 +12,14 @@ import {
|
||||||
|
|
||||||
import type { DevicePluginConnectionProps } from "../../core/device-plugins/contracts";
|
import type { DevicePluginConnectionProps } from "../../core/device-plugins/contracts";
|
||||||
import { MetricCard } from "../../components/MetricCard";
|
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 { localizeRuntimeMessage } from "./messages";
|
||||||
import {
|
import {
|
||||||
backendLabel,
|
backendLabel,
|
||||||
|
|
@ -23,7 +30,6 @@ import {
|
||||||
phaseLabel,
|
phaseLabel,
|
||||||
phaseTone,
|
phaseTone,
|
||||||
pipelineLatency,
|
pipelineLatency,
|
||||||
sourceModeLabel,
|
|
||||||
} from "./presentation";
|
} from "./presentation";
|
||||||
import { useXgridsK1Controller } from "./runtimeContext";
|
import { useXgridsK1Controller } from "./runtimeContext";
|
||||||
|
|
||||||
|
|
@ -34,6 +40,12 @@ const sessionItems = [
|
||||||
{ value: "replay", label: "Повтор записи" },
|
{ value: "replay", label: "Повтор записи" },
|
||||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
] 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 }) {
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
|
|
@ -92,7 +104,7 @@ function DeviceRow({
|
||||||
<div>
|
<div>
|
||||||
<span className="device-row__name">
|
<span className="device-row__name">
|
||||||
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
|
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
|
||||||
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
|
{device.likely_k1 ? <small>Кандидат по имени; профиль не подтверждён</small> : null}
|
||||||
</span>
|
</span>
|
||||||
<code>{device.device_id}</code>
|
<code>{device.device_id}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -145,23 +157,30 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
clearError,
|
clearError,
|
||||||
scan,
|
scan,
|
||||||
connect,
|
connect,
|
||||||
startLive,
|
prepareAndStartAcquisition,
|
||||||
startReplay,
|
startReplay,
|
||||||
stop,
|
stop,
|
||||||
|
abort,
|
||||||
} = console;
|
} = console;
|
||||||
|
|
||||||
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
const [powerConfirmed, setPowerConfirmed] = useState(false);
|
||||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||||
const [ssid, setSsid] = useState("");
|
const [ssid, setSsid] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [profileConfirmed, setProfileConfirmed] = useState(false);
|
||||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||||
const [liveHost, setLiveHost] = useState("");
|
const [liveHost, setLiveHost] = useState("");
|
||||||
const [replayPath, setReplayPath] = useState("");
|
const [replayPath, setReplayPath] = useState("");
|
||||||
const [replaySpeed, setReplaySpeed] = useState("1");
|
const [replaySpeed, setReplaySpeed] = useState("1");
|
||||||
const [replayLoop, setReplayLoop] = useState(false);
|
const [replayLoop, setReplayLoop] = useState(false);
|
||||||
|
const provisioningIntentRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state?.selected_device_id) {
|
if (state?.selected_device_id) {
|
||||||
|
if (state.selected_device_id !== selectedDeviceId) {
|
||||||
|
setProfileConfirmed(false);
|
||||||
|
provisioningIntentRef.current = null;
|
||||||
|
}
|
||||||
setSelectedDeviceId(state.selected_device_id);
|
setSelectedDeviceId(state.selected_device_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +193,16 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
}
|
}
|
||||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
}, [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 metrics = streamActive ? state?.metrics : undefined;
|
||||||
const latency = pipelineLatency(metrics);
|
const latency = pipelineLatency(metrics);
|
||||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
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 devices = state?.devices ?? [];
|
||||||
const isBusy = pendingAction !== null;
|
const isBusy = pendingAction !== null;
|
||||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
const canConnect =
|
||||||
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
|
powerConfirmed &&
|
||||||
const sourceLabel = sourceModeLabel(state?.source_mode);
|
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(
|
const deviceSummary = useMemo(
|
||||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||||
|
|
@ -194,17 +257,28 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
|
|
||||||
const submitConnect = async () => {
|
const submitConnect = async () => {
|
||||||
if (!canConnect) return;
|
if (!canConnect) return;
|
||||||
|
const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
|
||||||
|
provisioningIntentRef.current = idempotencyKey;
|
||||||
const succeeded = await connect({
|
const succeeded = await connect({
|
||||||
device_id: selectedDeviceId,
|
device_id: selectedDeviceId,
|
||||||
ssid: ssid.trim(),
|
ssid: ssid.trim(),
|
||||||
password,
|
password,
|
||||||
|
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
});
|
});
|
||||||
if (succeeded) setPassword("");
|
if (succeeded) {
|
||||||
|
provisioningIntentRef.current = null;
|
||||||
|
setPassword("");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitLive = async () => {
|
const submitLive = async () => {
|
||||||
|
if (!profileConfirmed || sourceRuntimeBusy) return;
|
||||||
const targetHost = liveHost.trim();
|
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();
|
if (started) host.openSpatialScene();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -229,7 +303,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
</div>
|
</div>
|
||||||
<div className="error-banner__actions">
|
<div className="error-banner__actions">
|
||||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
|
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
|
||||||
Повторить
|
Обновить состояние
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="compact" variant="ghost" onClick={clearError}>
|
<Button size="compact" variant="ghost" onClick={clearError}>
|
||||||
Закрыть
|
Закрыть
|
||||||
|
|
@ -248,7 +322,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-lead__status">
|
<div className="workspace-lead__status">
|
||||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||||
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -286,7 +360,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
|
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
|
||||||
<h2>Подключите устройство к сети</h2>
|
<h2>Подключите устройство к сети</h2>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="wizard-list">
|
<div className="wizard-list">
|
||||||
|
|
@ -303,7 +377,13 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
<Checker
|
<Checker
|
||||||
checked={powerConfirmed}
|
checked={powerConfirmed}
|
||||||
label="Устройство включено, индикатор стабилен"
|
label="Устройство включено, индикатор стабилен"
|
||||||
onChange={setPowerConfirmed}
|
onChange={(checked) => {
|
||||||
|
setPowerConfirmed(checked);
|
||||||
|
if (!checked) {
|
||||||
|
setProfileConfirmed(false);
|
||||||
|
provisioningIntentRef.current = null;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</WizardStep>
|
</WizardStep>
|
||||||
|
|
@ -327,8 +407,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="step-copy">
|
<p className="step-copy">
|
||||||
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
|
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата
|
||||||
профиль — только подсказка; окончательный выбор всегда делает оператор.
|
основана только на имени и не подтверждает модель или прошивку; окончательный выбор
|
||||||
|
и аттестацию всегда делает оператор.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
width="full"
|
width="full"
|
||||||
|
|
@ -337,6 +418,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
disabled={!powerConfirmed || isBusy}
|
disabled={!powerConfirmed || isBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedDeviceId("");
|
setSelectedDeviceId("");
|
||||||
|
setProfileConfirmed(false);
|
||||||
|
provisioningIntentRef.current = null;
|
||||||
void scan();
|
void scan();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -351,7 +434,11 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
key={device.device_id}
|
key={device.device_id}
|
||||||
device={device}
|
device={device}
|
||||||
selected={device.device_id === selectedDeviceId}
|
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"}
|
tone={state?.k1_ip ? "success" : "neutral"}
|
||||||
>
|
>
|
||||||
<div className="field-stack">
|
<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
|
<TextField
|
||||||
label="Название сети Wi‑Fi"
|
label="Название сети Wi‑Fi"
|
||||||
hint="SSID"
|
hint="SSID"
|
||||||
value={ssid}
|
value={ssid}
|
||||||
onChange={(event) => setSsid(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setSsid(event.target.value);
|
||||||
|
provisioningIntentRef.current = null;
|
||||||
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
placeholder="Сеть локального контура"
|
placeholder="Сеть локального контура"
|
||||||
|
|
@ -384,7 +488,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
hint="Только в оперативной памяти"
|
hint="Только в оперативной памяти"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setPassword(event.target.value);
|
||||||
|
provisioningIntentRef.current = null;
|
||||||
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="Введите пароль"
|
placeholder="Введите пароль"
|
||||||
/>
|
/>
|
||||||
|
|
@ -415,23 +522,25 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
<header className="panel-heading">
|
<header className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">
|
<span className="section-eyebrow">
|
||||||
{sessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
{effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
||||||
</span>
|
</span>
|
||||||
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
|
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
|
<StatusBadge tone={sourceTone}>
|
||||||
{sourceLabel}
|
{sourceLabel}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
label="Источник данных"
|
label="Источник данных"
|
||||||
value={sessionIntent}
|
value={effectiveSessionIntent}
|
||||||
items={sessionItems}
|
items={selectableSessionItems}
|
||||||
onChange={setSessionIntent}
|
onChange={(intent) => {
|
||||||
|
if (!sessionLocked) setSessionIntent(intent);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{sessionIntent === "live" ? (
|
{effectiveSessionIntent === "live" ? (
|
||||||
<div className="session-form">
|
<div className="session-form">
|
||||||
<TextField
|
<TextField
|
||||||
label="Адрес устройства"
|
label="Адрес устройства"
|
||||||
|
|
@ -439,20 +548,36 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
value={liveHost}
|
value={liveHost}
|
||||||
onChange={(event) => setLiveHost(event.target.value)}
|
onChange={(event) => setLiveHost(event.target.value)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
|
placeholder={
|
||||||
|
state?.k1_ip ||
|
||||||
|
preparedAcquisition?.target_host ||
|
||||||
|
"Сначала подключите устройство к Wi‑Fi"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon={<Icon name="activity" />}
|
icon={<Icon name="activity" />}
|
||||||
disabled={isBusy || !liveTargetReady}
|
disabled={
|
||||||
|
isBusy ||
|
||||||
|
!profileConfirmed ||
|
||||||
|
!liveTargetReady ||
|
||||||
|
sourceRuntimeBusy ||
|
||||||
|
(activeAcquisition !== null && preparedAcquisition === null)
|
||||||
|
}
|
||||||
onClick={() => void submitLive()}
|
onClick={() => void submitLive()}
|
||||||
>
|
>
|
||||||
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
{pendingAction === "live"
|
||||||
|
? "Подготавливаем приём…"
|
||||||
|
: preparedAcquisition
|
||||||
|
? "Продолжить подготовленный приём"
|
||||||
|
: "Подготовить приём данных"}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="live-instruction">
|
<p className="live-instruction">
|
||||||
{liveTargetReady
|
{!profileConfirmed
|
||||||
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
|
? "Сначала вручную подтвердите точную прошивку 3.0.2 и direct-LAN топологию. Интерфейс не аттестует устройство автоматически."
|
||||||
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
: liveTargetReady
|
||||||
|
? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
|
||||||
|
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -485,7 +610,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
icon={<Icon name="video" />}
|
icon={<Icon name="video" />}
|
||||||
disabled={isBusy || replayPath.trim().length === 0}
|
disabled={isBusy || sessionLocked || replayPath.trim().length === 0}
|
||||||
onClick={() => void submitReplay()}
|
onClick={() => void submitReplay()}
|
||||||
>
|
>
|
||||||
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
||||||
|
|
@ -494,14 +619,41 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="session-footer">
|
<div className="session-footer">
|
||||||
<p>Статус изменится только после ответа локального сервиса.</p>
|
<p>
|
||||||
|
{state?.source_mode === "replay"
|
||||||
|
? "Остановка завершит фактически запущенный повтор записи."
|
||||||
|
: activeAcquisition || state?.source_mode === "live"
|
||||||
|
? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||||
|
: "Активного источника сейчас нет."}
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
|
disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)}
|
||||||
onClick={() => void stop()}
|
onClick={() => void stop()}
|
||||||
>
|
>
|
||||||
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
|
{pendingAction === "stop"
|
||||||
|
? state?.source_mode === "replay"
|
||||||
|
? "Останавливаем повтор…"
|
||||||
|
: "Останавливаем локальный приём…"
|
||||||
|
: state?.source_mode === "replay"
|
||||||
|
? "Остановить повтор"
|
||||||
|
: preparedAcquisition
|
||||||
|
? "Завершить подготовленный приём"
|
||||||
|
: "Остановить локальный приём"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{activeAcquisition ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void abort()}
|
||||||
|
>
|
||||||
|
{pendingAction === "abort"
|
||||||
|
? "Прерываем локальную операцию…"
|
||||||
|
: preparedAcquisition
|
||||||
|
? "Отменить подготовку"
|
||||||
|
: "Аварийно завершить локальный приём"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,101 @@ export interface BleDevice {
|
||||||
|
|
||||||
export type SourceMode = "idle" | "live" | "replay";
|
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 {
|
export interface XgridsK1Metrics {
|
||||||
mqtt_to_decode_ms?: number | null;
|
mqtt_to_decode_ms?: number | null;
|
||||||
decode_ms?: number | null;
|
decode_ms?: number | null;
|
||||||
|
|
@ -28,6 +123,7 @@ export interface XgridsK1Metrics {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface XgridsK1State {
|
export interface XgridsK1State {
|
||||||
|
contract_version?: string | null;
|
||||||
phase?: string | null;
|
phase?: string | null;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
devices?: BleDevice[];
|
devices?: BleDevice[];
|
||||||
|
|
@ -39,6 +135,12 @@ export interface XgridsK1State {
|
||||||
viewer_settings?: ViewerSettings | null;
|
viewer_settings?: ViewerSettings | null;
|
||||||
source_mode?: SourceMode | null;
|
source_mode?: SourceMode | null;
|
||||||
metrics?: XgridsK1Metrics;
|
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 {
|
export interface HealthResponse {
|
||||||
|
|
@ -52,15 +154,66 @@ export interface ScanRequest {
|
||||||
duration_seconds?: number;
|
duration_seconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompatibilityAttestation {
|
||||||
|
firmware_version: "3.0.2";
|
||||||
|
topology: "direct-lan";
|
||||||
|
operator_confirmed: true;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConnectRequest {
|
export interface ConnectRequest {
|
||||||
device_id: string;
|
device_id: string;
|
||||||
ssid: string;
|
ssid: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
compatibility_attestation: CompatibilityAttestation;
|
||||||
|
operation_id?: string;
|
||||||
|
idempotency_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveRequest {
|
export interface PrepareAcquisitionRequest {
|
||||||
host?: string;
|
host?: string;
|
||||||
duration_seconds?: number;
|
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 {
|
export interface ReplayRequest {
|
||||||
|
|
@ -177,16 +330,32 @@ export const xgridsK1Api = {
|
||||||
return invokeState(xgridsK1Actions.networkProvision, body);
|
return invokeState(xgridsK1Actions.networkProvision, body);
|
||||||
},
|
},
|
||||||
|
|
||||||
startLive(body: LiveRequest = {}): Promise<XgridsK1State> {
|
prepareAcquisition(body: PrepareAcquisitionRequest): Promise<XgridsK1State> {
|
||||||
return invokeState(xgridsK1Actions.streamStartLive, body);
|
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> {
|
startReplay(body: ReplayRequest): Promise<XgridsK1State> {
|
||||||
return invokeState(xgridsK1Actions.streamStartReplay, body);
|
return invokeState(xgridsK1Actions.streamStartReplay, body);
|
||||||
},
|
},
|
||||||
|
|
||||||
stopSession(): Promise<XgridsK1State> {
|
startLiveCompatibility(body: CompatibilityLiveRequest): Promise<XgridsK1State> {
|
||||||
return invokeState(xgridsK1Actions.streamStop);
|
return invokeState(xgridsK1Actions.compatibilityStreamStartLive, body);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopSessionCompatibility(): Promise<XgridsK1State> {
|
||||||
|
return invokeState(xgridsK1Actions.compatibilityStreamStop);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
|
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {
|
||||||
|
|
|
||||||
|
|
@ -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()}`;
|
||||||
|
}
|
||||||
|
|
@ -11,9 +11,21 @@ export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument);
|
||||||
export const xgridsK1Actions = Object.freeze({
|
export const xgridsK1Actions = Object.freeze({
|
||||||
stateRead: requirePluginAction(xgridsK1Manifest, DEVICE_STATE_READ_ACTION_ID),
|
stateRead: requirePluginAction(xgridsK1Manifest, DEVICE_STATE_READ_ACTION_ID),
|
||||||
discoveryScan: requirePluginAction(xgridsK1Manifest, "discovery.scan"),
|
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"),
|
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"),
|
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
|
||||||
streamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
|
compatibilityStreamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
|
||||||
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
|
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,13 @@ import {
|
||||||
import type {
|
import type {
|
||||||
MissionRuntimeController,
|
MissionRuntimeController,
|
||||||
MissionRuntimeState,
|
MissionRuntimeState,
|
||||||
RuntimePhase,
|
|
||||||
} from "../../core/runtime/contracts";
|
} from "../../core/runtime/contracts";
|
||||||
|
import {
|
||||||
|
confirmedRuntimeSourceMode,
|
||||||
|
effectiveAcquisition,
|
||||||
|
normalizeRuntimePhase,
|
||||||
|
spatialSourceId,
|
||||||
|
} from "./lifecycle";
|
||||||
import { localizeRuntimeMessage } from "./messages";
|
import { localizeRuntimeMessage } from "./messages";
|
||||||
import { xgridsK1Manifest } from "./manifest";
|
import { xgridsK1Manifest } from "./manifest";
|
||||||
import { finiteMetric, pipelineLatency } from "./presentation";
|
import { finiteMetric, pipelineLatency } from "./presentation";
|
||||||
|
|
@ -19,19 +24,6 @@ export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
|
||||||
|
|
||||||
const XgridsK1RuntimeContext = createContext<XgridsK1Controller | null>(null);
|
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(
|
function normalizeState(
|
||||||
controller: XgridsK1Controller,
|
controller: XgridsK1Controller,
|
||||||
activeModel: DeviceModelDefinition,
|
activeModel: DeviceModelDefinition,
|
||||||
|
|
@ -39,31 +31,67 @@ function normalizeState(
|
||||||
const state = controller.state;
|
const state = controller.state;
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
const metrics = state.metrics;
|
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 sourceUrl = state.rerun_grpc_url?.trim() ?? "";
|
||||||
|
const resolvedSpatialSourceId =
|
||||||
|
state.source_mode === "replay"
|
||||||
|
? spatialSourceId(state, sourceUrl)
|
||||||
|
: acquisition?.acquisition_id ?? spatialSourceId(state, sourceUrl);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phase: normalizePhase(state.phase),
|
phase: normalizeRuntimePhase(state),
|
||||||
message: localizeRuntimeMessage(state.message),
|
message: localizeRuntimeMessage(state.message),
|
||||||
activeDevice: hasDevice
|
activeDevice: deviceRef
|
||||||
? {
|
? {
|
||||||
pluginId: xgridsK1Manifest.metadata.id,
|
pluginId: xgridsK1Manifest.metadata.id,
|
||||||
modelId: activeModel.id,
|
modelId: deviceRef.model_id || activeModel.id,
|
||||||
displayName: activeModel.displayName,
|
displayName: activeModel.displayName,
|
||||||
instanceId: state.selected_device_id,
|
instanceId: deviceRef.device_id,
|
||||||
endpointLabel: state.k1_ip,
|
endpointLabel: state.k1_ip,
|
||||||
}
|
}
|
||||||
: null,
|
: 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,
|
url: sourceUrl,
|
||||||
label: "Локальный пространственный поток",
|
label:
|
||||||
|
state.source_mode === "replay"
|
||||||
|
? "Повтор пространственной записи"
|
||||||
|
: "Локальный пространственный поток",
|
||||||
kind: "rerun-grpc",
|
kind: "rerun-grpc",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
viewerSettings: state.viewer_settings,
|
viewerSettings: state.viewer_settings,
|
||||||
sourceMode: state.source_mode ?? "idle",
|
sourceMode: confirmedRuntimeSourceMode(state),
|
||||||
metrics: {
|
metrics: {
|
||||||
latencyMs: pipelineLatency(metrics),
|
latencyMs: pipelineLatency(metrics),
|
||||||
frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz),
|
frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz),
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,27 @@ import {
|
||||||
xgridsK1Api,
|
xgridsK1Api,
|
||||||
openEventSocket,
|
openEventSocket,
|
||||||
type ConnectRequest,
|
type ConnectRequest,
|
||||||
type XgridsK1State,
|
|
||||||
type EventSocketStatus,
|
type EventSocketStatus,
|
||||||
type LiveRequest,
|
type PrepareAcquisitionRequest,
|
||||||
type ReplayRequest,
|
type ReplayRequest,
|
||||||
|
type XgridsK1State,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
import {
|
||||||
|
isTerminalAcquisitionState,
|
||||||
|
liveStartPlan,
|
||||||
|
operationByIdempotencyKey,
|
||||||
|
operationNeedsReconciliation,
|
||||||
|
} from "./lifecycle";
|
||||||
import { localizeRuntimeMessage } from "./messages";
|
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 {
|
function messageFor(error: unknown): string {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
|
|
@ -114,13 +127,64 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const connect = useCallback(
|
const connect = useCallback(
|
||||||
(request: ConnectRequest) => run("connect", () => xgridsK1Api.connect(request)),
|
(request: ConnectRequest) =>
|
||||||
[run],
|
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(
|
const prepareAndStartAcquisition = useCallback(
|
||||||
(request: LiveRequest = {}) => run("live", () => xgridsK1Api.startLive(request)),
|
(request: PrepareAcquisitionRequest) =>
|
||||||
[run],
|
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(
|
const startReplay = useCallback(
|
||||||
|
|
@ -129,10 +193,32 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(
|
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(
|
const updateViewerSettings = useCallback(
|
||||||
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
|
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
|
||||||
[run],
|
[run],
|
||||||
|
|
@ -210,9 +296,10 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||||
clearError: () => setError(null),
|
clearError: () => setError(null),
|
||||||
scan,
|
scan,
|
||||||
connect,
|
connect,
|
||||||
startLive,
|
prepareAndStartAcquisition,
|
||||||
startReplay,
|
startReplay,
|
||||||
stop,
|
stop,
|
||||||
|
abort,
|
||||||
updateViewerSettings,
|
updateViewerSettings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
{
|
{
|
||||||
"apiVersion": "missioncore.nodedc/v1alpha1",
|
"apiVersion": "missioncore.nodedc/v1alpha2",
|
||||||
"kind": "DevicePlugin",
|
"kind": "DevicePlugin",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"id": "nodedc.device.xgrids-lixelkity-k1",
|
"id": "nodedc.device.xgrids-lixelkity-k1",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"displayName": "XGRIDS K1 Integration"
|
"displayName": "XGRIDS K1 Integration"
|
||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
"hostApiRange": "v1alpha1",
|
"hostApiRange": "v1alpha2",
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"backendEntrypoint": "k1link.web.xgrids_k1_facade:build_xgrids_k1_plugin",
|
"backendEntrypoint": "k1link.web.xgrids_k1_facade:build_xgrids_k1_plugin",
|
||||||
"isolation": "transitional-in-process"
|
"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": [
|
"permissions": [
|
||||||
"device.discovery.ble",
|
"device.discovery.ble",
|
||||||
"device.provisioning.wifi-over-ble",
|
"device.provisioning.wifi-over-ble",
|
||||||
|
|
@ -21,7 +28,16 @@
|
||||||
"actions": [
|
"actions": [
|
||||||
{ "id": "state.read", "mutating": false, "secretFields": [] },
|
{ "id": "state.read", "mutating": false, "secretFields": [] },
|
||||||
{ "id": "discovery.scan", "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": "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-live", "mutating": true, "secretFields": [] },
|
||||||
{ "id": "stream.start-replay", "mutating": true, "secretFields": [] },
|
{ "id": "stream.start-replay", "mutating": true, "secretFields": [] },
|
||||||
{ "id": "stream.stop", "mutating": true, "secretFields": [] },
|
{ "id": "stream.stop", "mutating": true, "secretFields": [] },
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -234,8 +234,7 @@ def summarize_mqtt_streams(
|
||||||
"""Stream a raw MQTT capture into an aggregate-only, coordinate-free summary."""
|
"""Stream a raw MQTT capture into an aggregate-only, coordinate-free summary."""
|
||||||
if not 1 <= max_payload_bytes <= MAX_STREAM_SUMMARY_PAYLOAD_BYTES:
|
if not 1 <= max_payload_bytes <= MAX_STREAM_SUMMARY_PAYLOAD_BYTES:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"max_payload_bytes must be between 1 and "
|
f"max_payload_bytes must be between 1 and {MAX_STREAM_SUMMARY_PAYLOAD_BYTES}"
|
||||||
f"{MAX_STREAM_SUMMARY_PAYLOAD_BYTES}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
capture_path = capture.expanduser()
|
capture_path = capture.expanduser()
|
||||||
|
|
|
||||||
|
|
@ -179,9 +179,7 @@ async def provision_wifi_once(
|
||||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||||
device_name = client.name
|
device_name = client.name
|
||||||
service = client.services.get_service(SERVICE_UUID)
|
service = client.services.get_service(SERVICE_UUID)
|
||||||
write_characteristic = client.services.get_characteristic(
|
write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID)
|
||||||
WRITE_CHARACTERISTIC_UUID
|
|
||||||
)
|
|
||||||
status_characteristic = client.services.get_characteristic(
|
status_characteristic = client.services.get_characteristic(
|
||||||
STATUS_CHARACTERISTIC_UUID
|
STATUS_CHARACTERISTIC_UUID
|
||||||
)
|
)
|
||||||
|
|
@ -189,13 +187,11 @@ async def provision_wifi_once(
|
||||||
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
|
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
|
||||||
if write_characteristic is None:
|
if write_characteristic is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Reviewed K1 write characteristic not found: "
|
f"Reviewed K1 write characteristic not found: {WRITE_CHARACTERISTIC_UUID}"
|
||||||
f"{WRITE_CHARACTERISTIC_UUID}"
|
|
||||||
)
|
)
|
||||||
if status_characteristic is None:
|
if status_characteristic is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Reviewed K1 status characteristic not found: "
|
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
|
||||||
f"{STATUS_CHARACTERISTIC_UUID}"
|
|
||||||
)
|
)
|
||||||
if write_characteristic.service_uuid != service.uuid:
|
if write_characteristic.service_uuid != service.uuid:
|
||||||
raise ValueError("K1 write characteristic is attached to an unexpected service")
|
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")
|
raise ValueError("Reviewed K1 status characteristic is not readable")
|
||||||
|
|
||||||
properties = set(write_characteristic.properties)
|
properties = set(write_characteristic.properties)
|
||||||
max_without_response = (
|
max_without_response = write_characteristic.max_write_without_response_size
|
||||||
write_characteristic.max_write_without_response_size
|
|
||||||
)
|
|
||||||
resolved_write_mode: ResolvedWriteMode
|
resolved_write_mode: ResolvedWriteMode
|
||||||
if write_mode == "auto":
|
if write_mode == "auto":
|
||||||
if "write-without-response" in properties:
|
if "write-without-response" in properties:
|
||||||
|
|
@ -276,9 +270,7 @@ async def provision_wifi_once(
|
||||||
"status_characteristic_uuid": status_characteristic.uuid,
|
"status_characteristic_uuid": status_characteristic.uuid,
|
||||||
"operation": "single_reviewed_wifi_provisioning_write",
|
"operation": "single_reviewed_wifi_provisioning_write",
|
||||||
"write_mode": resolved_write_mode,
|
"write_mode": resolved_write_mode,
|
||||||
"write_without_response_advertised": (
|
"write_without_response_advertised": ("write-without-response" in properties),
|
||||||
"write-without-response" in properties
|
|
||||||
),
|
|
||||||
"max_write_without_response_size": max_without_response,
|
"max_write_without_response_size": max_without_response,
|
||||||
"frame_length": len(frame),
|
"frame_length": len(frame),
|
||||||
"baseline_status": baseline,
|
"baseline_status": baseline,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -130,7 +130,7 @@ class CaptureFrame:
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class CapturedMqttMessage:
|
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
|
sequence: int
|
||||||
topic: str
|
topic: str
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
|
||||||
import statistics
|
|
||||||
import struct
|
import struct
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
import foxglove
|
import foxglove
|
||||||
from foxglove import Channel
|
from foxglove import Channel
|
||||||
|
|
@ -45,6 +41,7 @@ from k1link.protocol.streams import (
|
||||||
decode_lio_pose,
|
decode_lio_pose,
|
||||||
)
|
)
|
||||||
from k1link.viewer.messages import StreamMessage
|
from k1link.viewer.messages import StreamMessage
|
||||||
|
from k1link.viewer.metrics import BridgeMetrics
|
||||||
|
|
||||||
POINT_STRUCT = struct.Struct("<fffB3x")
|
POINT_STRUCT = struct.Struct("<fffB3x")
|
||||||
POINT_STRIDE = POINT_STRUCT.size
|
POINT_STRIDE = POINT_STRUCT.size
|
||||||
|
|
@ -52,113 +49,12 @@ FRAME_ID = "map"
|
||||||
MAX_TRAJECTORY_POSES = 20_000
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PackedPointCloud:
|
class PackedPointCloud:
|
||||||
data: bytes
|
data: bytes
|
||||||
point_count: int
|
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:
|
class FoxgloveBridge:
|
||||||
"""Decode verified K1 topics and publish Foxglove-native visualization messages."""
|
"""Decode verified K1 topics and publish Foxglove-native visualization messages."""
|
||||||
|
|
||||||
|
|
@ -390,28 +286,3 @@ def _identity_pose() -> Pose:
|
||||||
|
|
||||||
def _timestamp(epoch_ns: int) -> Timestamp:
|
def _timestamp(epoch_ns: int) -> Timestamp:
|
||||||
return Timestamp(epoch_ns // 1_000_000_000, epoch_ns % 1_000_000_000)
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -11,19 +11,12 @@ import numpy as np
|
||||||
import rerun as rr
|
import rerun as rr
|
||||||
from rerun import blueprint as rrb
|
from rerun import blueprint as rrb
|
||||||
|
|
||||||
from k1link.protocol.streams import (
|
from k1link.data_plane import (
|
||||||
LegacyPointCloudFrame,
|
DecodedDataPlaneView,
|
||||||
LegacyPoseFrame,
|
DecodedPointCloudView,
|
||||||
LioPointCloudFrame,
|
DecodedPoseView,
|
||||||
LioPoseFrame,
|
|
||||||
StreamDecodeError,
|
|
||||||
decode_legacy_pointcloud,
|
|
||||||
decode_legacy_pose,
|
|
||||||
decode_lio_pcl,
|
|
||||||
decode_lio_pose,
|
|
||||||
)
|
)
|
||||||
from k1link.viewer.foxglove_bridge import BridgeMetrics
|
from k1link.viewer.metrics import BridgeMetrics
|
||||||
from k1link.viewer.messages import StreamMessage
|
|
||||||
|
|
||||||
PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"]
|
PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"]
|
||||||
PointPalette = Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
|
PointPalette = Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
|
||||||
|
|
@ -59,7 +52,7 @@ SettingsProvider = Callable[[], RerunSceneSettings]
|
||||||
|
|
||||||
|
|
||||||
class RerunBridge:
|
class RerunBridge:
|
||||||
"""Decode verified device topics into a self-hosted Rerun recording stream."""
|
"""Publish transport-neutral canonical envelopes to a Rerun recording."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -73,9 +66,7 @@ class RerunBridge:
|
||||||
self.metrics = metrics or BridgeMetrics()
|
self.metrics = metrics or BridgeMetrics()
|
||||||
self._settings_provider = settings_provider or RerunSceneSettings
|
self._settings_provider = settings_provider or RerunSceneSettings
|
||||||
self._settings = self._settings_provider()
|
self._settings = self._settings_provider()
|
||||||
self._recording = (recording_factory or rr.RecordingStream)(
|
self._recording = (recording_factory or rr.RecordingStream)("nodedc_mission_core_spatial")
|
||||||
"nodedc_mission_core_spatial"
|
|
||||||
)
|
|
||||||
blueprint = _blueprint(self._settings)
|
blueprint = _blueprint(self._settings)
|
||||||
self._url = self._recording.serve_grpc(
|
self._url = self._recording.serve_grpc(
|
||||||
grpc_port=grpc_port,
|
grpc_port=grpc_port,
|
||||||
|
|
@ -95,9 +86,7 @@ class RerunBridge:
|
||||||
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
|
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
|
||||||
static=True,
|
static=True,
|
||||||
)
|
)
|
||||||
self._path: deque[tuple[float, float, float]] = deque(
|
self._path: deque[tuple[float, float, float]] = deque(maxlen=MAX_TRAJECTORY_POSES)
|
||||||
maxlen=MAX_TRAJECTORY_POSES
|
|
||||||
)
|
|
||||||
self._last_trajectory_publish_ns = 0
|
self._last_trajectory_publish_ns = 0
|
||||||
self._last_point_count = 0
|
self._last_point_count = 0
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
@ -130,33 +119,23 @@ class RerunBridge:
|
||||||
make_default=True,
|
make_default=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def process(self, message: StreamMessage) -> None:
|
def process(self, envelope: DecodedDataPlaneView) -> None:
|
||||||
started_ns = time.monotonic_ns()
|
|
||||||
self.metrics.received(len(message.payload))
|
|
||||||
self._apply_latest_settings()
|
self._apply_latest_settings()
|
||||||
self._set_message_time(message)
|
self._set_message_time(envelope)
|
||||||
|
|
||||||
try:
|
if isinstance(envelope, DecodedPointCloudView):
|
||||||
if message.topic.endswith("/lio_pcl"):
|
self._publish_points(envelope)
|
||||||
self._publish_lio_pcl(decode_lio_pcl(message.payload))
|
point_frame = True
|
||||||
point_frame = True
|
elif isinstance(envelope, DecodedPoseView):
|
||||||
elif message.topic == "RealtimePointcloud":
|
self._publish_pose(envelope)
|
||||||
self._publish_legacy_pcl(decode_legacy_pointcloud(message.payload))
|
point_frame = False
|
||||||
point_frame = True
|
else:
|
||||||
elif message.topic.endswith("/lio_pose"):
|
|
||||||
self._publish_lio_pose(decode_lio_pose(message.payload))
|
|
||||||
point_frame = False
|
|
||||||
elif message.topic == "RealtimePath":
|
|
||||||
self._publish_legacy_pose(decode_legacy_pose(message.payload))
|
|
||||||
point_frame = False
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
except StreamDecodeError:
|
|
||||||
self.metrics.decode_error()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
published_ns = time.monotonic_ns()
|
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:
|
if point_frame:
|
||||||
self.metrics.published_pcl(
|
self.metrics.published_pcl(
|
||||||
self._last_point_count,
|
self._last_point_count,
|
||||||
|
|
@ -169,10 +148,9 @@ class RerunBridge:
|
||||||
decode_publish_ms,
|
decode_publish_ms,
|
||||||
len(self._path),
|
len(self._path),
|
||||||
)
|
)
|
||||||
if message.source == "live_mqtt" and message.received_monotonic_ns is not None:
|
context = envelope.context
|
||||||
self.metrics.record_latency(
|
if context.live and context.received_monotonic_ns is not None:
|
||||||
(published_ns - message.received_monotonic_ns) / 1_000_000
|
self.metrics.record_latency((published_ns - context.received_monotonic_ns) / 1_000_000)
|
||||||
)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
if self._closed:
|
if self._closed:
|
||||||
|
|
@ -187,13 +165,14 @@ class RerunBridge:
|
||||||
# instead of waiting for the publisher thread frame to be collected.
|
# instead of waiting for the publisher thread frame to be collected.
|
||||||
del self._recording
|
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("stream_time", timestamp=time.time())
|
||||||
self._recording.set_time(
|
self._recording.set_time(
|
||||||
"capture_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:
|
def _apply_latest_settings(self) -> None:
|
||||||
settings = self._settings_provider()
|
settings = self._settings_provider()
|
||||||
|
|
@ -202,34 +181,17 @@ class RerunBridge:
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._recording.send_blueprint(_blueprint(settings))
|
self._recording.send_blueprint(_blueprint(settings))
|
||||||
|
|
||||||
def _publish_lio_pcl(self, frame: LioPointCloudFrame) -> None:
|
def _publish_points(self, frame: DecodedPointCloudView) -> None:
|
||||||
count = len(frame.points)
|
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
|
||||||
positions = np.empty((count, 3), dtype=np.float32)
|
if frame.intensities is None:
|
||||||
intensities = np.empty(count, dtype=np.uint8)
|
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
|
||||||
scaler = frame.header.scaler
|
else:
|
||||||
for index, point in enumerate(frame.points):
|
intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
|
||||||
positions[index] = point.scaled_xyz(scaler)
|
rgb = (
|
||||||
intensities[index] = point.intensity
|
None
|
||||||
self._publish_points(positions, intensities, rgb=None)
|
if frame.colors_rgb is None
|
||||||
|
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
||||||
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:
|
|
||||||
self._last_point_count = int(positions.shape[0])
|
self._last_point_count = int(positions.shape[0])
|
||||||
if not self._settings.show_points:
|
if not self._settings.show_points:
|
||||||
self._recording.log("/world/points", rr.Clear(recursive=False))
|
self._recording.log("/world/points", rr.Clear(recursive=False))
|
||||||
|
|
@ -244,25 +206,15 @@ class RerunBridge:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _publish_lio_pose(self, frame: LioPoseFrame) -> None:
|
def _publish_pose(self, frame: DecodedPoseView) -> 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:
|
|
||||||
self._recording.log(
|
self._recording.log(
|
||||||
"/world/sensor_pose",
|
"/world/sensor_pose",
|
||||||
rr.Transform3D(
|
rr.Transform3D(
|
||||||
translation=position_xyz,
|
translation=frame.position_xyz,
|
||||||
quaternion=rr.Quaternion(xyzw=orientation_xyzw),
|
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._path.append(position_xyz)
|
self._path.append(frame.position_xyz)
|
||||||
if not self._settings.show_trajectory:
|
if not self._settings.show_trajectory:
|
||||||
self._recording.log("/world/trajectory", rr.Clear(recursive=False))
|
self._recording.log("/world/trajectory", rr.Clear(recursive=False))
|
||||||
return
|
return
|
||||||
|
|
@ -283,6 +235,7 @@ class RerunBridge:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
|
def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
|
||||||
accumulation = max(0.0, settings.accumulation_seconds)
|
accumulation = max(0.0, settings.accumulation_seconds)
|
||||||
time_range = rr.VisibleTimeRange(
|
time_range = rr.VisibleTimeRange(
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,13 @@ import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
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.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.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
|
||||||
from k1link.viewer.foxglove_bridge import BridgeMetrics, MetricsSnapshot
|
|
||||||
from k1link.viewer.messages import StreamMessage
|
from k1link.viewer.messages import StreamMessage
|
||||||
|
from k1link.viewer.metrics import BridgeMetrics, MetricsSnapshot
|
||||||
from k1link.viewer.replay import iter_replay_messages
|
from k1link.viewer.replay import iter_replay_messages
|
||||||
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
|
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
|
||||||
|
|
||||||
|
|
@ -29,10 +30,20 @@ StateCallback = Callable[[], None]
|
||||||
BridgeFactory = Callable[..., RerunBridge]
|
BridgeFactory = Callable[..., RerunBridge]
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalNormalizer(Protocol):
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
message: StreamMessage,
|
||||||
|
*,
|
||||||
|
processing_started_monotonic_ns: int,
|
||||||
|
) -> DecodedDataPlaneView | None: ...
|
||||||
|
|
||||||
|
|
||||||
class RuntimeSnapshot(TypedDict):
|
class RuntimeSnapshot(TypedDict):
|
||||||
phase: RuntimePhase
|
phase: RuntimePhase
|
||||||
message: str
|
message: str
|
||||||
source_mode: SourceMode
|
source_mode: SourceMode
|
||||||
|
source_ready: bool
|
||||||
foxglove_ws_url: str | None
|
foxglove_ws_url: str | None
|
||||||
foxglove_viewer_url: str | None
|
foxglove_viewer_url: str | None
|
||||||
rerun_grpc_url: str | None
|
rerun_grpc_url: str | None
|
||||||
|
|
@ -49,6 +60,7 @@ class VisualizationRuntime:
|
||||||
on_state_change: StateCallback | None = None,
|
on_state_change: StateCallback | None = None,
|
||||||
grpc_port: int = DEFAULT_GRPC_PORT,
|
grpc_port: int = DEFAULT_GRPC_PORT,
|
||||||
bridge_factory: BridgeFactory | None = None,
|
bridge_factory: BridgeFactory | None = None,
|
||||||
|
normalizer: CanonicalNormalizer,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._on_state_change = on_state_change
|
self._on_state_change = on_state_change
|
||||||
|
|
@ -57,11 +69,13 @@ class VisualizationRuntime:
|
||||||
self._phase: RuntimePhase = "idle"
|
self._phase: RuntimePhase = "idle"
|
||||||
self._message = "Готово. Включите устройство и начните с поиска по Bluetooth."
|
self._message = "Готово. Включите устройство и начните с поиска по Bluetooth."
|
||||||
self._source_mode: SourceMode = "idle"
|
self._source_mode: SourceMode = "idle"
|
||||||
|
self._source_ready = False
|
||||||
self._foxglove_ws_url: str | None = None
|
self._foxglove_ws_url: str | None = None
|
||||||
self._foxglove_viewer_url: str | None = None
|
self._foxglove_viewer_url: str | None = None
|
||||||
self._rerun_grpc_url: str | None = None
|
self._rerun_grpc_url: str | None = None
|
||||||
self._grpc_port = grpc_port
|
self._grpc_port = grpc_port
|
||||||
self._bridge_factory = bridge_factory or RerunBridge
|
self._bridge_factory = bridge_factory or RerunBridge
|
||||||
|
self._normalizer = normalizer
|
||||||
self._bridge: RerunBridge | None = None
|
self._bridge: RerunBridge | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._scene_settings = RerunSceneSettings()
|
self._scene_settings = RerunSceneSettings()
|
||||||
|
|
@ -73,6 +87,7 @@ class VisualizationRuntime:
|
||||||
"phase": self._phase,
|
"phase": self._phase,
|
||||||
"message": self._message,
|
"message": self._message,
|
||||||
"source_mode": self._source_mode,
|
"source_mode": self._source_mode,
|
||||||
|
"source_ready": self._source_ready,
|
||||||
"foxglove_ws_url": self._foxglove_ws_url,
|
"foxglove_ws_url": self._foxglove_ws_url,
|
||||||
"foxglove_viewer_url": self._foxglove_viewer_url,
|
"foxglove_viewer_url": self._foxglove_viewer_url,
|
||||||
"rerun_grpc_url": self._rerun_grpc_url,
|
"rerun_grpc_url": self._rerun_grpc_url,
|
||||||
|
|
@ -135,6 +150,7 @@ class VisualizationRuntime:
|
||||||
if thread is None or not thread.is_alive():
|
if thread is None or not thread.is_alive():
|
||||||
self._phase = "idle"
|
self._phase = "idle"
|
||||||
self._source_mode = "idle"
|
self._source_mode = "idle"
|
||||||
|
self._source_ready = False
|
||||||
self._message = "Активного потока нет."
|
self._message = "Активного потока нет."
|
||||||
notify_only = True
|
notify_only = True
|
||||||
else:
|
else:
|
||||||
|
|
@ -161,6 +177,7 @@ class VisualizationRuntime:
|
||||||
else:
|
else:
|
||||||
self._phase = "idle"
|
self._phase = "idle"
|
||||||
self._source_mode = "idle"
|
self._source_mode = "idle"
|
||||||
|
self._source_ready = False
|
||||||
self._message = "Локальный поток завершён."
|
self._message = "Локальный поток завершён."
|
||||||
self._notify()
|
self._notify()
|
||||||
|
|
||||||
|
|
@ -194,17 +211,34 @@ class VisualizationRuntime:
|
||||||
self._metrics = BridgeMetrics()
|
self._metrics = BridgeMetrics()
|
||||||
self._phase = phase
|
self._phase = phase
|
||||||
self._source_mode = source_mode
|
self._source_mode = source_mode
|
||||||
|
self._source_ready = False
|
||||||
self._message = message
|
self._message = message
|
||||||
self._foxglove_ws_url = None
|
self._foxglove_ws_url = None
|
||||||
self._foxglove_viewer_url = None
|
self._foxglove_viewer_url = None
|
||||||
self._thread = threading.Thread(
|
self._thread = threading.Thread(
|
||||||
target=target,
|
target=lambda: self._run_target_safely(target, source_mode),
|
||||||
name=f"k1-{source_mode}-session",
|
name=f"k1-{source_mode}-session",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
self._notify()
|
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 _run_replay(self, path: Path, *, speed: float, loop: bool) -> None:
|
||||||
def produce(put: Callable[[StreamMessage], None]) -> str:
|
def produce(put: Callable[[StreamMessage], None]) -> str:
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
|
|
@ -324,8 +358,9 @@ class VisualizationRuntime:
|
||||||
publisher_aborted.set()
|
publisher_aborted.set()
|
||||||
else:
|
else:
|
||||||
self._rerun_grpc_url = bridge.grpc_url
|
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._phase = running_phase
|
||||||
|
self._source_ready = True
|
||||||
self._message = "Локальный Rerun-мост готов; источник данных запущен."
|
self._message = "Локальный Rerun-мост готов; источник данных запущен."
|
||||||
publisher_ready.set()
|
publisher_ready.set()
|
||||||
self._notify()
|
self._notify()
|
||||||
|
|
@ -339,7 +374,18 @@ class VisualizationRuntime:
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
try:
|
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:
|
finally:
|
||||||
messages.task_done()
|
messages.task_done()
|
||||||
if self._metrics.snapshot()["messages_received"] % 10 == 0:
|
if self._metrics.snapshot()["messages_received"] % 10 == 0:
|
||||||
|
|
@ -413,6 +459,7 @@ class VisualizationRuntime:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._phase != "stopping":
|
if self._phase != "stopping":
|
||||||
self._phase = phase
|
self._phase = phase
|
||||||
|
self._source_ready = True
|
||||||
self._message = message
|
self._message = message
|
||||||
self._notify()
|
self._notify()
|
||||||
|
|
||||||
|
|
@ -420,6 +467,7 @@ class VisualizationRuntime:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._phase = "idle"
|
self._phase = "idle"
|
||||||
self._source_mode = "idle"
|
self._source_mode = "idle"
|
||||||
|
self._source_ready = False
|
||||||
self._message = message
|
self._message = message
|
||||||
self._foxglove_ws_url = None
|
self._foxglove_ws_url = None
|
||||||
self._foxglove_viewer_url = None
|
self._foxglove_viewer_url = None
|
||||||
|
|
@ -428,6 +476,7 @@ class VisualizationRuntime:
|
||||||
def _finish_error(self, message: str) -> None:
|
def _finish_error(self, message: str) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._phase = "error"
|
self._phase = "error"
|
||||||
|
self._source_ready = False
|
||||||
self._message = message
|
self._message = message
|
||||||
self._foxglove_ws_url = None
|
self._foxglove_ws_url = None
|
||||||
self._foxglove_viewer_url = None
|
self._foxglove_viewer_url = None
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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 fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
|
@ -23,6 +25,7 @@ from k1link.web.plugin_runtime import (
|
||||||
)
|
)
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
||||||
|
|
||||||
|
|
||||||
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
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")
|
@app.get("/api/health")
|
||||||
def health() -> dict[str, Any]:
|
def health() -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -86,7 +99,7 @@ async def invoke_device_plugin_action(
|
||||||
except (PluginNotFoundError, PluginActionNotFoundError) as exc:
|
except (PluginNotFoundError, PluginActionNotFoundError) as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except ValidationError as 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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
except PluginExecutionError as exc:
|
except PluginExecutionError as exc:
|
||||||
|
|
|
||||||
|
|
@ -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()}"
|
||||||
|
|
@ -1,13 +1,29 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Literal
|
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
|
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:
|
def _reject_blank(value: str) -> str:
|
||||||
|
|
@ -16,6 +32,11 @@ def _reject_blank(value: str) -> str:
|
||||||
return value
|
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[
|
ShortText = Annotated[
|
||||||
str,
|
str,
|
||||||
Field(min_length=1, max_length=160),
|
Field(min_length=1, max_length=160),
|
||||||
|
|
@ -31,6 +52,11 @@ EntrypointText = Annotated[
|
||||||
Field(min_length=1, max_length=256),
|
Field(min_length=1, max_length=256),
|
||||||
AfterValidator(_reject_blank),
|
AfterValidator(_reject_blank),
|
||||||
]
|
]
|
||||||
|
ProfilePathText = Annotated[
|
||||||
|
str,
|
||||||
|
Field(min_length=1, max_length=512),
|
||||||
|
AfterValidator(_reject_blank),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class CapabilityManifest(BaseModel):
|
class CapabilityManifest(BaseModel):
|
||||||
|
|
@ -75,6 +101,14 @@ class PluginActionManifest(BaseModel):
|
||||||
secretFields: list[ShortText]
|
secretFields: list[ShortText]
|
||||||
|
|
||||||
|
|
||||||
|
class CompatibilityProfileLinkManifest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
profileId: ShortText
|
||||||
|
path: ProfilePathText
|
||||||
|
modelId: ShortText
|
||||||
|
|
||||||
|
|
||||||
class PluginMetadata(BaseModel):
|
class PluginMetadata(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
@ -94,26 +128,164 @@ class PluginMetadata(BaseModel):
|
||||||
class PluginSpec(BaseModel):
|
class PluginSpec(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
hostApiRange: Literal["v1alpha1"]
|
hostApiRange: Literal["v1alpha1", "v1alpha2"]
|
||||||
runtime: PluginRuntimeManifest
|
runtime: PluginRuntimeManifest
|
||||||
permissions: list[ShortText]
|
permissions: list[ShortText]
|
||||||
actions: list[PluginActionManifest]
|
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):
|
class DevicePluginManifest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
apiVersion: Literal["missioncore.nodedc/v1alpha1"]
|
apiVersion: Literal[
|
||||||
|
"missioncore.nodedc/v1alpha1",
|
||||||
|
"missioncore.nodedc/v1alpha2",
|
||||||
|
]
|
||||||
kind: Literal["DevicePlugin"]
|
kind: Literal["DevicePlugin"]
|
||||||
metadata: PluginMetadata
|
metadata: PluginMetadata
|
||||||
spec: PluginSpec
|
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):
|
class PluginCatalogError(RuntimeError):
|
||||||
"""An installed manifest is invalid or conflicts with another manifest."""
|
"""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:
|
class DevicePluginCatalog:
|
||||||
"""Read-only catalog of statically reviewed device-plugin manifests."""
|
"""Read-only catalog of statically reviewed device-plugin manifests."""
|
||||||
|
|
||||||
|
|
@ -141,6 +313,8 @@ class DevicePluginCatalog:
|
||||||
raise PluginCatalogError(f"Duplicate device-plugin id: {plugin_id}")
|
raise PluginCatalogError(f"Duplicate device-plugin id: {plugin_id}")
|
||||||
plugin_ids.add(plugin_id)
|
plugin_ids.add(plugin_id)
|
||||||
|
|
||||||
|
_validate_profile_links(manifest, path)
|
||||||
|
|
||||||
action_ids: set[str] = set()
|
action_ids: set[str] = set()
|
||||||
for action in manifest.spec.actions:
|
for action in manifest.spec.actions:
|
||||||
if action.id in action_ids:
|
if action.id in action_ids:
|
||||||
|
|
@ -178,7 +352,9 @@ class DevicePluginCatalog:
|
||||||
return list(self._validated_manifests)
|
return list(self._validated_manifests)
|
||||||
|
|
||||||
def plugin_documents(self) -> list[dict[str, Any]]:
|
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]]:
|
def model_documents(self) -> list[dict[str, Any]]:
|
||||||
models: list[dict[str, Any]] = []
|
models: list[dict[str, Any]] = []
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,10 +23,10 @@ def test_advertisement_record_marks_k1_candidate() -> None:
|
||||||
assert record["service_uuids"] == ["B", "a"]
|
assert record["service_uuids"] == ["B", "a"]
|
||||||
|
|
||||||
|
|
||||||
def test_advertisement_record_marks_observed_xgr_serial_name_as_k1_candidate() -> None:
|
def test_advertisement_record_marks_synthetic_xgr_name_as_k1_candidate() -> None:
|
||||||
device = BLEDevice("F89438FA-55ED-85AD-EED7-734AC84746D8", "XGR-A46BE7", None)
|
device = BLEDevice("00000000-0000-0000-0000-000000000001", "XGR-TEST01", None)
|
||||||
advertisement = AdvertisementData(
|
advertisement = AdvertisementData(
|
||||||
local_name="XGR-A46BE7",
|
local_name="XGR-TEST01",
|
||||||
manufacturer_data={},
|
manufacturer_data={},
|
||||||
service_data={},
|
service_data={},
|
||||||
service_uuids=[],
|
service_uuids=[],
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -90,3 +90,46 @@ def test_model_switch_is_guarded_by_plugin_deactivation() -> None:
|
||||||
assert "catch" in host_source
|
assert "catch" in host_source
|
||||||
assert "selectionTransitionError" in host_source
|
assert "selectionTransitionError" in host_source
|
||||||
assert "return controller.stop();" in xgrids_runtime
|
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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def _write_manifest(root: Path, directory: str, document: dict[str, Any]) -> None:
|
||||||
target = root / "plugins" / directory / "plugin.manifest.json"
|
target = root / "plugins" / directory / "plugin.manifest.json"
|
||||||
target.parent.mkdir(parents=True)
|
target.parent.mkdir(parents=True)
|
||||||
target.write_text(json.dumps(document), encoding="utf-8")
|
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:
|
def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||||
repository_root = Path(__file__).resolve().parents[1]
|
repository_root = Path(__file__).resolve().parents[1]
|
||||||
catalog = DevicePluginCatalog(repository_root)
|
catalog = DevicePluginCatalog(repository_root)
|
||||||
|
|
@ -58,10 +84,40 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
|
||||||
plugins = catalog.plugin_documents()
|
plugins = catalog.plugin_documents()
|
||||||
models = catalog.model_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") == {
|
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
|
||||||
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
|
||||||
"pluginVersion": "0.1.0",
|
"pluginVersion": "0.2.0",
|
||||||
"id": "xgrids.lixelkity-k1",
|
"id": "xgrids.lixelkity-k1",
|
||||||
"vendor": "XGRIDS",
|
"vendor": "XGRIDS",
|
||||||
"displayName": "XGRIDS LixelKity K1",
|
"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:
|
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, "first", _manifest("example.first", "example.model"))
|
||||||
_write_manifest(tmp_path, "second", _manifest("example.second", "example.model"))
|
_write_manifest(tmp_path, "second", _manifest("example.second", "example.model"))
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ from k1link.web.xgrids_k1_facade import (
|
||||||
ACTION_STREAM_STOP,
|
ACTION_STREAM_STOP,
|
||||||
ACTION_VIEWER_SETTINGS_UPDATE,
|
ACTION_VIEWER_SETTINGS_UPDATE,
|
||||||
XGRIDS_K1_PLUGIN_ID,
|
XGRIDS_K1_PLUGIN_ID,
|
||||||
|
CompatibilityAttestationRequest,
|
||||||
ConnectRequest,
|
ConnectRequest,
|
||||||
ViewerSettingsRequest,
|
ViewerSettingsRequest,
|
||||||
XgridsK1PluginFacade,
|
XgridsK1PluginFacade,
|
||||||
|
|
@ -53,8 +54,13 @@ class FakeXgridsService:
|
||||||
self.calls.append(("connect", request))
|
self.calls.append(("connect", request))
|
||||||
return {"phase": "connected", "k1_ip": "192.168.1.20"}
|
return {"phase": "connected", "k1_ip": "192.168.1.20"}
|
||||||
|
|
||||||
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
|
def start_live(
|
||||||
self.calls.append(("live", (host, duration_seconds)))
|
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"}
|
return {"phase": "live"}
|
||||||
|
|
||||||
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
|
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(
|
dispatcher.invoke(
|
||||||
XGRIDS_K1_PLUGIN_ID,
|
XGRIDS_K1_PLUGIN_ID,
|
||||||
ACTION_NETWORK_PROVISION,
|
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(
|
@pytest.mark.parametrize(
|
||||||
("action_id", "payload", "expected_call"),
|
("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,
|
ACTION_STREAM_START_REPLAY,
|
||||||
{"path": "sessions/capture.k1mqtt", "speed": 1, "loop": False},
|
{"path": "sessions/capture.k1mqtt", "speed": 1, "loop": False},
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ from pathlib import Path
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from k1link.data_plane import DecodedDataPlaneView, NormalizationError
|
||||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
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.messages import StreamMessage
|
||||||
from k1link.viewer.rerun_bridge import RerunBridge, RerunSceneSettings, _point_colors
|
from k1link.viewer.rerun_bridge import RerunBridge, RerunSceneSettings, _point_colors
|
||||||
from k1link.viewer.runtime import VisualizationRuntime
|
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:
|
def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
|
||||||
recording = FakeRecording()
|
recording = FakeRecording()
|
||||||
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
|
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
|
"<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)
|
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(_envelope("RealtimePointcloud", point_payload))
|
||||||
bridge.process(_message("RealtimePath", pose_payload, sequence=8))
|
bridge.process(_envelope("RealtimePath", pose_payload, sequence=8))
|
||||||
|
|
||||||
paths = [path for path, _, _ in recording.logs]
|
paths = [path for path, _, _ in recording.logs]
|
||||||
snapshot = bridge.metrics.snapshot()
|
snapshot = bridge.metrics.snapshot()
|
||||||
|
|
@ -83,15 +94,19 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
|
||||||
assert recording.disconnected is True
|
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()
|
recording = FakeRecording()
|
||||||
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
|
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()
|
snapshot = bridge.metrics.snapshot()
|
||||||
assert snapshot["messages_received"] == 1
|
assert snapshot["messages_received"] == 0
|
||||||
assert snapshot["decode_errors"] == 1
|
assert snapshot["decode_errors"] == 0
|
||||||
assert not any(path == "/world/points" for path, _, _ in recording.logs)
|
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(
|
runtime = VisualizationRuntime(
|
||||||
bridge_factory=bridge_factory,
|
bridge_factory=bridge_factory,
|
||||||
|
normalizer=normalize_k1_message,
|
||||||
)
|
)
|
||||||
runtime.start_replay(capture, speed=1.0)
|
runtime.start_replay(capture, speed=1.0)
|
||||||
deadline = time.monotonic() + 5.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]
|
**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)
|
runtime.start_replay(capture, speed=0.0)
|
||||||
assert factory_entered.wait(timeout=2.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]
|
**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)
|
runtime.start_replay(capture, speed=0.0)
|
||||||
assert factory_entered.wait(timeout=2.0)
|
assert factory_entered.wait(timeout=2.0)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,20 +113,10 @@ def test_decode_lio_pcl_rejects_unverified_or_unsafe_frames() -> None:
|
||||||
|
|
||||||
def test_decode_lio_pose() -> None:
|
def test_decode_lio_pose() -> None:
|
||||||
position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75)
|
position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75)
|
||||||
orientation = (
|
orientation = _fixed64(1, 0.1) + _fixed64(2, 0.2) + _fixed64(3, 0.3) + _fixed64(4, 0.9)
|
||||||
_fixed64(1, 0.1)
|
|
||||||
+ _fixed64(2, 0.2)
|
|
||||||
+ _fixed64(3, 0.3)
|
|
||||||
+ _fixed64(4, 0.9)
|
|
||||||
)
|
|
||||||
pose = _bytes(1, position) + _bytes(2, orientation)
|
pose = _bytes(1, position) + _bytes(2, orientation)
|
||||||
stamped = _sint(1, 987654321) + _bytes(2, pose)
|
stamped = _sint(1, 987654321) + _bytes(2, pose)
|
||||||
payload = (
|
payload = _bytes(1, _header()) + _bytes(2, stamped) + _fixed32(3, 12.5) + _fixed32(4, 0.001)
|
||||||
_bytes(1, _header())
|
|
||||||
+ _bytes(2, stamped)
|
|
||||||
+ _fixed32(3, 12.5)
|
|
||||||
+ _fixed32(4, 0.001)
|
|
||||||
)
|
|
||||||
|
|
||||||
frame = decode_lio_pose(payload)
|
frame = decode_lio_pose(payload)
|
||||||
assert frame.pose_stamp == 987654321
|
assert frame.pose_stamp == 987654321
|
||||||
|
|
|
||||||
|
|
@ -77,15 +77,9 @@ def _pcl_payload(*, scaler: int, point_count: int) -> bytes:
|
||||||
|
|
||||||
def _pose_payload(position_xyz: tuple[float, float, float]) -> bytes:
|
def _pose_payload(position_xyz: tuple[float, float, float]) -> bytes:
|
||||||
position = b"".join(
|
position = b"".join(
|
||||||
_fixed64(field_number, value)
|
_fixed64(field_number, value) for field_number, value in enumerate(position_xyz, start=1)
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
orientation = _fixed64(1, 0.0) + _fixed64(2, 0.0) + _fixed64(3, 0.0) + _fixed64(4, 1.0)
|
||||||
pose = _bytes(1, position) + _bytes(2, orientation)
|
pose = _bytes(1, position) + _bytes(2, orientation)
|
||||||
stamped = _sint(1, 987654321) + _bytes(2, pose)
|
stamped = _sint(1, 987654321) + _bytes(2, pose)
|
||||||
return _bytes(1, _header(scaler=1000)) + _bytes(2, stamped)
|
return _bytes(1, _header(scaler=1000)) + _bytes(2, stamped)
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
|
||||||
"devices": [
|
"devices": [
|
||||||
{
|
{
|
||||||
"macos_uuid": "K1-UUID",
|
"macos_uuid": "K1-UUID",
|
||||||
"name": "XGR-A46BE7",
|
"name": "XGR-TEST01",
|
||||||
"local_name": "XGR-A46BE7",
|
"local_name": "XGR-TEST01",
|
||||||
"rssi": -51,
|
"rssi": -51,
|
||||||
"k1_name_candidate": True,
|
"k1_name_candidate": True,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -8,31 +8,34 @@ from k1link.ble.wifi_provisioning import (
|
||||||
|
|
||||||
|
|
||||||
def test_build_wifi_provisioning_frame_layout() -> None:
|
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 len(frame) == FRAME_LENGTH
|
||||||
assert frame[0] == 6
|
assert frame[0] == 6
|
||||||
assert frame[1:7] == b"LabNet"
|
assert frame[1:7] == b"LabNet"
|
||||||
assert frame[7:33] == bytes(26)
|
assert frame[7:33] == bytes(26)
|
||||||
assert frame[33] == 13
|
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[47:98] == bytes(51)
|
||||||
assert frame[98] == 0
|
assert frame[98] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_build_wifi_provisioning_frame_uses_utf8_byte_lengths() -> None:
|
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[0] == len(ssid.encode())
|
||||||
assert frame[33] == len("пароль".encode())
|
assert frame[33] == len(credential.encode())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("ssid", "password", "message"),
|
("ssid", "password", "message"),
|
||||||
[
|
[
|
||||||
("", "password", "SSID must not be empty"),
|
("", "x" * 8, "SSID must not be empty"),
|
||||||
("network", "", "password 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"),
|
("network", "x" * 65, "at most 64 UTF-8 bytes"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue