Compare commits

...

4 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 05bdab24a5 docs: record Mission Core K1 architecture milestone 2026-07-16 19:44:16 +03:00
DCCONSTRUCTIONS e6f7648b84 feat(device-plugins): add profiled K1 lifecycle and canonical data plane 2026-07-16 19:44:06 +03:00
DCCONSTRUCTIONS 19ab973110 feat(lab): add isolated iPhone capture evidence 2026-07-16 19:43:41 +03:00
DCCONSTRUCTIONS 63dd8c8790 feat(plugin-sdk): add executable v0alpha2 contracts 2026-07-16 19:43:26 +03:00
82 changed files with 11122 additions and 547 deletions

View File

@ -8,16 +8,30 @@ extracted, and device plugins.
The first proven hardware vertical is the XGRIDS/LixelKity K1 plugin. On firmware
3.0.2 the host provisions the scanner onto an existing LAN without LixelGO,
connects to its MQTT broker, durably captures the raw stream, decodes point cloud
and pose, and renders the real cloud plus trajectory through an embedded
self-hosted Rerun Web Viewer. The former Foxglove bridge remains only as a
legacy regression module.
connects to its MQTT broker, persists each raw frame before preview work, decodes
point cloud and pose, and renders the real cloud plus trajectory through an
embedded self-hosted Rerun Web Viewer. Capture files are `fsync`ed on clean
close; per-frame power-loss durability is not claimed. The former Foxglove
bridge remains only as a legacy regression module.
The repository is intentionally migrating in stages. The current `src/k1link`
package is the compatibility implementation of the first plugin path; vendor
transport and codecs will move behind `plugins/xgrids-k1` and the Mission Core
Plugin SDK without changing the verified wire protocol or raw evidence format.
Plugin SDK v0alpha2 now provides executable, vendor-neutral identity, session,
operation, stream, evidence and compatibility contracts. The meanings remain a
local experimental vocabulary rather than a mutation of NODE.DC Platform
Ontology. The current runtime is still transitional and in process: it uses an
explicitly injected K1 normalizer to produce transport-neutral local consumer
views; portable SDK stream envelopes, process isolation, durable operations,
multi-device routing and the remote Edge split remain later gates.
The current runtime cannot read K1 firmware automatically. It keeps the exact
profile inactive until the operator explicitly attests firmware `3.0.2` and
direct-LAN topology; state records that basis as `operator-attested`, not as
device-derived evidence.
The repository now contains one narrowly gated state-changing command:
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
profile and requires explicit `--confirm-write`; the Wi-Fi password is collected
@ -29,7 +43,16 @@ Nothing changes router settings, firmware or global Python packages.
- one XGRIDS/LixelKity K1;
- one Apple Silicon MacBook running macOS;
- one ordinary TP-Link Deco/mesh network used by other devices;
- no LixelGO, phone, Linux host, dedicated AP, OpenWrt, or vendor SDK.
- the proven baseline used no LixelGO, phone, Linux host, dedicated AP,
OpenWrt, or vendor SDK.
An owner-controlled iPhone with LixelGO is now available for a separate,
evidence-only observation stage. It does not invalidate the no-phone baseline
and is not a runtime dependency. The decision and gated runbook are
[`ADR 0005`](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md) and
[`docs/08_LIXELGO_IPHONE_OBSERVATION.md`](docs/08_LIXELGO_IPHONE_OBSERVATION.md).
The Mac capture environment is isolated under
`plugins/xgrids-k1/lab/iphone-capture/` and does not require full Xcode.
The ordinary router is sufficient for the first gates. We first observe the
existing LAN without changing it. A Guest/IoT SSID is optional and may be
@ -45,10 +68,11 @@ The project has three independent gates:
3. Without LixelGO, K1 can be associated with Wi-Fi and a proprietary data
session can be opened.
All three gates are now proven on the tested unit. The external stream is plain
All three gates are now proven on the tested unit. The spatial stream is plain
MQTT 3.1.1 on TCP 1883. Firmware-3 `lio_pcl` is protobuf wrapped in a raw LZ4
block, and `lio_pose` is an uncompressed protobuf. Raw panoramic camera access is
still unproven and is not implied by point-cloud success.
block, and `lio_pose` is an uncompressed protobuf. Owner-operated LixelGO capture
also proved separate left/right RTSP/H.264 camera previews on TCP 8554. This is a
compressed preview contract, not proof of full-resolution raw camera access.
## Local environment
@ -58,7 +82,7 @@ repositories.
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
uv sync --group dev
uv sync --frozen --group dev
uv run k1link doctor
uv run pytest
```
@ -74,9 +98,10 @@ Install, type-check, build and serve the complete local application from the
repository root:
```bash
uv sync --group dev
uv sync --frozen --group dev
cd apps/control-station
npm install
npm ci
npm run test:unit
npm run typecheck
npm run build
cd ../..
@ -89,7 +114,14 @@ still provides real CoreBluetooth discovery, one operator-triggered reviewed
BLE Wi-Fi provisioning write, read-only MQTT live capture, native `.k1mqtt` and
reviewed-TSV replay, raw-first evidence storage and measured preview metrics.
Physical K1 scanning is still started and stopped by the verified double-click;
the connector publishes no modeling command.
the connector publishes no modeling command. The observed LixelGO action mapping
remains descriptive and write-disabled.
This locked bootstrap is repeatable in the current workspace, not yet a
standalone release install. The frontend consumes sibling `file:` packages from
`NODEDC_DESIGN_GUIDELINE`; `package-lock.json` does not pin that checkout's Git
revision or content hash. Publishing/vendoring those packages or enforcing an
immutable donor revision remains a packaging and CI prerequisite.
The Observation spatial workspace embeds the open-source Rerun Web Viewer
inside the Mission Core shell. It can open a compatible RRD file over HTTP(S) or a
@ -102,9 +134,12 @@ the resulting URL through control-plane state. Later sessions reset their
session-local scene and metrics and reuse that process-wide stream; this avoids
restarting the native listener while the embedded browser remains connected.
Unless an operator has entered a manual source, the React application assigns
that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first evidence
capture → bounded latest-wins preview queue → reviewed protobuf/LZ4 decoders →
Rerun `Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer.
that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first
evidence capture → bounded latest-wins preview queue → explicitly injected K1
protobuf/LZ4 normalizer → transport-neutral decoded local views → Rerun
`Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. Rerun does
not inspect K1 topics or raw payloads. These local decoded views are not yet the
portable Plugin SDK wire envelopes.
The default Rerun blueprint shows a 12-second sliding accumulation of real point
frames. Product controls are connected for point size, intensity/height/distance
@ -115,9 +150,9 @@ point cloud, trajectory, camera frame or latency value is generated.
A powered-device checkpoint passed 80 real MQTT messages through the current
Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last
cloud and zero decode errors. Raw panoramic camera frames remain absent. Rerun
`capture_time` is the Mac receive timestamp, not a proven K1 sensor timestamp or
photon-to-screen measurement.
cloud and zero decode errors. The later RTSP camera preview is not yet wired into
the Rerun/runtime path. Rerun `capture_time` is the Mac receive timestamp, not a
proven K1 sensor timestamp or photon-to-screen measurement.
The old Foxglove implementation is retained only in
`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current
@ -179,6 +214,7 @@ present.
- [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md)
- [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md)
- [Device plugin UI and runtime boundary](docs/adr/0003-device-plugin-ui-and-runtime-boundary.md)
- [Plugin SDK v0alpha2 and experimental device lifecycle](docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md)
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
- [Session manifest schema](schemas/session-manifest.schema.json)
- [Reference input provenance](docs/reference/README.md)

View File

@ -16,7 +16,7 @@ device adapter, но структура интерфейса от него не
| Контур | Состояние | Что это означает |
| --- | --- | --- |
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
| Device plugin registry | Реализован, v1alpha1 | До выбора модели provider остаётся inert и не делает I/O; каждый manifest объявляет ровно одну модель, custom `device.connection` UI key и backend factory. Frontend component подключается одним reviewed import в composition root. |
| Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. |
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
@ -36,12 +36,13 @@ device adapter, но структура интерфейса от него не
K1 MQTT :1883, read-only
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
└── bounded latest-wins preview queue (32 сообщения)
└── проверенный protobuf/LZ4 decoder
└── Rerun Points3D + Transform3D + LineStrips3D
└── gRPC/proxy TCP 9876
└── rerun_grpc_url в REST/WebSocket state
└── встроенный @rerun-io/web-viewer
└── пространственная сцена Mission Core
└── явно внедрённый K1 protobuf/LZ4 normalizer
└── transport-neutral DecodedPointCloudView / DecodedPoseView
└── Rerun Points3D + Transform3D + LineStrips3D
└── gRPC/proxy TCP 9876
└── rerun_grpc_url в REST/WebSocket state
└── встроенный @rerun-io/web-viewer
└── пространственная сцена Mission Core
Mission Core Control Station ←→ REST /api/v1/device-plugins/*
+ plugin-scoped WebSocket events
@ -108,10 +109,11 @@ layout пока остаются интерфейсным контрактом.
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
uv sync --group dev
uv sync --frozen --group dev
cd apps/control-station
npm install
npm ci
npm run test:unit
npm run typecheck
npm run build
@ -124,6 +126,12 @@ uv run k1link serve
предусмотрен, потому что endpoint подключения кратковременно принимает пароль
Wi-Fi.
Этот locked bootstrap повторяем в текущем workspace, но он ещё не является
standalone release install. Локальные `file:` зависимости берутся из соседнего
`NODEDC_DESIGN_GUIDELINE`, а lockfile не фиксирует Git revision или content hash
этого checkout. До CI/portable packaging эти пакеты нужно опубликовать,
завендорить либо проверять по неизменяемой donor revision.
Для разработки интерфейса после запуска backend:
```bash
@ -142,7 +150,10 @@ Vite слушает `http://127.0.0.1:5173` и проксирует весь `/a
2. Выбрать **Парк → Локальное устройство**.
3. В каталоге моделей выбрать **XGRIDS LixelKity K1**. Только после этого
активируется runtime и монтируется custom UI XGRIDS-плагина.
4. Включить K1, дождаться стабильного индикатора и подтвердить это в форме.
4. Включить K1, дождаться стабильного индикатора, вручную сверить firmware
`3.0.2` и direct-LAN топологию, затем подтвердить это в форме. Mission Core
не читает firmware с устройства; такое подтверждение остаётся
`operator-attested`, а не device-derived evidence.
5. Нажать **Показать все BLE-устройства**. Интерфейс показывает полный результат
шестисекундного поиска; метка совместимости является подсказкой, выбор делает
оператор.
@ -211,7 +222,7 @@ Frontend XGRIDS-плагина использует только v1alpha namespa
`/api/viewer/settings` сохранены как deprecated compatibility shims внутри
XGRIDS backend contribution.
В v1alpha1 backend composition загружается из manifests, а frontend plugins
В v1alpha1/v1alpha2 backend composition загружается из manifests, а frontend plugins
статически включаются в сборку через `src/composition/devicePlugins.ts`.
Автоматической runtime-сверки двух installed sets пока нет: их соответствие —
проверяемое требование сборки до появления подписанных plugin bundles и startup
@ -219,9 +230,17 @@ compatibility handshake. Поля manifest `permissions`, `mutating` и
`secretFields` пока являются декларативными метаданными; generic host валидирует
их форму и action allowlist, но ещё не реализует на их основе RBAC, подтверждения
оператора или secret-vault substitution.
Точный K1 compatibility profile остаётся неактивным, пока оператор явно не
подтвердит firmware `3.0.2` и direct-LAN топологию. Runtime не получает версию
firmware с устройства и явно помечает основание как `operator-attested`.
Ошибки валидации namespaced action endpoint и deprecated `/api/connect`
возвращаются как общий `422` без исходных значений запроса, поэтому отклонённое
значение credential-поля не отражается клиенту.
Legacy `/api/state` и изменяющие состояние ответы могут вернуть snapshot напрямую или
как `{ "state": { ... } }`. Текущие поля включают `phase`, `message`, `devices`,
как `{ "state": { ... } }`. v1alpha2 snapshot отдельно содержит `device_ref`,
`device_session`, `acquisition`, `operations`, compatibility/calibration/sensor
facts и старые presentation-поля `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `source_mode`, `metrics`, `rerun_grpc_url` и
`viewer_settings`. Legacy-поля `foxglove_ws_url`/`foxglove_viewer_url` остаются
пустыми. OpenAPI доступен по `/api/docs`.

View File

@ -25,7 +25,7 @@
"vite-plugin-wasm": "^3.6.0"
},
"engines": {
"node": ">=20"
"node": "^20.19.0 || >=22.12.0"
}
},
"../../../NODEDC_DESIGN_GUIDELINE/packages/tokens": {

View File

@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test:unit": "node --test test/*.test.mjs",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
@ -27,6 +28,6 @@
"vite-plugin-wasm": "^3.6.0"
},
"engines": {
"node": ">=20"
"node": "^20.19.0 || >=22.12.0"
}
}

View File

@ -1,8 +1,17 @@
import type { ComponentType, ReactNode } from "react";
export const DEVICE_PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1" as const;
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA1 = "missioncore.nodedc/v1alpha1" as const;
export const DEVICE_PLUGIN_API_VERSION_V1ALPHA2 = "missioncore.nodedc/v1alpha2" as const;
export const DEVICE_PLUGIN_API_VERSION = DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
export const SUPPORTED_DEVICE_PLUGIN_API_VERSIONS = Object.freeze([
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
] as const);
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
export type DevicePluginApiVersion =
(typeof SUPPORTED_DEVICE_PLUGIN_API_VERSIONS)[number];
export interface DeviceCapability {
id: string;
label: string;
@ -28,14 +37,24 @@ export interface DeviceModelDefinition {
};
}
export interface DevicePluginManifest {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION;
export interface DeviceCompatibilityProfileDefinition {
profileId: string;
path: string;
modelId: string;
}
interface DevicePluginManifestBase {
apiVersion: DevicePluginApiVersion;
kind: "DevicePlugin";
metadata: {
id: string;
version: string;
displayName: string;
};
}
export interface DevicePluginManifestV1Alpha1 extends DevicePluginManifestBase {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA1;
spec: {
hostApiRange: "v1alpha1";
runtime: {
@ -48,6 +67,31 @@ export interface DevicePluginManifest {
};
}
export interface DevicePluginManifestV1Alpha2 extends DevicePluginManifestBase {
apiVersion: typeof DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
spec: {
hostApiRange: "v1alpha2";
runtime: {
backendEntrypoint: string;
isolation: "transitional-in-process";
};
permissions: readonly string[];
actions: readonly DevicePluginActionDefinition[];
models: readonly DeviceModelDefinition[];
compatibilityProfiles: readonly DeviceCompatibilityProfileDefinition[];
};
}
export type DevicePluginManifest =
| DevicePluginManifestV1Alpha1
| DevicePluginManifestV1Alpha2;
export function isDevicePluginManifestV1Alpha2(
manifest: DevicePluginManifest,
): manifest is DevicePluginManifestV1Alpha2 {
return manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
}
export interface DevicePluginHostActions {
openSpatialScene: () => void;
}

View File

@ -1,9 +1,13 @@
import {
DEVICE_PLUGIN_API_VERSION,
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_PLUGIN_API_VERSION_V1ALPHA2,
type DeviceCapability,
type DeviceCompatibilityProfileDefinition,
type DeviceModelDefinition,
type DevicePluginActionDefinition,
type DevicePluginManifest,
type DevicePluginManifestV1Alpha1,
type DevicePluginManifestV1Alpha2,
} from "./contracts";
function record(value: unknown, path: string): Record<string, unknown> {
@ -36,6 +40,14 @@ function text(value: unknown, path: string, maxLength = 160): string {
return value;
}
function identifier(value: unknown, path: string): string {
const candidate = text(value, path, 192);
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(candidate)) {
throw new Error(`Некорректный manifest: ${path} не является идентификатором.`);
}
return candidate;
}
function flag(value: unknown, path: string): boolean {
if (typeof value !== "boolean") {
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
@ -50,25 +62,44 @@ function list(value: unknown, path: string): unknown[] {
return value;
}
function capability(value: unknown, path: string): DeviceCapability {
const item = record(value, path);
exactKeys(item, path, ["id", "label"]);
return { id: text(item.id, `${path}.id`), label: text(item.label, `${path}.label`) };
function contractText(value: unknown, path: string, strictIdentifiers: boolean): string {
return strictIdentifiers ? identifier(value, path) : text(value, path);
}
function action(value: unknown, path: string): DevicePluginActionDefinition {
function capability(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DeviceCapability {
const item = record(value, path);
exactKeys(item, path, ["id", "label"]);
return {
id: contractText(item.id, `${path}.id`, strictIdentifiers),
label: text(item.label, `${path}.label`),
};
}
function action(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DevicePluginActionDefinition {
const item = record(value, path);
exactKeys(item, path, ["id", "mutating", "secretFields"]);
return {
id: text(item.id, `${path}.id`),
id: contractText(item.id, `${path}.id`, strictIdentifiers),
mutating: flag(item.mutating, `${path}.mutating`),
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
text(field, `${path}.secretFields[${index}]`),
contractText(field, `${path}.secretFields[${index}]`, strictIdentifiers),
),
};
}
function model(value: unknown, path: string): DeviceModelDefinition {
function model(
value: unknown,
path: string,
strictIdentifiers: boolean,
): DeviceModelDefinition {
const item = record(value, path);
exactKeys(item, path, [
"id",
@ -87,14 +118,14 @@ function model(value: unknown, path: string): DeviceModelDefinition {
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
}
return {
id: text(item.id, `${path}.id`),
id: contractText(item.id, `${path}.id`, strictIdentifiers),
vendor: text(item.vendor, `${path}.vendor`),
displayName: text(item.displayName, `${path}.displayName`),
category: text(item.category, `${path}.category`),
description: text(item.description, `${path}.description`, 1024),
verified: flag(item.verified, `${path}.verified`),
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
capability(entry, `${path}.capabilities[${index}]`),
capability(entry, `${path}.capabilities[${index}]`, strictIdentifiers),
),
ui: {
slot,
@ -103,23 +134,96 @@ function model(value: unknown, path: string): DeviceModelDefinition {
};
}
function compatibilityProfile(
value: unknown,
path: string,
): DeviceCompatibilityProfileDefinition {
const item = record(value, path);
exactKeys(item, path, ["profileId", "path", "modelId"]);
const profilePath = text(item.path, `${path}.path`, 512);
const pathSegments = profilePath.split("/");
if (
profilePath.startsWith("/") ||
profilePath.includes("\\") ||
!profilePath.endsWith(".json") ||
pathSegments.some((segment) => !segment || segment === "." || segment === "..")
) {
throw new Error(
`Некорректный manifest: ${path}.path должен быть безопасным относительным JSON-путём.`,
);
}
return {
profileId: identifier(item.profileId, `${path}.profileId`),
path: profilePath,
modelId: identifier(item.modelId, `${path}.modelId`),
};
}
function validateV1Alpha2Profiles(
models: readonly DeviceModelDefinition[],
profiles: readonly DeviceCompatibilityProfileDefinition[],
): void {
if (!profiles.length) {
throw new Error("Manifest v1alpha2 должен объявлять compatibilityProfiles.");
}
const modelIds = new Set(models.map((modelItem) => modelItem.id));
const profileIds = new Set<string>();
const profilePaths = new Set<string>();
const coveredModels = new Set<string>();
for (const profile of profiles) {
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
throw new Error(
`Manifest v1alpha2 повторяет профиль ${profile.profileId} или его путь.`,
);
}
profileIds.add(profile.profileId);
profilePaths.add(profile.path);
if (!modelIds.has(profile.modelId)) {
throw new Error(
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
);
}
coveredModels.add(profile.modelId);
}
const uncovered = [...modelIds].filter((modelId) => !coveredModels.has(modelId));
if (uncovered.length) {
throw new Error(
`Manifest v1alpha2 не содержит разрешённого профиля для моделей: ${uncovered.join(", ")}.`,
);
}
}
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
const root = record(document, "root");
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
if (root.apiVersion !== DEVICE_PLUGIN_API_VERSION || root.kind !== "DevicePlugin") {
const apiVersion = root.apiVersion;
if (
(apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
apiVersion !== DEVICE_PLUGIN_API_VERSION_V1ALPHA2) ||
root.kind !== "DevicePlugin"
) {
throw new Error("Manifest использует несовместимую версию или kind.");
}
const metadata = record(root.metadata, "metadata");
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
const spec = record(root.spec, "spec");
exactKeys(spec, "spec", [
const specKeys = [
"hostApiRange",
"runtime",
"permissions",
"actions",
"models",
]);
if (spec.hostApiRange !== "v1alpha1") {
];
exactKeys(
spec,
"spec",
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2
? [...specKeys, "compatibilityProfiles"]
: specKeys,
);
const expectedHostApiRange =
apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 ? "v1alpha2" : "v1alpha1";
if (spec.hostApiRange !== expectedHostApiRange) {
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
}
const runtime = record(spec.runtime, "spec.runtime");
@ -129,45 +233,72 @@ export function parseDevicePluginManifest(document: unknown): DevicePluginManife
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
}
const strictIdentifiers = apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2;
const models = list(spec.models, "spec.models").map((entry, index) =>
model(entry, `spec.models[${index}]`),
model(entry, `spec.models[${index}]`, strictIdentifiers),
);
if (models.length !== 1) {
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 && models.length !== 1) {
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
}
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA2 && !models.length) {
throw new Error("Manifest v1alpha2 должен объявлять хотя бы одну модель.");
}
const version = text(metadata.version, "metadata.version");
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
}
return {
apiVersion: DEVICE_PLUGIN_API_VERSION,
const common = {
kind: "DevicePlugin",
metadata: {
id: text(metadata.id, "metadata.id"),
id: contractText(metadata.id, "metadata.id", strictIdentifiers),
version,
displayName: text(metadata.displayName, "metadata.displayName"),
},
spec: {
hostApiRange: "v1alpha1",
runtime: {
backendEntrypoint: text(
runtime.backendEntrypoint,
"spec.runtime.backendEntrypoint",
256,
),
isolation,
},
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
text(entry, `spec.permissions[${index}]`),
} as const;
const commonSpec = {
runtime: {
backendEntrypoint: text(
runtime.backendEntrypoint,
"spec.runtime.backendEntrypoint",
256,
),
actions: list(spec.actions, "spec.actions").map((entry, index) =>
action(entry, `spec.actions[${index}]`),
),
models,
isolation,
},
};
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
contractText(entry, `spec.permissions[${index}]`, strictIdentifiers),
),
actions: list(spec.actions, "spec.actions").map((entry, index) =>
action(entry, `spec.actions[${index}]`, strictIdentifiers),
),
models,
} as const;
if (apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1) {
return {
...common,
apiVersion,
spec: {
...commonSpec,
hostApiRange: "v1alpha1",
},
} satisfies DevicePluginManifestV1Alpha1;
}
const profiles = list(spec.compatibilityProfiles, "spec.compatibilityProfiles").map(
(entry, index) => compatibilityProfile(entry, `spec.compatibilityProfiles[${index}]`),
);
validateV1Alpha2Profiles(models, profiles);
return {
...common,
apiVersion,
spec: {
...commonSpec,
hostApiRange: "v1alpha2",
compatibilityProfiles: profiles,
},
} satisfies DevicePluginManifestV1Alpha2;
}
export function requirePluginAction(

View File

@ -1,6 +1,8 @@
import {
DEVICE_PLUGIN_API_VERSION,
DEVICE_PLUGIN_API_VERSION_V1ALPHA1,
DEVICE_STATE_READ_ACTION_ID,
SUPPORTED_DEVICE_PLUGIN_API_VERSIONS,
isDevicePluginManifestV1Alpha2,
type DeviceUiPlugin,
type RegisteredDeviceModel,
} from "./contracts";
@ -20,7 +22,7 @@ export function createDevicePluginRegistry(
for (const plugin of installedPlugins) {
const { manifest } = plugin;
if (manifest.apiVersion !== DEVICE_PLUGIN_API_VERSION) {
if (!SUPPORTED_DEVICE_PLUGIN_API_VERSIONS.includes(manifest.apiVersion)) {
throw new Error(
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
);
@ -35,11 +37,17 @@ export function createDevicePluginRegistry(
}
pluginIds.add(manifest.metadata.id);
if (manifest.spec.models.length !== 1) {
if (
manifest.apiVersion === DEVICE_PLUGIN_API_VERSION_V1ALPHA1 &&
manifest.spec.models.length !== 1
) {
throw new Error(
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
);
}
if (!manifest.spec.models.length) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет ни одной модели.`);
}
const permissions = new Set(manifest.spec.permissions);
if (permissions.size !== manifest.spec.permissions.length) {
@ -84,6 +92,37 @@ export function createDevicePluginRegistry(
}
models.push({ plugin, model, ConnectionView });
}
if (isDevicePluginManifestV1Alpha2(manifest)) {
const profileIds = new Set<string>();
const profilePaths = new Set<string>();
const declaredModelIds = new Set(manifest.spec.models.map((model) => model.id));
const coveredModelIds = new Set<string>();
if (!manifest.spec.compatibilityProfiles.length) {
throw new Error(`Плагин ${manifest.metadata.id} не объявляет compatibilityProfiles.`);
}
for (const profile of manifest.spec.compatibilityProfiles) {
if (profileIds.has(profile.profileId) || profilePaths.has(profile.path)) {
throw new Error(`Плагин ${manifest.metadata.id} повторяет compatibility profile.`);
}
profileIds.add(profile.profileId);
profilePaths.add(profile.path);
if (!declaredModelIds.has(profile.modelId)) {
throw new Error(
`Профиль ${profile.profileId} ссылается на неизвестную модель ${profile.modelId}.`,
);
}
coveredModelIds.add(profile.modelId);
}
const uncovered = [...declaredModelIds].filter(
(modelId) => !coveredModelIds.has(modelId),
);
if (uncovered.length) {
throw new Error(
`Плагин ${manifest.metadata.id} не имеет разрешённого профиля для ${uncovered.join(", ")}.`,
);
}
}
}
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));

View File

@ -39,6 +39,32 @@ export interface ActiveDeviceSnapshot {
endpointLabel?: string | null;
}
export interface RuntimeDeviceSessionSnapshot {
sessionId: string;
deviceId: string;
compatibilityProfileId?: string | null;
connectivity?: string | null;
}
export interface RuntimeAcquisitionSnapshot {
acquisitionId: string;
deviceId: string;
deviceSessionId: string;
compatibilityProfileId: string;
controlMode: string;
state: string;
stateRevision: number;
operatorInstructions: readonly string[];
}
export interface RuntimeOperationSnapshot {
operationId: string;
action: string;
status: string;
stageCode?: string | null;
messageCode?: string | null;
}
export interface SpatialSourceDescriptor {
id: string;
url: string;
@ -50,6 +76,9 @@ export interface MissionRuntimeState {
phase: RuntimePhase;
message?: string | null;
activeDevice?: ActiveDeviceSnapshot | null;
deviceSession?: RuntimeDeviceSessionSnapshot | null;
acquisition?: RuntimeAcquisitionSnapshot | null;
operations?: readonly RuntimeOperationSnapshot[];
spatialSource?: SpatialSourceDescriptor | null;
viewerSettings?: ViewerSettings | null;
sourceMode: SourceMode;

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Button,
Checker,
@ -12,7 +12,14 @@ import {
import type { DevicePluginConnectionProps } from "../../core/device-plugins/contracts";
import { MetricCard } from "../../components/MetricCard";
import type { BleDevice } from "./api";
import type { BleDevice, CompatibilityAttestation } from "./api";
import {
isConfirmedLiveState,
isSourceRuntimeBusy,
provisioningIntentKey,
recoverableAcquisition,
sourceStatusLabel,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import {
backendLabel,
@ -23,7 +30,6 @@ import {
phaseLabel,
phaseTone,
pipelineLatency,
sourceModeLabel,
} from "./presentation";
import { useXgridsK1Controller } from "./runtimeContext";
@ -34,6 +40,12 @@ const sessionItems = [
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
const EXACT_PROFILE_ATTESTATION: CompatibilityAttestation = {
firmware_version: "3.0.2",
topology: "direct-lan",
operator_confirmed: true,
};
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
@ -92,7 +104,7 @@ function DeviceRow({
<div>
<span className="device-row__name">
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
{device.likely_k1 ? <small>Кандидат по имени; профиль не подтверждён</small> : null}
</span>
<code>{device.device_id}</code>
</div>
@ -145,23 +157,30 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
clearError,
scan,
connect,
startLive,
prepareAndStartAcquisition,
startReplay,
stop,
abort,
} = console;
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [profileConfirmed, setProfileConfirmed] = useState(false);
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
const provisioningIntentRef = useRef<string | null>(null);
useEffect(() => {
if (state?.selected_device_id) {
if (state.selected_device_id !== selectedDeviceId) {
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}
setSelectedDeviceId(state.selected_device_id);
return;
}
@ -174,7 +193,16 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
}
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") {
setSessionIntent(state.source_mode);
} else if (state?.acquisition?.state === "prepared") {
setSessionIntent("live");
}
}, [state?.acquisition?.state, state?.source_mode]);
const confirmedLive = isConfirmedLiveState(state);
const streamActive = confirmedLive || state?.source_mode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
@ -183,9 +211,44 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
const sourceLabel = sourceModeLabel(state?.source_mode);
const canConnect =
powerConfirmed &&
profileConfirmed &&
selectedDeviceId.length > 0 &&
credentialsReady &&
!isBusy;
const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
const sessionLocked = sourceRuntimeBusy || activeAcquisition !== null;
const effectiveSessionIntent: SessionIntent =
state?.source_mode === "live" || state?.source_mode === "replay"
? state.source_mode
: activeAcquisition
? "live"
: sessionIntent;
const liveTargetReady = Boolean(
state?.k1_ip || liveHost.trim() || preparedAcquisition?.target_host,
);
const sourceLabel = sourceStatusLabel(state);
const relevantAcquisitionFailed =
state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
const sourceTone: StatusTone =
state?.phase === "error" || relevantAcquisitionFailed
? "danger"
: confirmedLive || state?.source_mode === "replay"
? "success"
: sourceRuntimeBusy || preparedAcquisition
? "warning"
: "neutral";
const connectionPhaseLabel =
sourceRuntimeBusy || preparedAcquisition ? sourceLabel : phaseLabel(state?.phase);
const connectionPhaseTone =
sourceRuntimeBusy || preparedAcquisition ? sourceTone : phaseTone(state?.phase);
const selectableSessionItems = useMemo(
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
[sessionLocked],
);
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
@ -194,17 +257,28 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
const submitConnect = async () => {
if (!canConnect) return;
const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
provisioningIntentRef.current = idempotencyKey;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
idempotency_key: idempotencyKey,
});
if (succeeded) setPassword("");
if (succeeded) {
provisioningIntentRef.current = null;
setPassword("");
}
};
const submitLive = async () => {
if (!profileConfirmed || sourceRuntimeBusy) return;
const targetHost = liveHost.trim();
const started = await startLive(targetHost ? { host: targetHost } : {});
const started = await prepareAndStartAcquisition({
...(targetHost ? { host: targetHost } : {}),
compatibility_attestation: EXACT_PROFILE_ATTESTATION,
});
if (started) host.openSpatialScene();
};
@ -229,7 +303,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
</div>
<div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
Повторить
Обновить состояние
</Button>
<Button size="compact" variant="ghost" onClick={clearError}>
Закрыть
@ -248,7 +322,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
</div>
</section>
@ -286,7 +360,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</span>
<h2>Подключите устройство к сети</h2>
</div>
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
</header>
<div className="wizard-list">
@ -303,7 +377,13 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
<Checker
checked={powerConfirmed}
label="Устройство включено, индикатор стабилен"
onChange={setPowerConfirmed}
onChange={(checked) => {
setPowerConfirmed(checked);
if (!checked) {
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}
}}
/>
</div>
</WizardStep>
@ -327,8 +407,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
}
>
<p className="step-copy">
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
профиль только подсказка; окончательный выбор всегда делает оператор.
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата
основана только на имени и не подтверждает модель или прошивку; окончательный выбор
и аттестацию всегда делает оператор.
</p>
<Button
width="full"
@ -337,6 +418,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
disabled={!powerConfirmed || isBusy}
onClick={() => {
setSelectedDeviceId("");
setProfileConfirmed(false);
provisioningIntentRef.current = null;
void scan();
}}
>
@ -351,7 +434,11 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => setSelectedDeviceId(device.device_id)}
onSelect={() => {
setSelectedDeviceId(device.device_id);
setProfileConfirmed(false);
provisioningIntentRef.current = null;
}}
/>
))
) : (
@ -370,11 +457,28 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<div className="nodedc-field">
<span className="nodedc-field__description">
Mission Core не определяет прошивку автоматически. Сверьте её на устройстве
или в официальном приложении и подтвердите только точное соответствие профилю.
</span>
<Checker
checked={profileConfirmed}
label="Я вручную подтвердил FW 3.0.2 и прямое подключение в локальной сети"
onChange={(checked) => {
setProfileConfirmed(checked);
provisioningIntentRef.current = null;
}}
/>
</div>
<TextField
label="Название сети WiFi"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
onChange={(event) => {
setSsid(event.target.value);
provisioningIntentRef.current = null;
}}
autoComplete="off"
spellCheck={false}
placeholder="Сеть локального контура"
@ -384,7 +488,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
hint="Только в оперативной памяти"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onChange={(event) => {
setPassword(event.target.value);
provisioningIntentRef.current = null;
}}
autoComplete="off"
placeholder="Введите пароль"
/>
@ -415,23 +522,25 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
<header className="panel-heading">
<div>
<span className="section-eyebrow">
{sessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
{effectiveSessionIntent === "live" ? "ШАГИ 0405 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
</span>
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
<h2>{effectiveSessionIntent === "live" ? "Подготовьте приём" : "Повторите запись"}</h2>
</div>
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
<StatusBadge tone={sourceTone}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Источник данных"
value={sessionIntent}
items={sessionItems}
onChange={setSessionIntent}
value={effectiveSessionIntent}
items={selectableSessionItems}
onChange={(intent) => {
if (!sessionLocked) setSessionIntent(intent);
}}
/>
{sessionIntent === "live" ? (
{effectiveSessionIntent === "live" ? (
<div className="session-form">
<TextField
label="Адрес устройства"
@ -439,20 +548,36 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || "Сначала подключите устройство к WiFi"}
placeholder={
state?.k1_ip ||
preparedAcquisition?.target_host ||
"Сначала подключите устройство к WiFi"
}
/>
<Button
variant="primary"
icon={<Icon name="activity" />}
disabled={isBusy || !liveTargetReady}
disabled={
isBusy ||
!profileConfirmed ||
!liveTargetReady ||
sourceRuntimeBusy ||
(activeAcquisition !== null && preparedAcquisition === null)
}
onClick={() => void submitLive()}
>
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
{pendingAction === "live"
? "Подготавливаем приём…"
: preparedAcquisition
? "Продолжить подготовленный приём"
: "Подготовить приём данных"}
</Button>
<p className="live-instruction">
{liveTargetReady
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
: "Сначала подключите устройство к WiFi или укажите его локальный адрес."}
{!profileConfirmed
? "Сначала вручную подтвердите точную прошивку 3.0.2 и direct-LAN топологию. Интерфейс не аттестует устройство автоматически."
: liveTargetReady
? "Система подготовит локальный приёмник и перейдёт в ожидание. Затем физически запустите сканирование двойным нажатием кнопки устройства. Программная команда запуска на K1 пока не отправляется; поток подтверждается только реальными кадрами."
: "Сначала подключите устройство к WiFi или укажите его локальный адрес."}
</p>
</div>
) : (
@ -485,7 +610,7 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
<Button
variant="primary"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
disabled={isBusy || sessionLocked || replayPath.trim().length === 0}
onClick={() => void submitReplay()}
>
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
@ -494,14 +619,41 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
)}
<div className="session-footer">
<p>Статус изменится только после ответа локального сервиса.</p>
<p>
{state?.source_mode === "replay"
? "Остановка завершит фактически запущенный повтор записи."
: activeAcquisition || state?.source_mode === "live"
? "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
: "Активного источника сейчас нет."}
</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
disabled={isBusy || (!sourceRuntimeBusy && activeAcquisition === null)}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
{pendingAction === "stop"
? state?.source_mode === "replay"
? "Останавливаем повтор…"
: "Останавливаем локальный приём…"
: state?.source_mode === "replay"
? "Остановить повтор"
: preparedAcquisition
? "Завершить подготовленный приём"
: "Остановить локальный приём"}
</Button>
{activeAcquisition ? (
<Button
variant="ghost"
disabled={isBusy}
onClick={() => void abort()}
>
{pendingAction === "abort"
? "Прерываем локальную операцию…"
: preparedAcquisition
? "Отменить подготовку"
: "Аварийно завершить локальный приём"}
</Button>
) : null}
</div>
</GlassSurface>

View File

@ -14,6 +14,101 @@ export interface BleDevice {
export type SourceMode = "idle" | "live" | "replay";
export type AcquisitionState =
| "preparing"
| "prepared"
| "awaiting_external_start"
| "starting"
| "acquiring"
| "awaiting_external_stop"
| "stopping"
| "finalizing"
| "completed"
| "failed"
| "aborted"
| "interrupted";
export type OperationStatus =
| "accepted"
| "running"
| "operator_action_required"
| "succeeded"
| "failed"
| "cancelled"
| "timed_out"
| "interrupted";
export interface XgridsDeviceRef {
device_id: string;
model_id: string;
identity_stability: "stable" | "provisional";
identity_basis:
| "hardware-identifier"
| "plugin-derived"
| "operator-assigned"
| "transport-local";
transport_alias?: string | null;
}
export interface XgridsDeviceSession {
device_session_id: string;
device_id: string;
opened_at?: string | null;
compatibility_profile_id?: string | null;
connectivity?: "unknown" | "offline" | "connecting" | "connected" | "degraded";
}
export interface XgridsCompatibilityState {
profile_id?: string | null;
decision?: "compatible" | "limited" | "unknown" | "incompatible";
permitted_mode?: "blocked" | "evidence-only" | "read-only" | "active-control";
firmware_claim?: string | null;
vendor_writes_enabled?: boolean;
camera_preview?: string | null;
}
export interface XgridsAcquisition {
schema_version?: string;
acquisition_id: string;
device_id: string;
device_session_id: string;
compatibility_profile_id: string;
control_mode: "operator-manual" | "plugin-commanded" | "observe-only";
requested_streams: string[];
target_host: string;
duration_seconds: number;
evidence_policy: "required" | "best-effort" | "disabled";
state: AcquisitionState;
state_revision: number;
created_at?: string | null;
updated_at?: string | null;
message_code?: string | null;
operator_instructions?: string[];
result?: Record<string, unknown> | null;
}
export interface XgridsOperation {
schema_version?: string;
operation_id: string;
action: string;
status: OperationStatus;
accepted_at?: string | null;
completed_at?: string | null;
deadline_at?: string | null;
device_id?: string | null;
device_session_id?: string | null;
idempotency_key?: string | null;
stage_code?: string | null;
message_code?: string | null;
sequence?: number;
state_revision?: number;
cancellable?: boolean;
cancel_requested?: boolean;
result?: Record<string, unknown> | null;
error?: Record<string, unknown> | null;
evidence_refs?: string[];
}
export interface XgridsK1Metrics {
mqtt_to_decode_ms?: number | null;
decode_ms?: number | null;
@ -28,6 +123,7 @@ export interface XgridsK1Metrics {
}
export interface XgridsK1State {
contract_version?: string | null;
phase?: string | null;
message?: string | null;
devices?: BleDevice[];
@ -39,6 +135,12 @@ export interface XgridsK1State {
viewer_settings?: ViewerSettings | null;
source_mode?: SourceMode | null;
metrics?: XgridsK1Metrics;
compatibility?: XgridsCompatibilityState | null;
device_ref?: XgridsDeviceRef | null;
device_session?: XgridsDeviceSession | null;
acquisition?: XgridsAcquisition | null;
operations?: XgridsOperation[];
last_operation?: XgridsOperation | null;
}
export interface HealthResponse {
@ -52,15 +154,66 @@ export interface ScanRequest {
duration_seconds?: number;
}
export interface CompatibilityAttestation {
firmware_version: "3.0.2";
topology: "direct-lan";
operator_confirmed: true;
}
export interface ConnectRequest {
device_id: string;
ssid: string;
password: string;
compatibility_attestation: CompatibilityAttestation;
operation_id?: string;
idempotency_key?: string;
}
export interface LiveRequest {
export interface PrepareAcquisitionRequest {
host?: string;
duration_seconds?: number;
requested_streams?: RequestedStreamId[];
evidence_policy?: "required" | "best-effort" | "disabled";
compatibility_attestation: CompatibilityAttestation;
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
}
export type RequestedStreamId =
| "spatial.point-cloud.live"
| "spatial.pose.live"
| "device.status.live"
| "device.heartbeat.live";
export interface StartAcquisitionRequest {
acquisition_id: string;
expected_state_revision?: number;
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
}
export interface StopAcquisitionRequest {
acquisition_id: string;
mode: "capture-only" | "graceful";
operator_confirmed?: boolean;
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
}
export interface AbortAcquisitionRequest {
acquisition_id: string;
operation_id?: string;
idempotency_key?: string;
deadline_seconds?: number;
}
export interface CompatibilityLiveRequest {
host?: string;
duration_seconds?: number;
compatibility_attestation: CompatibilityAttestation;
}
export interface ReplayRequest {
@ -177,16 +330,32 @@ export const xgridsK1Api = {
return invokeState(xgridsK1Actions.networkProvision, body);
},
startLive(body: LiveRequest = {}): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStartLive, body);
prepareAcquisition(body: PrepareAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionPrepare, body);
},
startAcquisition(body: StartAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionStart, body);
},
stopAcquisition(body: StopAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionStop, body);
},
abortAcquisition(body: AbortAcquisitionRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.acquisitionAbort, body);
},
startReplay(body: ReplayRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStartReplay, body);
},
stopSession(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.streamStop);
startLiveCompatibility(body: CompatibilityLiveRequest): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.compatibilityStreamStartLive, body);
},
stopSessionCompatibility(): Promise<XgridsK1State> {
return invokeState(xgridsK1Actions.compatibilityStreamStop);
},
updateViewerSettings(body: ViewerSettings): Promise<XgridsK1State> {

View File

@ -0,0 +1,173 @@
import type { RuntimePhase, SourceMode as RuntimeSourceMode } from "../../core/runtime/contracts";
import type {
AcquisitionState,
XgridsAcquisition,
XgridsK1State,
XgridsOperation,
} from "./api";
const TERMINAL_ACQUISITION_STATES = new Set<AcquisitionState>([
"completed",
"failed",
"aborted",
"interrupted",
]);
const FAILED_OPERATION_STATUSES = new Set([
"failed",
"cancelled",
"timed_out",
"interrupted",
]);
export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked";
export function isTerminalAcquisitionState(
state: AcquisitionState | null | undefined,
): boolean {
return state ? TERMINAL_ACQUISITION_STATES.has(state) : false;
}
export function recoverableAcquisition(
state: XgridsK1State | null | undefined,
): XgridsAcquisition | null {
const acquisition = state?.acquisition;
return acquisition && !isTerminalAcquisitionState(acquisition.state) ? acquisition : null;
}
export function isConfirmedLiveState(state: XgridsK1State | null | undefined): boolean {
return state?.source_mode === "live" && state.acquisition?.state === "acquiring";
}
export function isSourceRuntimeBusy(state: XgridsK1State | null | undefined): boolean {
return state?.source_mode === "live" || state?.source_mode === "replay";
}
export function confirmedRuntimeSourceMode(
state: XgridsK1State | null | undefined,
): RuntimeSourceMode {
if (state?.source_mode === "replay") return "replay";
if (isConfirmedLiveState(state)) return "live";
return "idle";
}
export function effectiveAcquisition(
state: XgridsK1State | null | undefined,
): XgridsAcquisition | null {
if (state?.source_mode === "replay") return null;
return state?.acquisition ?? null;
}
export function liveStartPlan(state: XgridsK1State | null | undefined): LiveStartPlan {
if (state?.source_mode === "replay") return "blocked";
const acquisition = recoverableAcquisition(state);
if (!acquisition) return state?.source_mode === "live" ? "blocked" : "prepare";
if (acquisition.state === "prepared") return "resume-prepared";
if (
acquisition.state === "starting" ||
acquisition.state === "awaiting_external_start" ||
acquisition.state === "acquiring"
) {
return "already-running";
}
return "blocked";
}
export function normalizeRuntimePhase(
state: XgridsK1State | null | undefined,
): RuntimePhase {
const phase = state?.phase;
const acquisitionState = effectiveAcquisition(state)?.state;
if (acquisitionState === "failed" || acquisitionState === "interrupted") return "error";
if (acquisitionState === "awaiting_external_start" || acquisitionState === "starting") {
return "starting";
}
if (acquisitionState === "acquiring") return "streaming";
if (
acquisitionState === "awaiting_external_stop" ||
acquisitionState === "stopping" ||
acquisitionState === "finalizing"
) {
return "stopping";
}
if (acquisitionState === "prepared") return "connected";
if (phase === "error") return "error";
if (phase === "connected") return "connected";
if (phase === "starting_live") return "starting";
if (phase === "live") return "streaming";
if (phase === "replay") return "replaying";
if (phase === "stopping") return "stopping";
if (["scanning", "device_selected", "provisioning", "connecting"].includes(phase ?? "")) {
return "configuring";
}
return "idle";
}
export function spatialSourceId(
state: XgridsK1State | null | undefined,
sourceUrl: string,
): string | null {
if (!sourceUrl) return null;
if (state?.source_mode === "replay") return `replay:${sourceUrl}`;
return state?.acquisition?.acquisition_id ?? sourceUrl;
}
export function sourceStatusLabel(state: XgridsK1State | null | undefined): string {
if (state?.source_mode === "replay") return "Повтор записи";
if (isConfirmedLiveState(state)) return "Реальное время · данные подтверждены";
if (state?.source_mode === "live") {
if (state.acquisition?.state === "failed" || state.phase === "error") {
return "Ошибка локального приёмника";
}
return "Ожидание реальных данных";
}
if (state?.acquisition?.state === "prepared") return "Приём подготовлен";
return "Ожидание";
}
export function operationByIdempotencyKey(
state: XgridsK1State | null | undefined,
action: string,
idempotencyKey: string | null | undefined,
): XgridsOperation | null {
if (!idempotencyKey) return null;
return (
[...(state?.operations ?? [])]
.reverse()
.find(
(operation) =>
operation.action === action && operation.idempotency_key === idempotencyKey,
) ?? null
);
}
export function operationNeedsReconciliation(
operation: XgridsOperation | null | undefined,
): boolean {
if (!operation || !FAILED_OPERATION_STATUSES.has(operation.status)) return false;
return operation.error?.safe_to_retry !== true;
}
function defaultUuid(): string {
const cryptoApi = globalThis.crypto;
if (!cryptoApi) {
throw new Error("Web Crypto недоступен; безопасный идентификатор операции не создан.");
}
if (typeof cryptoApi.randomUUID === "function") return cryptoApi.randomUUID();
const bytes = new Uint8Array(16);
cryptoApi.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
.slice(6, 8)
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
export function provisioningIntentKey(
current: string | null,
createUuid: () => string = defaultUuid,
): string {
return current ?? `network-provision:${createUuid()}`;
}

View File

@ -11,9 +11,21 @@ export const xgridsK1Manifest = parseDevicePluginManifest(manifestDocument);
export const xgridsK1Actions = Object.freeze({
stateRead: requirePluginAction(xgridsK1Manifest, DEVICE_STATE_READ_ACTION_ID),
discoveryScan: requirePluginAction(xgridsK1Manifest, "discovery.scan"),
deviceInspect: requirePluginAction(xgridsK1Manifest, "device.inspect"),
sensorCatalogRead: requirePluginAction(xgridsK1Manifest, "sensor.catalog.read"),
deviceCalibrationSnapshotRead: requirePluginAction(
xgridsK1Manifest,
"calibration.device-snapshot.read",
),
networkProvision: requirePluginAction(xgridsK1Manifest, "network.provision"),
streamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
connectionVerify: requirePluginAction(xgridsK1Manifest, "connection.verify"),
acquisitionPrepare: requirePluginAction(xgridsK1Manifest, "acquisition.prepare"),
acquisitionStart: requirePluginAction(xgridsK1Manifest, "acquisition.start"),
acquisitionStop: requirePluginAction(xgridsK1Manifest, "acquisition.stop"),
acquisitionAbort: requirePluginAction(xgridsK1Manifest, "acquisition.abort"),
acquisitionStateRead: requirePluginAction(xgridsK1Manifest, "acquisition.state.read"),
compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
streamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
compatibilityStreamStop: requirePluginAction(xgridsK1Manifest, "stream.stop"),
viewerSettingsUpdate: requirePluginAction(xgridsK1Manifest, "viewer.settings.update"),
});

View File

@ -8,8 +8,13 @@ import {
import type {
MissionRuntimeController,
MissionRuntimeState,
RuntimePhase,
} from "../../core/runtime/contracts";
import {
confirmedRuntimeSourceMode,
effectiveAcquisition,
normalizeRuntimePhase,
spatialSourceId,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
import { xgridsK1Manifest } from "./manifest";
import { finiteMetric, pipelineLatency } from "./presentation";
@ -19,19 +24,6 @@ export type XgridsK1Controller = ReturnType<typeof useXgridsK1Runtime>;
const XgridsK1RuntimeContext = createContext<XgridsK1Controller | null>(null);
function normalizePhase(phase: string | null | undefined): RuntimePhase {
if (phase === "error") return "error";
if (phase === "connected") return "connected";
if (phase === "starting_live") return "starting";
if (phase === "live") return "streaming";
if (phase === "replay") return "replaying";
if (phase === "stopping") return "stopping";
if (["scanning", "device_selected", "provisioning", "connecting"].includes(phase ?? "")) {
return "configuring";
}
return "idle";
}
function normalizeState(
controller: XgridsK1Controller,
activeModel: DeviceModelDefinition,
@ -39,31 +31,67 @@ function normalizeState(
const state = controller.state;
if (!state) return null;
const metrics = state.metrics;
const hasDevice = Boolean(state.selected_device_id || state.k1_ip);
const deviceRef = state.device_ref;
const deviceSession = state.device_session;
const acquisition = effectiveAcquisition(state);
const sourceUrl = state.rerun_grpc_url?.trim() ?? "";
const resolvedSpatialSourceId =
state.source_mode === "replay"
? spatialSourceId(state, sourceUrl)
: acquisition?.acquisition_id ?? spatialSourceId(state, sourceUrl);
return {
phase: normalizePhase(state.phase),
phase: normalizeRuntimePhase(state),
message: localizeRuntimeMessage(state.message),
activeDevice: hasDevice
activeDevice: deviceRef
? {
pluginId: xgridsK1Manifest.metadata.id,
modelId: activeModel.id,
modelId: deviceRef.model_id || activeModel.id,
displayName: activeModel.displayName,
instanceId: state.selected_device_id,
instanceId: deviceRef.device_id,
endpointLabel: state.k1_ip,
}
: null,
spatialSource: sourceUrl
deviceSession: deviceSession
? {
id: "xgrids-k1-rerun-live",
sessionId: deviceSession.device_session_id,
deviceId: deviceSession.device_id,
compatibilityProfileId: deviceSession.compatibility_profile_id,
connectivity: deviceSession.connectivity,
}
: null,
acquisition: acquisition
? {
acquisitionId: acquisition.acquisition_id,
deviceId: acquisition.device_id,
deviceSessionId: acquisition.device_session_id,
compatibilityProfileId: acquisition.compatibility_profile_id,
controlMode: acquisition.control_mode,
state: acquisition.state,
stateRevision: acquisition.state_revision,
operatorInstructions: acquisition.operator_instructions ?? [],
}
: null,
operations: (state.operations ?? []).map((operation) => ({
operationId: operation.operation_id,
action: operation.action,
status: operation.status,
stageCode: operation.stage_code,
messageCode: operation.message_code,
})),
spatialSource: sourceUrl && resolvedSpatialSourceId
? {
id: resolvedSpatialSourceId,
url: sourceUrl,
label: "Локальный пространственный поток",
label:
state.source_mode === "replay"
? "Повтор пространственной записи"
: "Локальный пространственный поток",
kind: "rerun-grpc",
}
: null,
viewerSettings: state.viewer_settings,
sourceMode: state.source_mode ?? "idle",
sourceMode: confirmedRuntimeSourceMode(state),
metrics: {
latencyMs: pipelineLatency(metrics),
frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz),

View File

@ -7,14 +7,27 @@ import {
xgridsK1Api,
openEventSocket,
type ConnectRequest,
type XgridsK1State,
type EventSocketStatus,
type LiveRequest,
type PrepareAcquisitionRequest,
type ReplayRequest,
type XgridsK1State,
} from "./api";
import {
isTerminalAcquisitionState,
liveStartPlan,
operationByIdempotencyKey,
operationNeedsReconciliation,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop" | "viewer";
export type PendingAction =
| "scan"
| "connect"
| "live"
| "replay"
| "stop"
| "abort"
| "viewer";
function messageFor(error: unknown): string {
if (error instanceof ApiError) {
@ -114,13 +127,64 @@ export function useXgridsK1Runtime(enabled: boolean) {
);
const connect = useCallback(
(request: ConnectRequest) => run("connect", () => xgridsK1Api.connect(request)),
[run],
(request: ConnectRequest) =>
run("connect", async () => {
const previous = operationByIdempotencyKey(
state,
"network.provision",
request.idempotency_key,
);
if (previous?.status === "succeeded" && state) return state;
if (operationNeedsReconciliation(previous)) {
throw new ApiError(
"Предыдущая запись настроек завершилась с неопределённым результатом. Автоматический повтор заблокирован; измените параметры только после проверки устройства.",
);
}
const nextState = await xgridsK1Api.connect(request);
const operation = operationByIdempotencyKey(
nextState,
"network.provision",
request.idempotency_key,
);
if (operationNeedsReconciliation(operation)) {
throw new ApiError(
"Результат записи настроек требует ручной проверки. Повторная аппаратная запись не выполнена.",
);
}
if (operation && operation.status !== "succeeded") {
throw new ApiError("Запись настроек ещё выполняется; дождитесь обновления состояния.");
}
return nextState;
}),
[run, state],
);
const startLive = useCallback(
(request: LiveRequest = {}) => run("live", () => xgridsK1Api.startLive(request)),
[run],
const prepareAndStartAcquisition = useCallback(
(request: PrepareAcquisitionRequest) =>
run("live", async () => {
const plan = liveStartPlan(state);
if (plan === "blocked") {
throw new ApiError(
"Сначала завершите текущий приём или повтор записи.",
);
}
if (plan === "already-running" && state) return state;
const prepared =
plan === "resume-prepared" && state
? state
: await xgridsK1Api.prepareAcquisition(request);
const acquisition = prepared.acquisition;
if (!acquisition?.acquisition_id) {
throw new ApiError("Локальный сервис не вернул идентификатор подготовленного приёма.");
}
return xgridsK1Api.startAcquisition({
acquisition_id: acquisition.acquisition_id,
expected_state_revision: acquisition.state_revision,
});
}),
[run, state],
);
const startReplay = useCallback(
@ -129,10 +193,32 @@ export function useXgridsK1Runtime(enabled: boolean) {
);
const stop = useCallback(
() => run("stop", () => xgridsK1Api.stopSession()),
[run],
() =>
run("stop", () => {
const acquisition = state?.acquisition;
const acquisitionTerminal = isTerminalAcquisitionState(acquisition?.state);
if (acquisition && !acquisitionTerminal) {
return xgridsK1Api.stopAcquisition({
acquisition_id: acquisition.acquisition_id,
mode: "capture-only",
});
}
// Replay and pre-v1alpha2 sessions remain a compatibility-only path.
return xgridsK1Api.stopSessionCompatibility();
}),
[run, state?.acquisition],
);
const abort = useCallback(() => {
const acquisition = state?.acquisition;
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
return Promise.resolve(false);
}
return run("abort", () =>
xgridsK1Api.abortAcquisition({ acquisition_id: acquisition.acquisition_id }),
);
}, [run, state?.acquisition]);
const updateViewerSettings = useCallback(
(request: ViewerSettings) => run("viewer", () => xgridsK1Api.updateViewerSettings(request)),
[run],
@ -210,9 +296,10 @@ export function useXgridsK1Runtime(enabled: boolean) {
clearError: () => setError(null),
scan,
connect,
startLive,
prepareAndStartAcquisition,
startReplay,
stop,
abort,
updateViewerSettings,
};
}

View File

@ -0,0 +1,358 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let parseDevicePluginManifest;
let createDevicePluginRegistry;
let xgridsK1Manifest;
let xgridsK1Actions;
let xgridsK1Api;
let lifecycle;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ parseDevicePluginManifest } = await server.ssrLoadModule(
"/src/core/device-plugins/manifestParser.ts",
));
({ createDevicePluginRegistry } = await server.ssrLoadModule(
"/src/core/device-plugins/registry.ts",
));
({ xgridsK1Manifest, xgridsK1Actions } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/manifest.ts",
));
lifecycle = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/lifecycle.ts",
);
({ xgridsK1Api } = await server.ssrLoadModule(
"/src/device-plugins/xgrids-k1/api.ts",
));
});
after(async () => {
await server?.close();
});
function manifestDocument({
apiVersion = "missioncore.nodedc/v1alpha1",
pluginId = "test.device.plugin",
modelId = "test.device.model",
} = {}) {
const v1alpha2 = apiVersion === "missioncore.nodedc/v1alpha2";
return {
apiVersion,
kind: "DevicePlugin",
metadata: {
id: pluginId,
version: "1.0.0",
displayName: "Test device",
},
spec: {
hostApiRange: v1alpha2 ? "v1alpha2" : "v1alpha1",
runtime: {
backendEntrypoint: "test.plugin:build",
isolation: "transitional-in-process",
},
...(v1alpha2
? {
compatibilityProfiles: [
{
profileId: `${modelId}.fw-1.v1`,
path: "profiles/fw-1/profile.v1.json",
modelId,
},
],
}
: {}),
permissions: ["device.read"],
actions: [{ id: "state.read", mutating: false, secretFields: [] }],
models: [
{
id: modelId,
vendor: "Test",
displayName: "Test model",
category: "Sensor",
description: "Fixture model",
verified: true,
capabilities: [{ id: "device.read", label: "Read" }],
ui: { slot: "device.connection", componentKey: "test.connection" },
},
],
},
};
}
function uiPlugin(manifest) {
return {
manifest,
RuntimeProvider: ({ children }) => children,
connectionViews: { "test.connection": () => null },
};
}
test("parser accepts reviewed v1alpha1 and v1alpha2 shapes", () => {
const legacy = parseDevicePluginManifest(manifestDocument());
const current = parseDevicePluginManifest(
manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }),
);
assert.equal(legacy.apiVersion, "missioncore.nodedc/v1alpha1");
assert.equal(legacy.spec.hostApiRange, "v1alpha1");
assert.equal(current.apiVersion, "missioncore.nodedc/v1alpha2");
assert.equal(current.spec.hostApiRange, "v1alpha2");
assert.deepEqual(current.spec.compatibilityProfiles, [
{
profileId: "test.device.model.fw-1.v1",
path: "profiles/fw-1/profile.v1.json",
modelId: "test.device.model",
},
]);
});
test("v1alpha1 keeps the original nonblank identifier compatibility", () => {
const legacyDocument = manifestDocument({
pluginId: "legacy plugin id",
modelId: "legacy model id",
});
legacyDocument.spec.permissions = ["legacy permission"];
legacyDocument.spec.actions[0].secretFields = ["legacy secret field"];
legacyDocument.spec.models[0].capabilities = [
{ id: "legacy capability", label: "Legacy" },
];
const legacy = parseDevicePluginManifest(legacyDocument);
assert.equal(legacy.metadata.id, "legacy plugin id");
assert.equal(legacy.spec.models[0].id, "legacy model id");
assert.deepEqual(legacy.spec.permissions, ["legacy permission"]);
});
test("v1alpha2 applies strict identifiers without redefining v1alpha1", () => {
const current = manifestDocument({
apiVersion: "missioncore.nodedc/v1alpha2",
pluginId: "current plugin id",
});
assert.throws(
() => parseDevicePluginManifest(current),
/не является идентификатором/,
);
});
test("v1alpha2 parser and registry accept multiple independently profiled models", () => {
const document = manifestDocument({
apiVersion: "missioncore.nodedc/v1alpha2",
pluginId: "test.family",
modelId: "test.family.model-a",
});
document.spec.models.push({
...document.spec.models[0],
id: "test.family.model-b",
displayName: "Test model B",
});
document.spec.compatibilityProfiles.push({
profileId: "test.family.model-b.fw-1.v1",
path: "profiles/fw-1/model-b.v1.json",
modelId: "test.family.model-b",
});
const manifest = parseDevicePluginManifest(document);
const plugin = {
...uiPlugin(manifest),
connectionViews: { "test.connection": () => null },
};
const registry = createDevicePluginRegistry([plugin]);
assert.deepEqual(registry.models.map(({ model }) => model.id), [
"test.family.model-a",
"test.family.model-b",
]);
});
test("installed XGRIDS frontend manifest exposes the semantic v1alpha2 actions", () => {
assert.equal(xgridsK1Manifest.apiVersion, "missioncore.nodedc/v1alpha2");
assert.deepEqual(xgridsK1Manifest.spec.compatibilityProfiles, [
{
profileId: "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
path: "profiles/fw-3.0.2/direct-lan.v1.json",
modelId: "xgrids.lixelkity-k1",
},
]);
assert.equal(xgridsK1Actions.acquisitionPrepare, "acquisition.prepare");
assert.equal(xgridsK1Actions.acquisitionStart, "acquisition.start");
assert.equal(xgridsK1Actions.acquisitionStop, "acquisition.stop");
});
test("v1alpha2 compatibility profile is exact-key and rejects duplicated profile status", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
document.spec.compatibilityProfiles[0].status = "verified";
assert.throws(
() => parseDevicePluginManifest(document),
/неизвестные поля status/,
);
});
test("v1alpha2 compatibility profile fails closed on unsafe paths", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
document.spec.compatibilityProfiles[0].path = "../private/profile.json";
assert.throws(
() => parseDevicePluginManifest(document),
/безопасным относительным JSON-путём/,
);
});
test("v1alpha2 compatibility profile cannot reference an unknown model", () => {
const document = manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" });
document.spec.compatibilityProfiles[0].modelId = "missing.model";
assert.throws(
() => parseDevicePluginManifest(document),
/ссылается на неизвестную модель missing\.model/,
);
});
test("registry accepts v1alpha1 and v1alpha2 plugins together", () => {
const legacy = parseDevicePluginManifest(
manifestDocument({ pluginId: "test.legacy", modelId: "test.legacy.model" }),
);
const current = parseDevicePluginManifest(
manifestDocument({
apiVersion: "missioncore.nodedc/v1alpha2",
pluginId: "test.current",
modelId: "test.current.model",
}),
);
const registry = createDevicePluginRegistry([uiPlugin(legacy), uiPlugin(current)]);
assert.equal(registry.plugins.length, 2);
assert.equal(registry.models.length, 2);
assert.equal(registry.resolveModel("test.current.model")?.plugin.manifest.metadata.id, "test.current");
});
test("registry independently rejects an uncovered v1alpha2 model", () => {
const current = parseDevicePluginManifest(
manifestDocument({ apiVersion: "missioncore.nodedc/v1alpha2" }),
);
current.spec.compatibilityProfiles[0].modelId = "missing.model";
assert.throws(
() => createDevicePluginRegistry([uiPlugin(current)]),
/ссылается на неизвестную модель missing\.model/,
);
});
test("live source is confirmed only by an acquiring acquisition", () => {
const waiting = {
source_mode: "live",
phase: "live",
acquisition: { state: "awaiting_external_start" },
};
const acquiring = {
...waiting,
acquisition: { state: "acquiring" },
};
assert.equal(lifecycle.isConfirmedLiveState(waiting), false);
assert.equal(lifecycle.confirmedRuntimeSourceMode(waiting), "idle");
assert.equal(lifecycle.sourceStatusLabel(waiting), "Ожидание реальных данных");
assert.equal(lifecycle.isConfirmedLiveState(acquiring), true);
assert.equal(lifecycle.confirmedRuntimeSourceMode(acquiring), "live");
});
test("prepared acquisition resumes without another prepare and remains recoverable", () => {
const prepared = {
source_mode: "idle",
acquisition: {
acquisition_id: "acq-1",
state: "prepared",
},
};
assert.equal(lifecycle.liveStartPlan(prepared), "resume-prepared");
assert.equal(lifecycle.recoverableAcquisition(prepared)?.acquisition_id, "acq-1");
});
test("replay ignores a stale failed live acquisition", () => {
const replay = {
source_mode: "replay",
phase: "replay",
rerun_grpc_url: "rerun+http://127.0.0.1:9876/proxy",
acquisition: {
acquisition_id: "old-live-acquisition",
state: "failed",
},
};
assert.equal(lifecycle.normalizeRuntimePhase(replay), "replaying");
assert.equal(lifecycle.effectiveAcquisition(replay), null);
assert.equal(
lifecycle.spatialSourceId(replay, replay.rerun_grpc_url),
`replay:${replay.rerun_grpc_url}`,
);
});
test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
let created = 0;
const createUuid = () => {
created += 1;
return "11111111-1111-4111-8111-111111111111";
};
const first = lifecycle.provisioningIntentKey(null, createUuid);
const repeated = lifecycle.provisioningIntentKey(first, createUuid);
const failedOperation = {
status: "failed",
error: { safe_to_retry: false },
};
assert.equal(first, "network-provision:11111111-1111-4111-8111-111111111111");
assert.equal(repeated, first);
assert.equal(created, 1);
assert.equal(lifecycle.operationNeedsReconciliation(failedOperation), true);
});
test("device mutations send explicit nested compatibility attestation", async () => {
const originalFetch = globalThis.fetch;
const calls = [];
const syntheticCredential = "x".repeat(32);
globalThis.fetch = async (path, init) => {
calls.push({ path, init });
return new Response(JSON.stringify({ state: { source_mode: "idle" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const attestation = {
firmware_version: "3.0.2",
topology: "direct-lan",
operator_confirmed: true,
};
try {
await xgridsK1Api.connect({
device_id: "ble-device",
ssid: "lab-network",
password: syntheticCredential,
compatibility_attestation: attestation,
idempotency_key: "network-provision:test",
});
await xgridsK1Api.prepareAcquisition({
compatibility_attestation: attestation,
});
} finally {
globalThis.fetch = originalFetch;
}
assert.equal(calls.length, 2);
const provisioning = JSON.parse(calls[0].init.body);
const prepare = JSON.parse(calls[1].init.body);
assert.deepEqual(provisioning.input.compatibility_attestation, attestation);
assert.equal(provisioning.input.idempotency_key, "network-provision:test");
assert.deepEqual(prepare.input.compatibility_attestation, attestation);
});

View File

@ -1,6 +1,6 @@
# Technical audit
Status: live feasibility validated, 2026-07-15.
Status: live feasibility and camera-preview transport validated, 2026-07-16.
## Executive finding
@ -11,9 +11,10 @@ physical-button scan and decoded both point-cloud and pose reports.
All 1,140 captured `lio_pcl` frames decoded as raw-LZ4 protobuf blocks, yielding
4,165,862 points. All 1,215 `lio_pose` messages decoded, and the resulting
approximately 1.566 m displacement matched the controlled movement. The
remaining feasibility question is the panoramic camera branch, not the core
realtime LiDAR/pose path.
approximately 1.566 m displacement matched the controlled movement. A later
owner-operated LixelGO/iPhone capture also observed left/right RTSP/H.264 camera
preview and the remote start/stop wire mapping. The remaining camera work is a
bounded Mission Core receiver, not transport discovery.
The supplied Bible is useful as an OSINT dossier. It is not an executable plan
for the actual stand because many experiments assume a phone and LixelGO. The
@ -52,12 +53,14 @@ the local LAS alone: current LixelStudio materials describe K1 remote control
and realtime point-cloud streaming over USB/Wi-Fi. LixelStudio is Windows-only
and this does not disclose or guarantee access to the protocol from macOS. It
does, however, justify keeping realtime point cloud as the primary stream target.
No comparable official evidence proves an exportable raw panorama/camera stream.
The physical Lab 002 capture now provides stronger evidence than the prior
official-material inference: a compressed left/right preview leaves the device.
It still does not prove a full-resolution raw panorama stream.
### Still unsupported until measured
- Local `map.las` layout resembles the network stream.
- Raw or stitched panoramic camera frames leave the device.
- Full-resolution raw or pre-stitched panoramic camera frames leave the device.
- The upper 24 bits of point `rgbi` are usable packed RGB.
- MQTT application commands can be published safely without first reproducing
device/session header state and response handling.

View File

@ -15,12 +15,14 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Stage 4 artifacts/flows | GO — bounded capture, hashes and negative control |
| Stage 5 point cloud | GO — raw-LZ4 protobuf, 1,140 live frames decoded |
| Stage 5 pose | GO — 1,215 live frames decoded and motion-correlated |
| Stage 5 camera | PAUSE — no independent frame/video stream observed |
| Stage 5 camera | GO (discovery) — left/right RTSP/H.264 preview observed; runtime adapter pending |
| Stage 6 live viewer | GO — React console, Foxglove cloud/path and Mac latency metrics |
USB project copying remains optional ground truth rather than a blocker for the
now-verified network path. MQTT control publishing remains deliberately deferred
because the physical button is a known-safe start/stop mechanism.
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
start/stop mapping and RTSP camera transport. MQTT control publishing remains
deliberately deferred because complete request/save/rollback semantics are not
yet modeled and the physical button remains a known-safe fallback.
The Stage 6 alpha uses a bounded raw-first bridge: loss in the visualization
queue cannot discard MQTT evidence. Acceptance is replay of the full captured

View File

@ -131,12 +131,17 @@ orientation is exposed as `(x,y,z,w)`. Unknown tail fields are retained.
## Application control boundary
Static analysis identified the application start/stop topic as
Static analysis first identified the application start/stop topic as
`lixel/application/request/modeling`, QoS 2, with a protobuf
`ModelingRequest`. Start action is 1 and stop action is 2. A valid request also
contains a device/session/OpenAPI header and, for start, project/record/scan/mount
settings.
Lab 002 then observed both mappings in owner-operated LixelGO traffic and
correlated them with device success responses and physical start/stop of the
high-rate streams. This promotes the mapping from a static hint to descriptive
wire evidence; it does not authorize replay or publishing.
Publishing is deliberately not implemented. The physical double-click provides
a verified autonomous start/stop path and avoids inventing session headers or
changing scan settings. A future publisher requires a separate reviewed profile,
@ -156,9 +161,10 @@ Default defensive limits are applied before allocation or iteration:
Per-frame failures do not justify firmware writes or speculative recovery.
Always preserve the raw MQTT record and report the decoder error separately.
## Current camera result
## Camera result outside MQTT
No independent camera, panorama, JPEG, H.264 or RTSP topic was observed in this
run. `lio_pcl` may carry intensity or packed color information, but that does not
prove access to the two raw panoramic camera streams. Camera discovery remains a
separate evidence gate.
No camera payload was observed in the report-topic MQTT run documented above.
The later LixelGO/iPhone experiment found the preview on a separate transport:
left/right RTSP sessions on TCP `8554` with interleaved RTP/TCP and H.264. This
proves compressed preview availability, not full-resolution raw frames or
camera calibration. See Lab 002.

View File

@ -1,42 +1,193 @@
# NODEDC MISSION CORE monorepo
This document maps the renamed repository to the target Mission Core
architecture without claiming that unfinished package boundaries already
exist.
This document maps the current repository to the target Mission Core
architecture without claiming that deferred package and process boundaries
already exist. The accepted device-lifecycle decision is recorded in
[`ADR 0004`](adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md).
## Current layout
| Path | Current responsibility | Target responsibility |
| --- | --- | --- |
| `apps/control-station/` | React shell, device workflow, spatial scene | Vendor-neutral operator application |
| `src/k1link/web/` | Local FastAPI control plane and K1 session orchestration | Generic host APIs plus plugin supervisor |
| `src/k1link/viewer/` | Replay runtime and Rerun adapter | Canonical scene sink and presentation adapter |
| `src/k1link/ble/` | K1 BLE discovery and reviewed provisioning | `plugins/xgrids-k1` |
| `src/k1link/mqtt/` | Raw-first K1 MQTT capture | `plugins/xgrids-k1` behind EvidenceStore |
| `src/k1link/protocol/` | Firmware-scoped K1 codecs | `plugins/xgrids-k1` |
| `packages/plugin-sdk/` | Contract placeholder | Versioned manifests, capabilities, lifecycle, schemas |
| `plugins/xgrids-k1/` | Extraction boundary and documentation | Independently versioned device plugin |
| `packages/plugin-sdk/` | Installable v0alpha2 Pydantic contracts and JSON Schema export | Portable host/plugin identity, lifecycle, stream and evidence boundary |
| `plugins/xgrids-k1/` | v1alpha2 manifest, exact compatibility profile, fail-closed loader and extraction documentation | Independently versioned XGRIDS device plugin |
| `src/k1link/web/` | Local FastAPI host, in-memory operation/acquisition lifecycle and transitional XGRIDS facade | Generic host APIs plus isolated plugin supervisor |
| `src/k1link/mqtt/` | Raw-first K1 MQTT capture | Plugin-owned transport behind an EvidenceStore |
| `src/k1link/protocol/` | Firmware-scoped K1 codecs and explicit vendor normalizer | Plugin-owned protocol adapter |
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
| `src/k1link/viewer/` | Rerun consumer, visualization runtime and legacy Foxglove regression module | Replaceable canonical scene sink and presentation adapters |
| `docs/domain-model/` | Versioned experimental Mission Core vocabulary | Promotion source for meanings proven across devices/services |
## Extraction order
## Implemented boundary
1. Define canonical PointCloud, Pose, DeviceStatus, RawEnvelope, and metrics
contracts in the Plugin SDK.
2. Make Rerun consume canonical frames rather than K1 MQTT topics.
3. Extract codecs and replay parity fixtures.
4. Extract raw capture with fault isolation between evidence and preview.
5. Extract BLE/Wi-Fi provisioning and MQTT into a least-privilege edge worker.
6. Replace K1-specific host routes and state with plugin/device-session APIs.
7. Drive the connection wizard from plugin capabilities and schemas.
8. Re-run native replay and the physical BLE → Wi-Fi → MQTT → scene acceptance.
The current K1 live/replay data path is:
```text
raw K1 transport
-> raw-first evidence capture
-> bounded preview queue
-> XGRIDS normalizer injected by the plugin facade
-> DecodedPointCloudView / DecodedPoseView
-> Rerun bridge
-> embedded local viewer
```
Rerun does not import K1 protocol code, inspect MQTT topics or receive raw
payloads. `VisualizationRuntime` cannot choose a vendor decoder implicitly; its
composition owner injects one. The local `Decoded*View` classes are in-process
consumer projections, not the portable Plugin SDK wire envelopes.
The Plugin SDK v0alpha2 separately defines model/device/session identity,
operation events, acquisition-related session state, canonical stream
envelopes, payload handles, evidence lineage and compatibility assessments. It
is importable through the root editable path dependency and independently
buildable from `packages/plugin-sdk`. The hot K1 path does not yet instantiate
all SDK envelopes or use the SDK `EvidenceStore` protocol.
## Experimental vocabulary, not Platform Ontology
The local vocabulary distinguishes:
```text
model != device != transport alias != device session
acquisition != operation != source/channel != evidence
acknowledgement != completion
raw evidence != decoded data != viewer state
```
These meanings remain versioned under `docs/domain-model/` and do not mutate or
create a runtime dependency on NODE.DC Platform Ontology. Promotion requires a
second device family or another platform service using the same stable meaning,
an owner, lifecycle, compatibility policy and migration tests.
## Plugin manifest and compatibility
The catalog accepts existing v1alpha1 manifests and additive v1alpha2 manifests.
v1alpha1 remains a one-model contract; v1alpha2 accepts one or more models and
requires every model to be covered by a reviewed, plugin-local, path-confined
compatibility profile. The current XGRIDS profile matches exactly:
- XGRIDS LixelKity K1;
- firmware `3.0.2`;
- direct-LAN topology;
- one retained physical laboratory evidence scope.
Unknown or unattested firmware fails closed. The current runtime does not read
firmware from the device: it activates this limited, read-only profile only
after explicit operator attestation of firmware `3.0.2` and direct-LAN topology,
and records the basis as `operator-attested`. That claim is not device-derived
proof. The profile independently records observation, decode, replay,
physical-verification and write evidence. Loading it cannot authorize a
transport mutation. Application-command publishing remains disabled; the
separately reviewed BLE Wi-Fi provisioning write retains its own explicit
operator gate.
## Semantic lifecycle
The transitional facade now creates separate provisional device,
device-session, acquisition and operation IDs. Acquisition can be prepared,
wait for receiver readiness, then wait for an external physical start, and become acquiring only after real point
data, wait for an externally confirmed stop, finalize and complete. Capture-only
stop reports the K1 physical state as unknown.
The operation journal is bounded and in memory. It records IDs, idempotency,
declared deadlines, progress and terminal results without action parameters or secrets.
It is not durable, distributed or recoverable after process restart.
Semantic acquisition actions coexist with legacy `stream.*` actions for
v1alpha1/UI compatibility. On this profile, start and stop operate the local
receiver and operator workflow only. K1 scanning is still started and stopped
by physical double-click; no modeling request is published.
Left/right camera preview transport, endpoint paths and H.264 framing are now
observed under the exact compatibility profile. The read-only runtime adapter is
not implemented yet. Device status and heartbeat remain raw observed channels
without semantic decoders. Device calibration command and sensor-to-vehicle
extrinsics are unavailable.
## Remaining extraction order
1. Preserve native replay parity and repeat the exact-profile physical
point/pose regression through the explicit normalizer boundary.
2. Map portable SDK stream/evidence contracts onto the hot path without
replacing raw evidence or changing consumer output.
3. Extract codecs, normalizer and raw capture physically behind the XGRIDS
plugin package boundary.
4. Add process isolation and a durable operation/evidence supervisor.
5. Replace compatibility routes and singleton state with multi-device session
routing.
6. Split Edge execution from the Control Station behind authenticated transport.
7. Implement the now-observed read-only RTSP/H.264 camera adapter; keep the
modeling-command publisher disabled until its separate safety gate closes.
## Invariants
- Raw evidence is durable before preview work.
- Mission Core starts when the XGRIDS manifest is absent. A formal
disabled/quarantine state for an installed plugin is a future supervisor feature.
- The plugin cannot add global navigation or arbitrary CSS.
- Vendor writes and commands must ultimately require declared capabilities and
operator gates. In v1alpha1 `permissions`, `mutating`, and `secretFields` are
validated metadata only; concrete adapters still enforce request safety.
- The visualization engine can be replaced without changing device plugins.
- Real captures and credentials remain outside Git.
- Raw evidence is persisted before preview work and is never overwritten by a
decode result.
- Mission Core starts when the XGRIDS manifest is absent. Formal installed
plugin quarantine remains a future supervisor feature.
- Core code does not branch on XGRIDS IDs, fields, topics or firmware.
- A plugin cannot add global navigation or arbitrary CSS.
- A transport alias never becomes a stable device identity merely because it
appears in a vendor header.
- Acknowledgement, first data, operator confirmation and physical completion
remain distinct evidence levels.
- Vendor writes require a declared capability, exact compatibility evidence,
host authorization and an operator gate. Manifest metadata and profiles alone
grant none of these.
- The visualization engine can be replaced without changing vendor transports
or codecs.
- Real captures, credentials, device identities, maps and router metadata remain
outside Git.
## Deferred, not implemented
- isolated/signed plugin processes and crash containment;
- durable operation journal, restart recovery, vault and RBAC;
- persistent device identity and concurrent multi-device routing;
- complete SDK-envelope/EvidenceStore hot-path integration;
- remote Edge split, authenticated WAN relay and fleet orchestration;
- automatic K1 start/stop/calibration commands;
- camera discovery, decoding, synchronization and visualization.
## Workspace-repeatable locked architecture gate
From the repository root:
```bash
uv sync --frozen --group dev
uv run pytest \
tests/test_plugin_sdk_v0alpha2_contracts.py \
tests/test_plugin_catalog.py \
tests/test_xgrids_compatibility_profile.py \
tests/test_device_lifecycle.py \
tests/test_xgrids_acquisition_lifecycle.py \
tests/test_canonical_pipeline.py \
tests/test_rerun_bridge.py \
tests/test_web_validation_security.py
uv run ruff check .
uv run mypy
uv run mypy --strict packages/plugin-sdk/python/missioncore_plugin_sdk
uv run python plugins/xgrids-k1/profile_loader.py
uv run python -m missioncore_plugin_sdk.v0alpha2 \
> /tmp/missioncore-plugin-sdk-v0alpha2.schemas.json
cd apps/control-station
npm ci
npm run test:unit
npm run typecheck
npm run build
```
These checks perform no BLE scan, network capture, router mutation or K1
application write. The next physical and communication gates are specified in
ADR 0004.
This gate is repeatable in the current workspace, not standalone reproducible.
The frontend consumes mutable sibling `file:` dependencies from
`NODEDC_DESIGN_GUIDELINE`; its lockfile does not pin the donor checkout's Git
revision or content hash. CI and portable packaging remain blocked until those
packages are published or vendored, or an immutable donor revision and content
verification are enforced. The current root Python wheel also omits the plugin
manifests/profile loader and built frontend, so it is not a deployment bundle.

View File

@ -0,0 +1,76 @@
# LixelGO / iPhone observation runbook
This runbook prepares the Mac without full Xcode and defines the first bounded,
read-only capture. The accepted decision is ADR 0005. Do not start the iPhone
experiment until the exact-profile physical regression in ADR 0004 passes.
## Current Mac baseline
- macOS 26.5.1, Apple Silicon;
- Xcode Command Line Tools 16.4 are present;
- full Xcode and `rvictl` are absent;
- built-in `tcpdump` and `uv` are present;
- Wireshark/tshark are not required for capture;
- the isolated lab uses Python 3.12 and `pymobiledevice3==9.36.0`.
## Preparation
```bash
uv sync --project plugins/xgrids-k1/lab/iphone-capture --frozen
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
pymobiledevice3 version
```
Expected version: `9.36.0`.
Connect one unlocked iPhone using a data cable, approve the Mac accessory prompt,
and tap **Trust This Computer**. Do not paste a UDID into Terminal or notes.
```bash
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
python plugins/xgrids-k1/lab/iphone-capture/preflight.py
```
## First smoke without K1
After the iPhone preflight succeeds, run a 10-second idle capture only after the
current source milestone is committed or with an explicitly recorded dirty-tree
fingerprint:
```bash
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
python plugins/xgrids-k1/lab/iphone-capture/capture.py \
--duration 10
```
The tool retains the original metadata-bearing `PCAPNG` and creates a compatible
classic `PCAP`. Validate the latter with built-in `tcpdump`:
```bash
tcpdump -nn -r sessions/iphone-k1-observation/<session>/captures/iphone-network.pcap -c 1
```
After the capture and operator timeline are both closed, seal the complete
session without rewriting the capture manifest:
```bash
uv run python plugins/xgrids-k1/lab/iphone-capture/session_integrity.py \
sessions/iphone-k1-observation/<session>
```
The derived private inventory verifies all manifest hashes and also hashes the
late operator-event artifact when it exists.
## Full baseline sequence
1. Complete the ADR 0004 physical K1 regression without LixelGO.
2. Close unrelated iPhone apps and pause unrelated network activity.
3. Keep the iPhone on the K1/shared Wi-Fi while USB remains connected to Mac.
4. Start a bounded 60180 second network capture.
5. Perform one predefined LixelGO action at a recorded monotonic offset.
6. Stop and retain the raw capture; analyze only offline.
7. Record endpoints, ports, sizes, timing and correlation before interpreting a
payload as a command, image, or model frame.
The first baseline does not install the Bluetooth logging profile. BLE/HCI is a
separate operator-approved experiment.

View File

@ -0,0 +1,338 @@
# ADR 0004: Plugin SDK v0alpha2 and experimental device lifecycle
- Status: accepted
- Date: 2026-07-16
- Supersedes: the next-milestone assumptions in ADR 0003; it does not replace
the v1alpha1 compatibility boundary
## Context
ADR 0003 isolated the first concrete device integration from the Mission Core
shell, but the backend still exposed one aggregate runtime and the repository
had no executable, vendor-neutral definitions for identity, sessions,
operations, acquisitions, streams, or evidence. The verified K1 path also fed
vendor MQTT topics and decoder results directly into visualization code.
The next research stage will use an owner-controlled iPhone and LixelGO to
observe additional K1 communication. Before that experiment, Mission Core needs
stable local meanings and dependency boundaries so that discovered camera or
command mechanics do not spread vendor assumptions through the host.
The domain is still experimental. Only one K1, firmware `3.0.2`, one direct-LAN
topology, point cloud, pose, raw report capture, and operator-manual acquisition
have physical evidence. Mutating NODE.DC Platform Ontology now would promote
single-device hypotheses into platform-wide meanings before a second device or
service proves them.
## Decision
Mission Core adopts an independently installable Plugin SDK v0alpha2 and a
repository-local experimental vocabulary. It does not modify or depend on
NODE.DC Platform Ontology at this stage.
The executable package lives in:
```text
packages/plugin-sdk/
pyproject.toml
python/missioncore_plugin_sdk/v0alpha2/
```
It contains closed, deeply immutable Pydantic contracts and deterministic JSON
Schema export. Nested mappings and collections are frozen after validation;
`model_copy(update=...)` revalidates its update before returning a new contract.
The contracts cover:
- device model and device-instance identity;
- transport aliases and execution-node bindings;
- device-session state;
- operation policy, request, acknowledgement, progress, completion, failure,
cancellation and timeout;
- point-cloud, pose, image, encoded-video and device-status stream envelopes;
- payload and evidence handles, evidence records and raw transport records;
- compatibility assessments.
The experimental semantic definitions and promotion rules live in
`docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md`. Breaking
wire or semantic changes create a new explicit SDK module. Existing v0alpha2
documents and retained evidence are never silently reinterpreted.
### Domain distinctions
These terms are deliberately not aliases:
| Term | Meaning in this milestone |
| --- | --- |
| Model | A plugin-owned definition of a supported hardware/product model. It is not physical hardware. |
| Device | One enrolled or provisionally identified physical instance. Its identity outlives a connection when a stable basis is available. |
| Transport alias | A scoped BLE UUID, IP address, SSID, MQTT identity or USB path. It can locate a device but is not automatically a stable `device_id`. |
| Device session | One bounded association between one plugin execution agent and one device instance. Restarting or reconnecting may create a new session. |
| Acquisition | One bounded production of requested sensor data, potentially containing multiple channels and sources. |
| Operation | One requested action with its own ID, deadline, idempotency boundary, progress and terminal outcome. An acknowledgement is not completion. |
| Source/channel | A logical producer or consumable data channel within an acquisition or replay. It is neither the physical device nor the viewer URL. |
| Evidence | Immutable raw or derived bytes plus content identity, lineage, retention and redaction metadata. Evidence is not decoded state or presentation state. |
Enrollment, connectivity and acquisition remain independent state machines. A
device can be enrolled while offline, connected while idle, or retain identity
after an acquisition failure.
### Manifest and compatibility profiles
The manifest catalog accepts both v1alpha1 and v1alpha2 device-plugin manifests.
v1alpha1 remains valid for existing plugins and does not gain profile semantics
implicitly. A v1alpha2 manifest must link every declared model to at least one
plugin-local compatibility profile through `profileId`, `modelId`, and a path
constrained to that plugin's directory. v1alpha1 remains exactly one model;
v1alpha2 can describe more than one independently profiled model.
The XGRIDS manifest is now `missioncore.nodedc/v1alpha2`, plugin version `0.2.0`,
and links exactly one profile:
```text
xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1
```
The profile is evidence-scoped to exact firmware `3.0.2`, one direct-LAN
topology, and the retained laboratory sources. Its independent flags distinguish
`observed`, `decoded`, `replay_verified`, `physical_verified`, and
`write_enabled`. Unknown or unattested firmware fails closed. The current
runtime cannot read firmware from the device; it activates the limited,
read-only profile only after explicit operator attestation of firmware `3.0.2`
and direct-LAN topology, records an `operator-attested` basis, and does not
present that claim as device-derived proof.
A profile is descriptive compatibility evidence. Loading a manifest or profile
does not grant transport permissions, authorization, or device-write authority.
The XGRIDS v1 profile loader rejects every attempted `write_enabled: true` and
is executed as a fail-closed plugin-activation gate, not only as a test helper.
The separately reviewed BLE Wi-Fi provisioning path remains an explicit legacy
operator-confirmed mutation and is not authorized by this profile.
### Semantic acquisition lifecycle
The transitional backend now separates operation metadata from acquisition
state. It assigns provisional device, device-session, acquisition and operation
identifiers and records bounded in-process lifecycle snapshots.
The current XGRIDS acquisition lifecycle is:
```text
prepare
-> prepared
-> starting (host receiver is not ready yet)
-> awaiting_external_start
-> acquiring after the first real point frame
-> awaiting_external_stop when graceful stop is requested
-> finalizing
-> completed
```
Failure, abort and interruption are distinct terminal states. `capture-only`
stop may terminate the receiver but must report the physical K1 stop as
`unknown`. Graceful stop does not claim success until the operator explicitly
confirms the physical action.
The bounded `OperationJournal` records lifecycle metadata, sequence, revision,
declared deadline, idempotency key, terminal result/error and evidence references. It is
in memory only, limited to one process and intentionally excludes action inputs
and secrets. Deadline persistence and distributed enforcement are not implied.
It is not a durable audit log, distributed command journal or recovery mechanism.
HTTP validation failures on the plugin-scoped action endpoint and deprecated
`/api/connect` endpoint return a generic, input-free `422` response. Pydantic
diagnostics and rejected request values are not reflected to the client. This
is defense in depth for the current in-memory credential path, not a secret
vault implementation.
The v1alpha2 manifest declares semantic acquisition actions alongside legacy
`stream.*` compatibility actions. `acquisition.start` and `acquisition.stop`
currently mutate host-side lifecycle and capture state only. They prompt and
observe the verified physical double-click workflow; they do not publish an
MQTT modeling request to the K1.
### Data-plane boundary
The implemented local visualization path is:
```text
K1 transport message
-> raw-first .k1mqtt evidence capture
-> bounded latest-wins preview queue
-> explicitly injected K1 vendor normalizer
-> transport-neutral DecodedPointCloudView / DecodedPoseView
-> Rerun bridge
-> local embedded Rerun Web Viewer
```
`VisualizationRuntime` has no default vendor decoder. Its composition owner must
inject a `CanonicalNormalizer`; the XGRIDS facade explicitly injects
`normalize_k1_message`. The Rerun bridge consumes only decoded local views and
contains no MQTT topics, raw payload access, protobuf/LZ4 decoder imports, or
K1 branches.
The `src/k1link/data_plane/Decoded*View` classes are allocation-conscious,
in-process consumer projections. They are intentionally not the portable
Plugin SDK `PointCloudFrame`, `PoseFrame`, or `DeviceStatusFrame` wire
contracts. The current hot path does not yet instantiate SDK stream envelopes,
nor does the existing raw capture use the SDK `EvidenceStore` protocol. A later
process boundary will map portable SDK envelopes into local consumer views.
This transitional distinction avoids both vendor/viewer coupling and the false
claim that cross-process extraction is already complete.
### Current K1 safety and evidence status
The current profile and implementation assert only the following:
- point cloud and pose are observed, bounded-decoded, replay-verified and
physically verified for the exact profile;
- device status and heartbeat are observed raw channels but have no semantic
decoder;
- physical scan start and stop use the verified K1 double-click;
- application-level K1 command publishing is disabled;
- the statically identified modeling topic and action values remain research
evidence only because headers, settings, acknowledgement, error, timeout,
rollback and save-completion contracts are unresolved;
- device calibration command and sensor-to-vehicle extrinsics are unavailable;
- camera transport, endpoint, codec, framing and independent camera channels
remain unknown. One negative MQTT observation does not prove absence.
## Compatibility with v1alpha1
This milestone is additive:
- the catalog continues to validate v1alpha1 manifests;
- the v1alpha1 `state.read` safety requirement remains;
- the established composition root, plugin-scoped dispatcher/events, inactive
provider behavior and teardown gate remain in force;
- legacy `stream.start-live`, `stream.start-replay`, `stream.stop`, and viewer
settings actions remain compatibility shims for the proven UI and runtime;
- the XGRIDS backend still runs through the reviewed
`transitional-in-process` facade in `src/k1link`.
SDK v0alpha2 contracts do not imply that every current host endpoint, frontend
state object, or compatibility shim already emits those contracts.
## Deliberately deferred
The following are not complete and must not be inferred from this ADR:
- process isolation, crash containment or signed independently deployed plugin
bundles;
- durable operation journal, restart recovery or distributed idempotency;
- secret vault implementation, RBAC, host policy enforcement or command
approval workflow;
- persistent stable device identity and multi-device/multi-session routing;
- multiple concurrent acquisitions or multiple plugin models per runtime;
- portable SDK-envelope transport in the live hot path;
- SDK `EvidenceStore` integration with the existing `.k1mqtt` recorder;
- remote Edge/Control Station split, authenticated WAN relay or fleet
orchestration;
- automatic K1 acquisition/calibration commands;
- camera discovery, decode, synchronization or visualization;
- verified sensor timestamps, clock synchronization, coordinate convention,
sensor-to-vehicle extrinsics or georeferencing.
Four contract-compatibility questions are explicitly deferred before portable
SDK envelopes may enter the live hot path:
- `ClockDomain` names UTC, device-monotonic and host-monotonic domains, while
the current header stores aware datetimes. A later revision must define a
language-neutral monotonic tick/nanosecond representation plus its measured
relationship to UTC; an aware datetime must not be used as native monotonic
time.
- `ReferencedPayload` must distinguish total referenced-object length, segment
offset and segment length. Until that invariant is fixed, a non-zero-offset
shared-memory segment cannot be assumed to satisfy `PointCloudFrame`'s exact
layout-length check.
- the coarse SDK `AcquisitionState` vocabulary and the richer transitional
facade states require an explicit, tested mapping. Matching `v1alpha2` labels
alone do not make their state machines interchangeable.
- the generic manifest catalog currently validates profile link integrity,
model coverage, path confinement and profile identity only. Evidence and
device-specific profile semantics remain the responsibility of the plugin's
fail-closed loader. A future generic profile schema must not be claimed until
that validation boundary is versioned and tested.
## Consequences
New K1 protocol discoveries must enter through the compatibility profile,
plugin-owned transport/normalizer code and retained raw evidence. They cannot be
added directly to Rerun, Mission Core navigation, generic lifecycle code, or the
platform ontology.
The repository can continue laboratory work without prematurely freezing the
platform ontology. A future Edge split can preserve SDK identities, operations,
stream envelopes and evidence lineage while replacing the in-process facade and
local consumer projection.
The cost is an explicit temporary adapter boundary: the portable SDK contracts,
the in-process lifecycle implementation and the decoded consumer views coexist
until extraction and replay parity are proven.
## Workspace-repeatable locked acceptance checks
The architecture gate is hardware-free and must pass from the repository root:
```bash
uv sync --frozen --group dev
uv run pytest \
tests/test_plugin_sdk_v0alpha2_contracts.py \
tests/test_plugin_catalog.py \
tests/test_xgrids_compatibility_profile.py \
tests/test_device_lifecycle.py \
tests/test_xgrids_acquisition_lifecycle.py \
tests/test_canonical_pipeline.py \
tests/test_rerun_bridge.py \
tests/test_web_validation_security.py
uv run ruff check .
uv run mypy
uv run mypy --strict packages/plugin-sdk/python/missioncore_plugin_sdk
uv run python plugins/xgrids-k1/profile_loader.py
uv run python -m missioncore_plugin_sdk.v0alpha2 \
> /tmp/missioncore-plugin-sdk-v0alpha2.schemas.json
```
The frontend compatibility gate remains:
```bash
cd apps/control-station
npm ci
npm run test:unit
npm run typecheck
npm run build
```
No command above scans Bluetooth, contacts the K1, changes the router, or emits
a K1 application command.
These commands are repeatable in the current workspace, not a standalone
release installation. The frontend uses sibling `file:` dependencies from
`NODEDC_DESIGN_GUIDELINE`, and `package-lock.json` does not pin that checkout's
Git revision or content hash. CI and portable packaging remain blocked until
those packages are published or vendored, or the build enforces an immutable
donor revision plus content verification. The root Python wheel does not yet
bundle plugin manifests, the profile loader, or the built frontend.
## Next evidence gate
Communication research begins only after the architecture checks pass and the
existing physical regression is repeated on the exact profile:
1. verify firmware `3.0.2` and the owner-controlled direct-LAN topology;
2. prepare one operator-manual acquisition;
3. start the K1 by physical double-click;
4. retain raw `.k1mqtt` evidence outside Git;
5. confirm at least one real point frame and pose data reach the viewer through
the injected normalizer with no vendor dependency in Rerun;
6. stop by the operator-confirmed physical workflow and record whether device
stop was confirmed or remains unknown;
7. verify that no MQTT request topic was subscribed to or published and no
application command was emitted.
Only then may a separate read-only LixelGO/iPhone communication-observation
experiment begin. Its first output is evidence: endpoints, flows, topics,
payload signatures, timing and command/response correlation. It does not enable
camera or command execution. Any state-changing publisher or new BLE write
requires its own profile revision, exact request/acknowledgement/timeout/error
contract, replay fixture, operator gate and physical acceptance.

View File

@ -0,0 +1,76 @@
# ADR 0005: Owner-controlled LixelGO/iPhone observation
- Status: accepted
- Date: 2026-07-16
- Supersedes: only the unavailable-phone assumption in ADR 0001
## Context
ADR 0001 correctly described the original stand: no phone and no LixelGO were
available, so they could not be dependencies or test oracles. An
owner-controlled iPhone with LixelGO is now available for a new, evidence-only
experiment. The previous no-phone results remain valid historical evidence.
An ordinary third client on the mesh Wi-Fi cannot normally observe unicast
traffic between the iPhone and K1. Apple's native Remote Virtual Interface path
requires `rvictl`, which is installed by full Xcode and is absent from the
current Mac. Installing full Xcode solely for the first capture is unnecessary.
## Decision
The XGRIDS plugin may use a pinned, isolated `pymobiledevice3` lab environment
to read the iPhone `pcapd` service over a trusted USB connection. This mirrors
the iPhone IP stack while the phone remains connected to K1 over Wi-Fi. It is a
lab subprocess, not a root Mission Core dependency.
The experiment is evidence-only:
- no TLS interception or MITM certificate;
- no app automation;
- no new BLE or MQTT write;
- no router, firmware, device-file, or application mutation;
- no camera or command semantic claim until bounded decode and a controlled
physical correlation are both present.
Network capture does not include BLE HCI. Bluetooth capture is a separate gate
that requires Apple's diagnostic logging profile, explicit operator approval,
and removal of that profile after the experiment.
All captures are sensitive, mode `0600`, bounded in duration, stored under
ignored `sessions/`, hashed, and represented in Git/Ops only by redacted facts.
No device ID, credential, SSID, router metadata, or raw packet payload enters
normal Git history.
## Entry gate
LixelGO/iPhone observation starts only after ADR 0004's post-refactor physical
regression succeeds on exact firmware `3.0.2` and direct-LAN:
1. operator attestation;
2. prepare the receiver;
3. physical double-click start;
4. first real point frame and pose through the new normalizer/viewer path;
5. raw evidence finalization;
6. operator-confirmed physical stop;
7. verification that Mission Core emitted no K1 application command.
## Consequences
Full Xcode, `rvictl`, Wireshark, and system/Homebrew changes are not required for
the first IP capture. TLS or proprietary encryption may remain opaque; endpoint,
port, size, timing, interface, process, and plaintext local protocol evidence are
still useful. Any discovered state-changing contract requires a new profile
revision and its own acceptance gate before implementation.
## Outcome recorded on 2026-07-16
Lab 002 completed the bounded observation. It found a plaintext local MQTT data
and control plane plus two RTSP/H.264 camera preview endpoints. Owner-operated
LixelGO start and stop were correlated with bounded protobuf action values `1`
and `2`, device success responses, physical K1 transitions and start/stop of the
high-rate streams. No LixelGO WAN flow was observed in either principal window.
This outcome updates descriptive evidence only. Mission Core still emits no
modeling request: private header/settings construction, save-complete semantics,
timeouts and rollback remain unresolved. See
`docs/lab/002_LIXELGO_IPHONE_LOCAL_PROTOCOL_20260716.redacted.md`.

View File

@ -0,0 +1,108 @@
# Mission Core experimental vocabulary v0alpha2
- Status: experimental, local to Mission Core and its Plugin SDK
- Contract API: `missioncore.nodedc/plugin-sdk/v0alpha2`
- Scope: laboratory device integration; not yet a NODE.DC platform ontology
This vocabulary fixes the meaning of the executable Plugin SDK contracts while
their domain model is still being discovered. It is intentionally not loaded by
Ontology Core. A term is promoted into the platform ontology only after its
meaning is stable outside one device plugin, its owner and lifecycle are known,
and migration from experimental captures is defined.
## Identity and lifecycle
| Term | Meaning |
|---|---|
| `DeviceModelRef` | A plugin-owned device model definition, not physical hardware. |
| `DeviceInstanceRef` | A particular physical or logically enrolled device. Its ID may be stable or explicitly provisional. |
| `TransportAlias` | An address observed in one scope, such as a BLE UUID assigned by one host or an IP address. It is not a stable identity. |
| `DeviceSessionContext` | One bounded period in which one plugin adapter on one execution node communicates with one device instance. |
| `ExecutionBinding` | The logical node and concrete agent process that owns a session. |
| `Acquisition` | One bounded production of sensor data. It may span multiple canonical channels. |
| `Operation` | One requested, auditable action with its own ID, deadline and terminal result. |
Enrollment, connectivity, and acquisition are independent state machines. A
device can be enrolled while offline; it can be connected while acquisition is
idle; and a failed acquisition does not erase device identity.
## Operations
An acknowledgement records receipt or admission of a request. It never proves
that the requested physical action completed. Completion and failure are
separate terminal events. Calibration, acquisition start, and acquisition stop
therefore require an operation ID, explicit timeout, device/profile-specific
acknowledgement evidence, progress when available, and exactly one terminal
outcome.
Secret material is not a contract value. Operations carry only
`SecretReference` objects; raw passwords, tokens, and credentials must not
enter parameters, events, logs, browser storage, manifests, fixtures, or
evidence metadata.
## Data and evidence
| Term | Meaning |
|---|---|
| `RawTransportRecord` | Immutable bytes and transport context observed before vendor decoding. |
| `CanonicalStreamEnvelope` | Vendor-neutral data produced by a decoder, with device/session/acquisition lineage. |
| `EvidenceHandle` | Content-addressed reference to immutable stored bytes. |
| `EvidenceRecord` | Metadata, retention, redaction state and derivation lineage for one handle. |
| `CompatibilityAssessment` | A point-in-time, evidence-backed decision about a device and compatibility profile. |
Decoding creates derived evidence or a canonical stream; it never overwrites
the raw observation. A viewer is a consumer of canonical data and does not own
device protocol, transport, identity, or command state.
## Mandatory non-conflation rules
1. `DeviceModelRef` is not `DeviceInstanceRef`.
2. `DeviceInstanceRef` is not `DeviceSessionContext`.
3. A host-local BLE UUID, IP address, SSID, MQTT client ID, or USB path is a
`TransportAlias`, not a stable `device_id`.
4. Enrollment state is not connectivity state; connectivity state is not
acquisition state.
5. An operation acknowledgement is not operation completion.
6. A command published to a transport is not a successful physical action.
7. K1 internal sensor calibration is not sensor-to-vehicle extrinsic
calibration and is not geodetic calibration.
8. Raw evidence is not a decoded frame; a decoded frame is not viewer state.
9. Device time is not host time unless an explicit clock relationship has been
measured and recorded.
10. A camera preview is not assumed to be an independent raw camera stream.
11. Compatibility with one firmware/profile is not compatibility with a model
family or a future firmware.
12. A plugin UI contribution is not ownership of Mission Core navigation,
scene layout, persistence, or global application state.
## Deferred compatibility work
The following meanings are intentionally unresolved in v0alpha2. They must be
defined and covered by migration tests before the portable contracts replace
the transitional live path:
1. Canonical clock representation must distinguish UTC instants from native
host/device monotonic ticks or nanoseconds and record the measured mapping
between them. An aware datetime alone is not a monotonic timestamp.
2. `ReferencedPayload` must define whether `byte_length` describes the complete
referenced object or the selected segment, and how `offset` and
`segment_length` participate in a frame's exact layout length.
3. The coarse SDK `AcquisitionState` values require an explicit mapping to the
richer transitional states such as `prepared`, `awaiting_external_start`,
`acquiring`, `awaiting_external_stop`, `finalizing`, and `completed`.
4. Generic manifest validation currently proves profile-link integrity, model
coverage, path confinement, and profile identity. Evidence flags and
device-specific semantics are validated by each plugin's fail-closed loader;
they are not yet a generic compatibility-profile schema.
## Change and promotion policy
- Additive experimental changes remain in v0alpha2 only when an older document
retains exactly the same meaning and remains valid.
- Breaking shape or semantic changes create a new versioned contract module.
- Deprecated fields and enum values remain readable until an explicit migration
exists; they are never silently renamed or reused.
- Raw captures retain their original contract/profile versions and hashes.
- Promotion to Ontology Core requires at least one additional device family or
a second platform service using the same meaning, an agreed owner, lifecycle,
compatibility policy, and migration tests.

View File

@ -0,0 +1,180 @@
# Lab 002 — LixelGO/iPhone local K1 protocol observation
- Date: 2026-07-16
- Device scope: one owner-controlled LixelKity K1, exact firmware `3.0.2`
- Application scope: owner-operated LixelGO on a trusted iPhone
- Capture path: iPhone `pcapd` over USB to the Mac
- Result: **GO for protocol-backed camera discovery and start/stop mapping;
PAUSE for Mission Core command emission and save-state semantics**
This report contains no device identifier, address, SSID, credential, project
name, room image, or raw packet payload. The sensitive source captures remain
under ignored `sessions/` with mode `0600`.
## Experiment boundary
The operator performed one clean LixelGO lifecycle and one control/stop window:
1. K1 was powered to steady-green standby without a physical scan gesture.
2. LixelGO established Bluetooth control and requested the K1 Wi-Fi network.
3. The operator accepted the iOS Wi-Fi join request.
4. LixelGO entered its scan workspace; this navigation alone caused no K1 state
transition.
5. The operator supplied a private project name and confirmed start.
6. K1 performed static initialization and exposed point cloud, pose and camera
preview.
7. The operator selected left and right camera views.
8. The operator opened the stop confirmation and then confirmed it.
9. K1 stopped the LiDAR, displayed its saving state and returned to steady-green
standby without any physical K1 button press.
Bluetooth HCI was not captured. The BLE stage is supported by the operator's
screen/physical observation, while all protocol findings below start at the
iPhone IP stack.
## Evidence integrity
The two principal network sessions contain `108,188` and `207,123` packets.
Their raw PCAPNG and compatible classic PCAP hashes match the capture manifests,
and packet timestamps/order are equivalent across the two representations.
The original manifests and captures were left byte-for-byte unchanged. A
separate private integrity inventory re-hashes the manifests, packet files and
late operator-event JSONL. Operator events record sequence, not precise action
latency: several were narrated after the corresponding wire event.
## Network and privacy result
LixelGO accounted for more than `99.5%` of bytes in each principal capture and
communicated with exactly two private local endpoints. No LixelGO flow to a
public/WAN address was observed in either window. The first window contained a
very small amount of unrelated public traffic from other iPhone processes; the
second contained no public traffic at all.
This is bounded negative evidence for these sessions, not a permanent claim
about every application mode or future LixelGO release.
Observed K1 transports:
| Transport | Purpose | Result |
| --- | --- | --- |
| MQTT 3.1.1 over plaintext TCP `1883` | control, status, point cloud, pose | observed |
| RTSP 1.0 over TCP `8554` | left/right camera session control | observed |
| interleaved RTP/TCP, H.264 PT96, 90 kHz | camera preview media | observed |
| HTTP over TCP `80` | three empty project-related setup responses | ancillary only |
No LixelGO UDP flow was observed.
## Start and static initialization
The application start transaction is now physical wire evidence rather than
static-analysis evidence:
- QoS 2 PUBLISH topic: `lixel/application/request/modeling`;
- bounded protobuf top-level action field: `1`;
- correlated device response: about `1.5 ms` after the request, with the same
action and a success result;
- first pose: about `23.1 s` after the request;
- first modeling/point-cloud reports: about `24.1 s` after the request;
- first camera RTSP PLAY: about `25.1 s` after the request.
The observed 144-byte START protobuf has this bounded top-level wire shape
(private strings remain redacted):
```text
f1/wire2 = 130-byte private header/session string
f2/wire0 = 1
f3/wire2 = 3-byte private string
f4/wire0 = 2
f5/wire0 = 1
```
`f4` is the decoded value `2`; the occasionally noted `1` describes only the
one-byte varint encoding length and is not the field value. The successful
response repeats the private 130-byte field, reports `f2=1`, and includes an
observed success block in `f15/wire2` (15 bytes). These observations describe
the capture but do not establish safe construction rules for a new request.
The 2325 second interval matches the operator-observed static initialization.
The captured request contains additional private header/settings context. Its
complete construction and validation rules remain unresolved and are not
committed as a reusable command fixture.
## Live data plane
The existing repository decoder successfully decoded distributed samples of
both live spatial channels:
- point cloud: `lixel/application/report/lio_pcl`, about `9.4 Hz`, roughly
`2,5002,650` points per sampled frame;
- pose: `lixel/application/report/lio_pose`, about `10 Hz`, finite decoded
position/orientation fields.
During active scanning the aggregate K1-to-phone data plane averaged roughly
`1.11 MB/s`, split approximately into `0.25 MB/s` MQTT and `0.86 MB/s` camera
RTSP/RTP. The observed peak was about `1.89 MB/s`.
## Camera preview
The K1 exposes two independent path-labelled RTSP preview endpoints:
```text
rtsp://{confirmed-device-private-ipv4}:8554/live/chn_left_main
rtsp://{confirmed-device-private-ipv4}:8554/live/chn_right_main
```
Each session follows `OPTIONS`, `DESCRIBE`, `SETUP`, `PLAY`; media is H.264 in
interleaved RTP over TCP. The observed preview rate was approximately `10 fps`
and `6.456.59 Mbit/s`.
Camera selection is not an MQTT command. LixelGO tears down the current RTSP
session and opens the other endpoint. In the controlled left-to-right switch,
the new RTSP connection began about `0.8 s` after the preceding stream's last
packet. RTP arrived immediately after PLAY, but the first new SPS/PPS/IDR group
arrived about `0.79 s` later. This explains most of the operator-observed visual
delay; exact tap-to-display latency was not measurable from retrospective event
markers.
The capture proves a compressed preview path, not full-resolution raw camera
frames, camera calibration, stitched panorama geometry, or optical left/right
identity beyond the endpoint labels and operator-selected views.
## Stop and post-stop boundary
The remote stop transaction is also verified inside the packet capture:
- QoS 2 PUBLISH on the same modeling-request topic;
- bounded protobuf top-level action field: `2`;
- correlated success response after about `33 ms`;
- final point-cloud PUBLISH after about `36.4 ms`;
- final pose PUBLISH after about `40.8 ms`;
- final interleaved video packet after about `54.1 ms`;
- point-cloud UNSUBSCRIBE and RTSP TEARDOWN at about `0.95 s`;
- status/modeling reports for about another `9 s`;
- control MQTT and heartbeat remained reachable for about `31 s` without a
recurrence of high-rate point, pose, or video traffic.
The raw modeling-state value changed immediately after stop. Its enum name is
not decoded. The capture does not contain an independently verified
`save-complete`/`project-closed` response or the previously observed standby
state code. Completed saving and final steady-green standby are therefore
operator-corroborated, not packet-semantically proven.
## Decision
- **GO:** retain MQTT point cloud and pose as the current live spatial plane.
- **GO:** implement a read-only left/right RTSP/H.264 preview receiver behind
the XGRIDS plugin boundary.
- **GO:** promote start=`1` and stop=`2` from static hints to observed,
physical-correlated vendor mappings.
- **PAUSE:** keep all Mission Core vendor writes disabled until request headers,
settings, acknowledgements, timeout, save completion and rollback are modeled
and regression-tested.
- **PAUSE:** do not name the post-stop enum or claim durable project persistence
from this capture.
- **PAUSE:** BLE automation remains governed by the separately reviewed Wi-Fi
provisioning profile; no BLE/HCI inference is added here.
The next smallest implementation step is an offline/then-live read-only camera
adapter with bounded buffering, H.264 metadata, explicit left/right selection
and no acquisition command publisher.

View File

@ -1,29 +1,53 @@
# Mission Core Plugin SDK
This directory owns the versioned host/plugin contract. The v1alpha frontend
contract, validated registry, generic runtime envelope, and backend read-only
catalog are implemented in-tree, but this is not yet a separately published
SDK package.
This directory owns the versioned host/plugin contract. It is an independently
installable in-tree package; it is not yet published to a package registry.
The v1alpha1 contract currently validates one model per plugin, one reviewed
`transitional-in-process` backend entrypoint, a required safe `state.read`
action, and declared UI/action metadata. It establishes:
`missioncore_plugin_sdk.v0alpha2` is the first executable backend-neutral
contract layer. It uses closed, deeply immutable Pydantic models and exports
JSON Schema for TypeScript, Rust, or other consumers. Nested mappings and
collections are frozen after validation, and `model_copy(update=...)` is
revalidated before a new contract is returned. Import the explicit version:
- plugin manifest, version compatibility, firmware profiles, and declarative
permissions;
- discovery candidates and opaque device references;
- provisioning requests using secret references;
- device-session lifecycle and health;
- EvidenceStore handles and raw-artifact lineage;
- canonical PointCloud, Pose, DeviceStatus, and metrics envelopes;
- SceneSink and event interfaces;
- capability-driven UI contribution data without plugin-owned layout.
```python
from missioncore_plugin_sdk import v0alpha2 as sdk
The XGRIDS K1 extraction is the first real-device acceptance path. Synthetic
multi-plugin composition tests verify that backend routing has no K1 identity or
protocol assumption; static boundary tests enforce the same rule in the frontend.
request = sdk.OperationRequest.model_validate(document)
schemas = sdk.contract_json_schemas()
```
Current implementation references:
For an isolated editable install:
```bash
uv pip install -e packages/plugin-sdk
python -m missioncore_plugin_sdk.v0alpha2 > plugin-sdk-v0alpha2.schema-bundle.json
```
## Contract generations
The host accepts the original one-model v1alpha1 manifest and the additive
v1alpha2 manifest. v1alpha2 permits one or more models, requires plugin-local
compatibility-profile coverage for every model, and retains one reviewed
`transitional-in-process` backend entrypoint plus a safe `state.read` action.
The separate SDK v0alpha2 package establishes executable contracts for:
- stable and provisional device identities, transport aliases, and execution
node bindings;
- independently revisioned enrollment, connectivity, and acquisition states;
- operation policy, request, acknowledgement, progress, completion, failure,
timeout, cancellation, secret reference, and idempotency boundaries;
- canonical point cloud, pose, image, encoded video, and device-status streams;
- immutable evidence handles, raw transport records, lineage, and store
protocol;
- firmware/profile compatibility assessments that fail closed before active
device control;
- deterministic JSON Schema export for non-Python consumers.
The contracts deliberately contain no MQTT topic, BLE UUID, protobuf type,
concrete viewer, or XGRIDS-specific field. Those belong to a plugin
compatibility profile and adapter.
Current host implementation references:
- `apps/control-station/src/core/device-plugins/` — TypeScript manifest and UI
contribution contracts;
@ -31,13 +55,20 @@ Current implementation references:
- `src/k1link/web/plugin_catalog.py` — strict backend manifest validation;
- `src/k1link/web/plugin_runtime.py` — host-owned allowlisted action dispatcher;
- `src/k1link/web/device_plugin_composition.py` — manifest factory loader and
startup parity check between catalog and executable adapters;
startup parity checks;
- `docs/adr/0003-device-plugin-ui-and-runtime-boundary.md` — accepted boundary
and extraction sequence.
The v1alpha lifecycle is fail-closed: inactive provider shells must perform no
The existing lifecycle is fail-closed: inactive provider shells perform no
I/O, events are scoped by plugin ID, and selection cannot change until the
active plugin confirms teardown. Process isolation and independent device
session IDs remain the next SDK milestone. `permissions`, `mutating`, and
`secretFields` are contract metadata only in v1alpha1; host authorization and
secret-vault enforcement are not implemented yet.
active plugin confirms teardown. Process isolation remains a later host
milestone. v0alpha2 models secret references and operation policy, but host
authorization and the actual secret vault remain separate implementation
responsibilities.
## Version policy
v0alpha2 may receive additive fields while it remains experimental. A breaking
wire or semantic change creates a new explicit module (for example
`v0alpha3`); recorded documents keep their original `api_version`. Published
field meanings and enum values are never silently redefined.

View File

@ -0,0 +1,15 @@
[build-system]
requires = ["hatchling>=1.27,<2"]
build-backend = "hatchling.build"
[project]
name = "missioncore-plugin-sdk"
version = "0.2.0a2"
description = "Versioned host/device-plugin contracts for NODE.DC Mission Core"
readme = "README.md"
requires-python = ">=3.12,<3.13"
license = { text = "Proprietary" }
dependencies = ["pydantic>=2.11,<3"]
[tool.hatch.build.targets.wheel]
packages = ["python/missioncore_plugin_sdk"]

View File

@ -0,0 +1,11 @@
"""Versioned Mission Core host/plugin contracts.
The package is intentionally kept independent from the Mission Core runtime and
from every concrete device plugin. Consumers must import an explicit contract
version so an experimental contract cannot silently change underneath recorded
evidence or a deployed adapter.
"""
from . import v0alpha2
__all__ = ["v0alpha2"]

View File

@ -0,0 +1,130 @@
"""Mission Core Plugin SDK v0alpha2.
This version is experimental and additive to v1alpha1. It defines the runtime
contract boundary needed to extract concrete device plugins without claiming a
stable platform ontology.
"""
from .common import API_VERSION
from .compatibility import (
CompatibilityAssessment,
CompatibilityDecision,
CompatibilityRuleResult,
PermittedMode,
RuleOutcome,
)
from .evidence import (
EvidenceHandle,
EvidenceRecord,
EvidenceRetention,
EvidenceStore,
RawTransportRecord,
RedactionState,
)
from .identity import (
DeviceInstanceRef,
DeviceModelRef,
ExecutionBinding,
ExecutionPlatform,
IdentityBasis,
IdentityStability,
TransportAlias,
)
from .operations import (
AcknowledgementDisposition,
IdempotencyMode,
OperationAcknowledgement,
OperationCompletion,
OperationError,
OperationEvent,
OperationFailure,
OperationPolicy,
OperationProgress,
OperationRequest,
OperationSafetyClass,
OperationTerminalState,
SecretReference,
validate_operation_request,
)
from .payloads import InlinePayload, PayloadHandle, ReferencedPayload
from .schema import contract_json_schemas
from .session import (
AcquisitionState,
ConnectivityState,
DeviceSessionContext,
DeviceSessionRef,
DeviceSessionSnapshot,
EnrollmentState,
)
from .streams import (
CanonicalStreamEnvelope,
CanonicalStreamHeader,
ClockDomain,
DeviceStatusFrame,
EncodedVideoPacket,
ImageFrame,
PointCloudFrame,
PointField,
PointFieldType,
PoseFrame,
Quaternion,
Vector3,
)
__all__ = [
"API_VERSION",
"AcknowledgementDisposition",
"AcquisitionState",
"CanonicalStreamEnvelope",
"CanonicalStreamHeader",
"ClockDomain",
"CompatibilityAssessment",
"CompatibilityDecision",
"CompatibilityRuleResult",
"ConnectivityState",
"DeviceInstanceRef",
"DeviceModelRef",
"DeviceSessionContext",
"DeviceSessionRef",
"DeviceSessionSnapshot",
"DeviceStatusFrame",
"EncodedVideoPacket",
"EnrollmentState",
"EvidenceHandle",
"EvidenceRecord",
"EvidenceRetention",
"EvidenceStore",
"ExecutionBinding",
"ExecutionPlatform",
"IdentityBasis",
"IdentityStability",
"IdempotencyMode",
"ImageFrame",
"InlinePayload",
"OperationAcknowledgement",
"OperationCompletion",
"OperationError",
"OperationEvent",
"OperationFailure",
"OperationPolicy",
"OperationProgress",
"OperationRequest",
"OperationSafetyClass",
"OperationTerminalState",
"PayloadHandle",
"PermittedMode",
"PointCloudFrame",
"PointField",
"PointFieldType",
"PoseFrame",
"Quaternion",
"RawTransportRecord",
"RedactionState",
"ReferencedPayload",
"RuleOutcome",
"SecretReference",
"TransportAlias",
"Vector3",
"contract_json_schemas",
"validate_operation_request",
]

View File

@ -0,0 +1,9 @@
"""Print the v0alpha2 JSON Schema bundle."""
from __future__ import annotations
import json
from .schema import contract_json_schemas
print(json.dumps(contract_json_schemas(), ensure_ascii=False, indent=2, sort_keys=True))

View File

@ -0,0 +1,131 @@
"""Shared primitives for the Mission Core Plugin SDK v0alpha2 contracts."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Annotated, Any, Literal, Never, Self
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, JsonValue, model_validator
ApiVersion = Literal["missioncore.nodedc/plugin-sdk/v0alpha2"]
API_VERSION: ApiVersion = "missioncore.nodedc/plugin-sdk/v0alpha2"
def _reject_blank(value: str) -> str:
if not value.strip():
raise ValueError("must contain a non-whitespace character")
return value
Identifier = Annotated[
str,
Field(min_length=1, max_length=192, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$"),
AfterValidator(_reject_blank),
]
ShortText = Annotated[
str,
Field(min_length=1, max_length=256),
AfterValidator(_reject_blank),
]
LongText = Annotated[
str,
Field(min_length=1, max_length=4096),
AfterValidator(_reject_blank),
]
Sha256Digest = Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")]
JsonObject = dict[str, JsonValue]
class _FrozenDict(dict[Any, Any]):
"""A JSON-serializable mapping that rejects mutation after construction."""
@staticmethod
def _immutable(*_: object, **__: object) -> Never:
raise TypeError("Mission Core Plugin SDK contracts are deeply immutable")
__setitem__ = _immutable
__delitem__ = _immutable
clear = _immutable
pop = _immutable
popitem = _immutable
setdefault = _immutable
update = _immutable
__ior__ = _immutable
def __copy__(self) -> _FrozenDict:
return self
def __deepcopy__(self, _memo: dict[int, Any]) -> _FrozenDict:
return self
class _FrozenList(list[Any]):
"""A JSON-serializable sequence that rejects mutation after construction."""
@staticmethod
def _immutable(*_: object, **__: object) -> Never:
raise TypeError("Mission Core Plugin SDK contracts are deeply immutable")
__setitem__ = _immutable
__delitem__ = _immutable
append = _immutable
clear = _immutable
extend = _immutable
insert = _immutable
pop = _immutable
remove = _immutable
reverse = _immutable
sort = _immutable
__iadd__ = _immutable
__imul__ = _immutable
def __copy__(self) -> _FrozenList:
return self
def __deepcopy__(self, _memo: dict[int, Any]) -> _FrozenList:
return self
def _deep_freeze(value: Any) -> Any:
if isinstance(value, (_FrozenDict, _FrozenList)):
return value
if isinstance(value, dict):
return _FrozenDict({key: _deep_freeze(child) for key, child in value.items()})
if isinstance(value, list):
return _FrozenList(_deep_freeze(child) for child in value)
if isinstance(value, tuple):
return tuple(_deep_freeze(child) for child in value)
if isinstance(value, (set, frozenset)):
return frozenset(_deep_freeze(child) for child in value)
return value
class ContractModel(BaseModel):
"""Closed and immutable base for every wire-safe SDK contract."""
model_config = ConfigDict(extra="forbid", frozen=True)
@model_validator(mode="after")
def contract_values_are_deeply_immutable(self) -> ContractModel:
"""Freeze mutable containers after Pydantic has validated their contents."""
for field_name in type(self).model_fields:
value = getattr(self, field_name)
frozen = _deep_freeze(value)
if frozen is not value:
object.__setattr__(self, field_name, frozen)
return self
def model_copy(
self,
*,
update: Mapping[str, Any] | None = None,
deep: bool = False,
) -> Self:
"""Revalidate updates so a copied contract cannot bypass deep freezing."""
if update:
candidate = self.model_dump(round_trip=True, exclude_unset=True)
candidate.update(update)
return type(self).model_validate(candidate)
return super().model_copy(deep=deep)

View File

@ -0,0 +1,87 @@
"""Firmware/profile compatibility decisions that fail closed for active control."""
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import AwareDatetime, Field, model_validator
from .common import API_VERSION, ApiVersion, ContractModel, Identifier, LongText, ShortText
from .identity import DeviceInstanceRef
class CompatibilityDecision(StrEnum):
COMPATIBLE = "compatible"
LIMITED = "limited"
UNKNOWN = "unknown"
INCOMPATIBLE = "incompatible"
class PermittedMode(StrEnum):
BLOCKED = "blocked"
EVIDENCE_ONLY = "evidence-only"
READ_ONLY = "read-only"
ACTIVE_CONTROL = "active-control"
class RuleOutcome(StrEnum):
PASS = "pass"
FAIL = "fail"
UNKNOWN = "unknown"
class CompatibilityRuleResult(ContractModel):
rule_id: Identifier
outcome: RuleOutcome
blocking: bool
detail: LongText
evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple)
class CompatibilityAssessment(ContractModel):
api_version: ApiVersion = API_VERSION
kind: Literal["CompatibilityAssessment"] = "CompatibilityAssessment"
assessment_id: Identifier
device: DeviceInstanceRef
evaluated_at: AwareDatetime
observed_firmware: ShortText | None = None
profile_id: Identifier | None = None
decision: CompatibilityDecision
permitted_mode: PermittedMode
rules: tuple[CompatibilityRuleResult, ...] = Field(min_length=1)
supported_capabilities: tuple[Identifier, ...] = Field(default_factory=tuple)
supported_actions: tuple[Identifier, ...] = Field(default_factory=tuple)
notes: LongText | None = None
@model_validator(mode="after")
def compatibility_decision_must_fail_closed(self) -> CompatibilityAssessment:
blocking_failures = [
rule for rule in self.rules if rule.blocking and rule.outcome is RuleOutcome.FAIL
]
unresolved_blockers = [
rule for rule in self.rules if rule.blocking and rule.outcome is RuleOutcome.UNKNOWN
]
if self.decision is CompatibilityDecision.INCOMPATIBLE and (
self.permitted_mode is not PermittedMode.BLOCKED or not blocking_failures
):
raise ValueError("incompatible requires blocked mode and a failed blocking rule")
if self.permitted_mode is PermittedMode.ACTIVE_CONTROL:
if self.decision is not CompatibilityDecision.COMPATIBLE:
raise ValueError("active control requires a compatible decision")
if self.profile_id is None:
raise ValueError("active control requires a compatibility profile")
if blocking_failures or unresolved_blockers:
raise ValueError("active control requires every blocking rule to pass")
if self.decision is CompatibilityDecision.UNKNOWN and self.permitted_mode not in {
PermittedMode.BLOCKED,
PermittedMode.EVIDENCE_ONLY,
}:
raise ValueError("unknown compatibility permits only blocked or evidence-only mode")
for label, values in (
("capabilities", self.supported_capabilities),
("actions", self.supported_actions),
):
if len(values) != len(set(values)):
raise ValueError(f"supported {label} must be unique")
return self

View File

@ -0,0 +1,108 @@
"""Immutable evidence handles, lineage and raw transport records."""
from __future__ import annotations
from enum import StrEnum
from typing import Literal, Protocol, runtime_checkable
from pydantic import AwareDatetime, Field, NonNegativeInt, model_validator
from .common import (
API_VERSION,
ApiVersion,
ContractModel,
Identifier,
JsonObject,
Sha256Digest,
ShortText,
)
from .session import DeviceSessionRef
class EvidenceRetention(StrEnum):
EPHEMERAL = "ephemeral"
EXPERIMENT = "experiment"
REGRESSION_FIXTURE = "regression-fixture"
AUDIT = "audit"
class RedactionState(StrEnum):
UNREVIEWED = "unreviewed"
REDACTED = "redacted"
CLEARED = "cleared"
RESTRICTED = "restricted"
class EvidenceHandle(ContractModel):
evidence_id: Identifier
store_id: Identifier
media_type: ShortText
byte_length: NonNegativeInt
sha256: Sha256Digest
object_key: ShortText
class EvidenceRecord(ContractModel):
api_version: ApiVersion = API_VERSION
kind: Literal["EvidenceRecord"] = "EvidenceRecord"
handle: EvidenceHandle
plugin_id: Identifier
session: DeviceSessionRef | None = None
acquisition_id: Identifier | None = None
operation_id: Identifier | None = None
created_at: AwareDatetime
source_kind: Literal[
"packet-capture",
"transport-payload",
"device-file",
"operator-note",
"derived",
]
retention: EvidenceRetention
redaction: RedactionState
parent_evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple)
metadata: JsonObject = Field(default_factory=dict)
@model_validator(mode="after")
def lineage_must_be_acyclic_at_record_boundary(self) -> EvidenceRecord:
if self.handle.evidence_id in self.parent_evidence_ids:
raise ValueError("an evidence record cannot name itself as a parent")
if len(self.parent_evidence_ids) != len(set(self.parent_evidence_ids)):
raise ValueError("parent evidence ids must be unique")
return self
class RawTransportRecord(ContractModel):
"""A raw-first observation; decoding produces derived evidence, never replacement."""
api_version: ApiVersion = API_VERSION
kind: Literal["RawTransportRecord"] = "RawTransportRecord"
record_id: Identifier
session: DeviceSessionRef
profile_id: Identifier | None = None
observed_at: AwareDatetime
transport: Identifier
direction: Literal["device-to-host", "host-to-device", "peer-to-peer", "unknown"]
channel: ShortText
sequence: NonNegativeInt | None = None
payload: EvidenceHandle
metadata: JsonObject = Field(default_factory=dict)
@runtime_checkable
class EvidenceStore(Protocol):
"""Minimal store boundary; implementations own persistence and access policy."""
def put_bytes(
self,
*,
evidence_id: str,
media_type: str,
payload: bytes,
) -> EvidenceHandle: ...
def register(self, record: EvidenceRecord) -> None: ...
def get_record(self, evidence_id: str) -> EvidenceRecord: ...
def open_bytes(self, handle: EvidenceHandle) -> bytes: ...

View File

@ -0,0 +1,74 @@
"""Stable device identity contracts.
Transport-local names (for example a CoreBluetooth UUID assigned by one Mac)
may be recorded as aliases, but cannot be asserted as a stable device identity.
"""
from __future__ import annotations
from enum import StrEnum
from pydantic import AwareDatetime, Field, model_validator
from .common import ContractModel, Identifier, ShortText
class IdentityStability(StrEnum):
STABLE = "stable"
PROVISIONAL = "provisional"
class IdentityBasis(StrEnum):
HARDWARE_IDENTIFIER = "hardware-identifier"
PLUGIN_DERIVED = "plugin-derived"
OPERATOR_ASSIGNED = "operator-assigned"
TRANSPORT_LOCAL = "transport-local"
class ExecutionPlatform(StrEnum):
MACOS = "macos"
LINUX = "linux"
WINDOWS = "windows"
UNKNOWN = "unknown"
class DeviceModelRef(ContractModel):
plugin_id: Identifier
plugin_version: ShortText
model_id: Identifier
class TransportAlias(ContractModel):
"""A non-authoritative address observed in one transport scope."""
transport: Identifier
scope_id: Identifier
value: ShortText
observed_at: AwareDatetime
class DeviceInstanceRef(ContractModel):
"""Plugin-scoped device identity, independent from a connection session."""
device_id: Identifier
model: DeviceModelRef
stability: IdentityStability
basis: IdentityBasis
aliases: tuple[TransportAlias, ...] = Field(default_factory=tuple)
@model_validator(mode="after")
def stable_identity_must_not_be_transport_local(self) -> DeviceInstanceRef:
if (
self.stability is IdentityStability.STABLE
and self.basis is IdentityBasis.TRANSPORT_LOCAL
):
raise ValueError("a transport-local identifier cannot be a stable device identity")
return self
class ExecutionBinding(ContractModel):
"""The logical node and concrete agent process hosting a device session."""
node_id: Identifier
agent_instance_id: Identifier
platform: ExecutionPlatform

View File

@ -0,0 +1,187 @@
"""Auditable device-operation requests and lifecycle events."""
from __future__ import annotations
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import (
AwareDatetime,
Field,
JsonValue,
NonNegativeInt,
PositiveFloat,
model_validator,
)
from .common import (
API_VERSION,
ApiVersion,
ContractModel,
Identifier,
JsonObject,
LongText,
ShortText,
)
from .session import DeviceSessionRef
class OperationSafetyClass(StrEnum):
READ_ONLY = "read-only"
REVERSIBLE = "reversible"
STATE_CHANGING = "state-changing"
SAFETY_CRITICAL = "safety-critical"
class IdempotencyMode(StrEnum):
IDEMPOTENT = "idempotent"
IDEMPOTENT_WITH_KEY = "idempotent-with-key"
NON_IDEMPOTENT = "non-idempotent"
class SecretReference(ContractModel):
"""Opaque secret-vault reference; secret material is never an SDK value."""
provider: Identifier
reference: ShortText
version: ShortText | None = None
class OperationPolicy(ContractModel):
action_id: Identifier
safety_class: OperationSafetyClass
idempotency: IdempotencyMode
timeout_seconds: PositiveFloat
acknowledgement_required: bool = True
retry_limit: NonNegativeInt = 0
preconditions: tuple[Identifier, ...] = Field(default_factory=tuple)
secret_fields: tuple[Identifier, ...] = Field(default_factory=tuple)
@model_validator(mode="after")
def policy_collections_must_be_unique(self) -> OperationPolicy:
for label, values in (
("preconditions", self.preconditions),
("secret fields", self.secret_fields),
):
if len(values) != len(set(values)):
raise ValueError(f"{label} must be unique")
if self.idempotency is IdempotencyMode.NON_IDEMPOTENT and self.retry_limit:
raise ValueError("non-idempotent operations cannot declare automatic retries")
return self
class OperationRequest(ContractModel):
api_version: ApiVersion = API_VERSION
kind: Literal["OperationRequest"] = "OperationRequest"
operation_id: Identifier
session: DeviceSessionRef
action_id: Identifier
requested_at: AwareDatetime
deadline_at: AwareDatetime
idempotency_key: Identifier | None = None
parameters: JsonObject = Field(default_factory=dict)
secret_refs: dict[Identifier, SecretReference] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_request_boundaries(self) -> OperationRequest:
if self.deadline_at <= self.requested_at:
raise ValueError("deadline_at must be later than requested_at")
overlap = set(self.parameters).intersection(self.secret_refs)
if overlap:
raise ValueError(f"secret fields cannot also appear in parameters: {sorted(overlap)}")
return self
class AcknowledgementDisposition(StrEnum):
ACCEPTED = "accepted"
REJECTED = "rejected"
DUPLICATE = "duplicate"
class OperationAcknowledgement(ContractModel):
"""Receipt/admission acknowledgement; never proof of operation completion."""
api_version: ApiVersion = API_VERSION
event_type: Literal["acknowledgement"] = "acknowledgement"
operation_id: Identifier
session: DeviceSessionRef
sequence: NonNegativeInt
recorded_at: AwareDatetime
disposition: AcknowledgementDisposition
device_receipt_id: Identifier | None = None
message: LongText | None = None
class OperationProgress(ContractModel):
api_version: ApiVersion = API_VERSION
event_type: Literal["progress"] = "progress"
operation_id: Identifier
session: DeviceSessionRef
sequence: NonNegativeInt
recorded_at: AwareDatetime
stage: Identifier
progress: Annotated[float, Field(ge=0.0, le=1.0)] | None = None
message: LongText | None = None
measurements: dict[str, JsonValue] = Field(default_factory=dict)
class OperationCompletion(ContractModel):
api_version: ApiVersion = API_VERSION
event_type: Literal["completion"] = "completion"
operation_id: Identifier
session: DeviceSessionRef
sequence: NonNegativeInt
recorded_at: AwareDatetime
result: JsonObject = Field(default_factory=dict)
evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple)
class OperationTerminalState(StrEnum):
FAILED = "failed"
CANCELLED = "cancelled"
TIMED_OUT = "timed-out"
class OperationError(ContractModel):
code: Identifier
message: LongText
retryable: bool
details: JsonObject = Field(default_factory=dict)
class OperationFailure(ContractModel):
api_version: ApiVersion = API_VERSION
event_type: Literal["failure"] = "failure"
operation_id: Identifier
session: DeviceSessionRef
sequence: NonNegativeInt
recorded_at: AwareDatetime
terminal_state: OperationTerminalState
error: OperationError
evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple)
OperationEvent = Annotated[
OperationAcknowledgement | OperationProgress | OperationCompletion | OperationFailure,
Field(discriminator="event_type"),
]
def validate_operation_request(policy: OperationPolicy, request: OperationRequest) -> None:
"""Validate request data that depends on its declared action policy."""
if request.action_id != policy.action_id:
raise ValueError("operation request action_id does not match its policy")
declared_secrets = set(policy.secret_fields)
supplied_secrets = set(request.secret_refs)
missing = declared_secrets - supplied_secrets
undeclared = supplied_secrets - declared_secrets
if missing:
raise ValueError(f"missing secret references: {sorted(missing)}")
if undeclared:
raise ValueError(f"undeclared secret references: {sorted(undeclared)}")
if policy.idempotency is IdempotencyMode.IDEMPOTENT_WITH_KEY:
if request.idempotency_key is None:
raise ValueError("idempotent-with-key operation requires idempotency_key")
elif request.idempotency_key is not None:
raise ValueError("idempotency_key is allowed only for idempotent-with-key operations")

View File

@ -0,0 +1,62 @@
"""Language-neutral binary payload handles used by canonical stream contracts."""
from __future__ import annotations
import base64
import binascii
import hashlib
from typing import Annotated, Literal
from pydantic import Field, NonNegativeInt, PositiveInt, model_validator
from .common import ContractModel, Identifier, Sha256Digest, ShortText
class InlinePayload(ContractModel):
kind: Literal["inline-base64"] = "inline-base64"
media_type: ShortText
byte_length: NonNegativeInt
sha256: Sha256Digest
data: str
@model_validator(mode="after")
def inline_data_must_match_descriptor(self) -> InlinePayload:
try:
decoded = base64.b64decode(self.data, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("data must be canonical base64") from exc
if len(decoded) != self.byte_length:
raise ValueError("byte_length does not match decoded inline payload")
digest = f"sha256:{hashlib.sha256(decoded).hexdigest()}"
if digest != self.sha256:
raise ValueError("sha256 does not match decoded inline payload")
if base64.b64encode(decoded).decode("ascii") != self.data:
raise ValueError("data must use canonical padded base64 encoding")
return self
class ReferencedPayload(ContractModel):
"""Opaque payload location owned by a declared transport or evidence store."""
kind: Literal["reference"] = "reference"
media_type: ShortText
byte_length: NonNegativeInt
sha256: Sha256Digest | None = None
reference_scheme: Literal["evidence", "shared-memory", "transport", "uri"]
locator: ShortText
offset: NonNegativeInt = 0
segment_length: PositiveInt | None = None
owner_id: Identifier | None = None
@model_validator(mode="after")
def segment_must_fit_known_payload(self) -> ReferencedPayload:
if self.offset > self.byte_length:
raise ValueError("offset cannot exceed byte_length")
if self.segment_length is not None and self.offset + self.segment_length > self.byte_length:
raise ValueError("referenced segment exceeds byte_length")
if self.reference_scheme in {"evidence", "shared-memory"} and self.owner_id is None:
raise ValueError("evidence and shared-memory references require owner_id")
return self
PayloadHandle = Annotated[InlinePayload | ReferencedPayload, Field(discriminator="kind")]

View File

@ -0,0 +1,30 @@
"""Deterministic JSON Schema export for non-Python SDK consumers."""
from __future__ import annotations
from pydantic import TypeAdapter
from .compatibility import CompatibilityAssessment
from .evidence import EvidenceRecord, RawTransportRecord
from .identity import DeviceInstanceRef
from .operations import OperationEvent, OperationPolicy, OperationRequest
from .session import DeviceSessionContext, DeviceSessionSnapshot
from .streams import CanonicalStreamEnvelope
def contract_json_schemas() -> dict[str, dict[str, object]]:
"""Return named schemas; callers may persist or serve the resulting bundle."""
contracts: dict[str, object] = {
"DeviceInstanceRef": DeviceInstanceRef,
"DeviceSessionContext": DeviceSessionContext,
"DeviceSessionSnapshot": DeviceSessionSnapshot,
"OperationPolicy": OperationPolicy,
"OperationRequest": OperationRequest,
"OperationEvent": OperationEvent,
"CanonicalStreamEnvelope": CanonicalStreamEnvelope,
"EvidenceRecord": EvidenceRecord,
"RawTransportRecord": RawTransportRecord,
"CompatibilityAssessment": CompatibilityAssessment,
}
return {name: TypeAdapter(contract).json_schema() for name, contract in contracts.items()}

View File

@ -0,0 +1,70 @@
"""Independent device-session lifecycle contracts."""
from __future__ import annotations
from enum import StrEnum
from pydantic import AwareDatetime, Field, NonNegativeInt, model_validator
from .common import ContractModel, Identifier, LongText
from .identity import DeviceInstanceRef, ExecutionBinding
class EnrollmentState(StrEnum):
EMPTY = "empty"
SELECTING_MODEL = "selecting-model"
SETUP_IN_PROGRESS = "setup-in-progress"
ENROLLED = "enrolled"
FAILED = "failed"
class ConnectivityState(StrEnum):
UNKNOWN = "unknown"
OFFLINE = "offline"
CONNECTING = "connecting"
CONNECTED = "connected"
DEGRADED = "degraded"
class AcquisitionState(StrEnum):
IDLE = "idle"
PREPARING = "preparing"
STARTING = "starting"
STREAMING = "streaming"
STOPPING = "stopping"
FAILED = "failed"
class DeviceSessionContext(ContractModel):
"""Immutable identity and binding established when a plugin opens a session."""
session_id: Identifier
device: DeviceInstanceRef
execution: ExecutionBinding
compatibility_profile_id: Identifier | None = None
compatibility_assessment_id: Identifier | None = None
opened_at: AwareDatetime
class DeviceSessionRef(ContractModel):
session_id: Identifier
device_id: Identifier
class DeviceSessionSnapshot(ContractModel):
"""Revisioned state; enrollment, connectivity and acquisition never alias."""
context: DeviceSessionContext
revision: NonNegativeInt
enrollment: EnrollmentState
connectivity: ConnectivityState
acquisition: AcquisitionState
observed_at: AwareDatetime
message: LongText | None = None
active_operation_ids: tuple[Identifier, ...] = Field(default_factory=tuple)
@model_validator(mode="after")
def operation_ids_must_be_unique(self) -> DeviceSessionSnapshot:
if len(self.active_operation_ids) != len(set(self.active_operation_ids)):
raise ValueError("active operation ids must be unique")
return self

View File

@ -0,0 +1,174 @@
"""Vendor-neutral canonical stream envelopes."""
from __future__ import annotations
import math
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import AwareDatetime, Field, NonNegativeInt, PositiveInt, model_validator
from .common import API_VERSION, ApiVersion, ContractModel, Identifier, JsonObject, ShortText
from .payloads import PayloadHandle
from .session import DeviceSessionRef
class ClockDomain(StrEnum):
UTC = "utc"
DEVICE_MONOTONIC = "device-monotonic"
HOST_MONOTONIC = "host-monotonic"
UNKNOWN = "unknown"
class CanonicalStreamHeader(ContractModel):
api_version: ApiVersion = API_VERSION
message_id: Identifier
session: DeviceSessionRef
acquisition_id: Identifier | None = None
source_id: Identifier
channel_id: Identifier
sequence: NonNegativeInt
captured_at: AwareDatetime
observed_at: AwareDatetime
clock_domain: ClockDomain
compatibility_profile_id: Identifier | None = None
class PointFieldType(StrEnum):
INT8 = "int8"
UINT8 = "uint8"
INT16 = "int16"
UINT16 = "uint16"
INT32 = "int32"
UINT32 = "uint32"
FLOAT32 = "float32"
FLOAT64 = "float64"
_POINT_FIELD_BYTES = {
PointFieldType.INT8: 1,
PointFieldType.UINT8: 1,
PointFieldType.INT16: 2,
PointFieldType.UINT16: 2,
PointFieldType.INT32: 4,
PointFieldType.UINT32: 4,
PointFieldType.FLOAT32: 4,
PointFieldType.FLOAT64: 8,
}
class PointField(ContractModel):
name: Identifier
offset: NonNegativeInt
data_type: PointFieldType
count: PositiveInt = 1
class PointCloudFrame(ContractModel):
kind: Literal["point-cloud"] = "point-cloud"
header: CanonicalStreamHeader
coordinate_frame: Identifier
width: PositiveInt
height: PositiveInt = 1
point_count: PositiveInt
point_step: PositiveInt
row_step: PositiveInt
little_endian: bool = True
dense: bool
fields: tuple[PointField, ...] = Field(min_length=3)
payload: PayloadHandle
@model_validator(mode="after")
def point_layout_must_be_self_consistent(self) -> PointCloudFrame:
if self.point_count != self.width * self.height:
raise ValueError("point_count must equal width * height")
if self.row_step != self.point_step * self.width:
raise ValueError("row_step must equal point_step * width")
if self.payload.byte_length != self.row_step * self.height:
raise ValueError("payload byte_length must match the declared point layout")
names = [field.name for field in self.fields]
if len(names) != len(set(names)):
raise ValueError("point field names must be unique")
if not {"x", "y", "z"}.issubset(names):
raise ValueError("canonical point clouds require x, y, and z fields")
for field in self.fields:
field_end = field.offset + _POINT_FIELD_BYTES[field.data_type] * field.count
if field_end > self.point_step:
raise ValueError(f"point field {field.name} exceeds point_step")
return self
class Vector3(ContractModel):
x: float
y: float
z: float
class Quaternion(ContractModel):
x: float
y: float
z: float
w: float
@model_validator(mode="after")
def quaternion_must_be_normalized(self) -> Quaternion:
norm = math.sqrt(self.x**2 + self.y**2 + self.z**2 + self.w**2)
if not math.isclose(norm, 1.0, rel_tol=1e-3, abs_tol=1e-3):
raise ValueError("quaternion must be normalized")
return self
class PoseFrame(ContractModel):
kind: Literal["pose"] = "pose"
header: CanonicalStreamHeader
parent_frame: Identifier
child_frame: Identifier
translation_m: Vector3
rotation: Quaternion
covariance: tuple[float, ...] | None = None
@model_validator(mode="after")
def covariance_must_be_six_by_six(self) -> PoseFrame:
if self.covariance is not None and len(self.covariance) != 36:
raise ValueError("pose covariance must contain 36 values")
return self
class ImageFrame(ContractModel):
kind: Literal["image"] = "image"
header: CanonicalStreamHeader
camera_id: Identifier
optical_frame: Identifier
width: PositiveInt
height: PositiveInt
pixel_format: Literal["rgb8", "bgr8", "rgba8", "mono8", "mono16", "depth16", "jpeg", "png"]
payload: PayloadHandle
calibration_id: Identifier | None = None
class EncodedVideoPacket(ContractModel):
kind: Literal["encoded-video"] = "encoded-video"
header: CanonicalStreamHeader
stream_id: Identifier
camera_id: Identifier
codec: Literal["h264", "h265", "mjpeg", "av1", "unknown"]
key_frame: bool
presentation_time_ns: NonNegativeInt
decode_time_ns: NonNegativeInt | None = None
duration_ns: NonNegativeInt | None = None
payload: PayloadHandle
class DeviceStatusFrame(ContractModel):
kind: Literal["device-status"] = "device-status"
header: CanonicalStreamHeader
health: Literal["unknown", "healthy", "degraded", "fault"]
state: Identifier
measurements: JsonObject = Field(default_factory=dict)
message: ShortText | None = None
CanonicalStreamEnvelope = Annotated[
PointCloudFrame | PoseFrame | ImageFrame | EncodedVideoPacket | DeviceStatusFrame,
Field(discriminator="kind"),
]

View File

@ -1,25 +1,29 @@
# XGRIDS / LixelKity K1 plugin
This directory is the package boundary for the first Mission Core device
plugin. It now owns the canonical v1alpha manifest
plugin. It now owns the canonical v1alpha2 manifest
[`plugin.manifest.json`](plugin.manifest.json). The statically linked frontend
contribution lives in `apps/control-station/src/device-plugins/xgrids-k1/`; the
verified backend implementation remains temporarily in `src/k1link` so the
real-device path stays operational during extraction.
The v1alpha1 host deliberately assigns one model to one plugin runtime, so this
manifest exposes only `xgrids.lixelkity-k1`. Adding another device model means a
new plugin contribution until session/model routing lands in the next SDK.
The v1alpha2 catalog can describe one or more independently profiled models.
This manifest currently exposes only `xgrids.lixelkity-k1`, and the transitional
runtime still owns one active model/session at a time. Concurrent model/session
routing remains a later supervisor milestone.
The plugin owns:
- the exact firmware/topology compatibility profiles under
[`profiles/`](profiles/), with strict evidence flags and a fail-closed loader;
- BLE discovery hints and K1 GATT metadata;
- the reviewed firmware-3 Wi-Fi provisioning profile;
- K1 LAN status and private-address validation;
- subscribe-only MQTT transport and report-topic allowlist;
- native `.k1mqtt` capture;
- firmware-scoped protobuf/LZ4 and legacy codecs;
- normalization of K1 point cloud, pose, status, health, and artifacts;
- normalization of K1 point cloud and pose, plus raw-only preservation of the
still-undecoded status and heartbeat channels;
- K1-specific operator instructions and compatibility tests.
The plugin does not own:
@ -45,7 +49,26 @@ legacy router; both paths delegate to the same proven
`XgridsK1CompatibilityService` methods. Synchronous capture/runtime operations
run outside the FastAPI event loop.
Model switching calls the plugin deactivation hook and must receive a successful
`stream.stop` before Mission Core removes this custom UI. BLE, MQTT, codec and
Model switching calls the plugin deactivation hook. An active acquisition uses
semantic `acquisition.stop` in `capture-only` mode; replay and pre-v1alpha2
sessions retain the legacy `stream.stop` shim. Neither path claims that the
physical K1 stopped without separate operator evidence. BLE, MQTT, codec and
evidence modules remain in `src/k1link` until replay parity and another physical
K1 regression are complete.
The compatibility profile is descriptive and cannot itself authorize a vendor
write. The current runtime cannot inspect K1 firmware: it keeps the profile
inactive until the operator explicitly attests firmware `3.0.2` and direct-LAN
topology, and records that basis as `operator-attested` rather than
device-derived evidence. Validate the current exact-match profile without
device I/O with:
```bash
uv run python plugins/xgrids-k1/profile_loader.py
```
The optional owner-controlled iPhone/LixelGO observation tool lives under
[`lab/iphone-capture/`](lab/iphone-capture/). It pins `pymobiledevice3` in a
separate `uv` environment, writes only ignored evidence sessions, and remains a
lab subprocess rather than a plugin/runtime dependency. The first capture is
gated by ADR 0004 and ADR 0005.

View File

@ -0,0 +1 @@
3.12

View File

@ -0,0 +1,69 @@
# iPhone / LixelGO capture lab
This is an isolated evidence tool for the XGRIDS K1 plugin. It is not a Mission
Core runtime dependency and must not be bundled without a separate GPL license
review.
## Install without Xcode
From the repository root:
```bash
uv sync --project plugins/xgrids-k1/lab/iphone-capture --frozen
```
The environment is created under the lab directory. It pins
`pymobiledevice3==9.36.0`; it does not modify the system Python, Homebrew, the
Mission Core `.venv`, or the root lockfile.
## USB preflight
Connect exactly one iPhone with a data-capable USB cable, unlock it, confirm the
Mac accessory prompt, and tap **Trust This Computer** on the iPhone. Then run:
```bash
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
python plugins/xgrids-k1/lab/iphone-capture/preflight.py
```
The preflight prints only a device count. It never prints or stores the iPhone
UDID or serial number.
## Bounded network capture
Run this only after the post-refactor exact-profile K1 regression in ADR 0004:
```bash
uv run --project plugins/xgrids-k1/lab/iphone-capture --frozen \
python plugins/xgrids-k1/lab/iphone-capture/capture.py \
--duration 120
```
The capture uses the iPhone `pcapd` service over USB, suppresses packet hex on
stdout, and writes private artifacts under ignored
`sessions/iphone-k1-observation/`. It is intentionally bounded to 5600 seconds.
Each successful run retains the original metadata-bearing `PCAPNG` and creates a
classic Ethernet `PCAP` that the macOS bundled `tcpdump` can read. The redacted
manifest records the validated packet count and hashes both artifacts.
The default refuses a dirty repository; `--allow-dirty` records a source-tree
fingerprint when an explicitly approved lab run cannot wait for a commit.
Operator events may be recorded while the capture is active and can finish
after the bounded network process exits. Preserve the original capture manifest
and create a separate integrity inventory after the operator timeline is closed:
```bash
uv run python plugins/xgrids-k1/lab/iphone-capture/session_integrity.py \
sessions/iphone-k1-observation/<session>
```
The sealer re-hashes every manifest artifact, adds the manifest and optional
`operator-events.private.jsonl`, and writes an ignored mode-`0600`
`integrity.private.json`. It never edits the original manifest or raw capture.
Do not add `--bluetooth` unless Apple's Bluetooth diagnostic logging profile is
already installed on the iPhone and the operator explicitly approved that
configuration change. The script never installs the profile.
Wireshark is optional and is not required to record PCAP/PCAPNG. Full Xcode is
also not required for this path.

View File

@ -0,0 +1,313 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import secrets
import struct
import subprocess
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from pcapng import FileScanner
from pcapng.blocks import EnhancedPacket
from preflight import (
pymobiledevice3_binary,
pymobiledevice3_version,
usb_device_count,
)
REPO_ROOT = Path(__file__).resolve().parents[4]
SESSION_ROOT = REPO_ROOT / "sessions" / "iphone-k1-observation"
SAFE_LABEL = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
def utc_now() -> str:
return datetime.now(UTC).isoformat()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def git_output(*args: str) -> bytes:
completed = subprocess.run(
["git", *args],
cwd=REPO_ROOT,
check=True,
capture_output=True,
)
return completed.stdout
def source_snapshot() -> dict[str, Any]:
revision = git_output("rev-parse", "HEAD").decode().strip()
status = git_output("status", "--porcelain=v1", "--untracked-files=all")
dirty = bool(status.strip())
digest = hashlib.sha256()
digest.update(revision.encode())
digest.update(b"\0")
digest.update(status)
if dirty:
digest.update(git_output("diff", "--binary", "HEAD"))
untracked = git_output("ls-files", "--others", "--exclude-standard", "-z")
for raw_name in filter(None, untracked.split(b"\0")):
relative = raw_name.decode("utf-8", errors="surrogateescape")
path = REPO_ROOT / relative
digest.update(raw_name)
digest.update(b"\0")
if path.is_file():
digest.update(sha256_file(path).encode())
return {
"git_revision": revision,
"source_tree_dirty": dirty,
"source_tree_fingerprint_sha256": digest.hexdigest(),
}
def terminate(process: subprocess.Popen[bytes]) -> int:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
return int(process.returncode or 0)
def artifact_record(path: Path, kind: str, session_dir: Path) -> dict[str, Any] | None:
if not path.is_file() or path.stat().st_size == 0:
return None
path.chmod(0o600)
return {
"kind": kind,
"relative_path": str(path.relative_to(session_dir)),
"size_bytes": path.stat().st_size,
"sha256": sha256_file(path),
}
def convert_pcapng_to_pcap(source: Path, destination: Path) -> int:
"""Create a classic Ethernet PCAP that macOS's bundled tcpdump can read."""
packet_count = 0
with source.open("rb") as source_stream, destination.open("xb") as destination_stream:
destination_stream.write(
struct.pack(
"<IHHIIII",
0xA1B2C3D4,
2,
4,
0,
0,
262_144,
1,
)
)
for block in FileScanner(source_stream):
if not isinstance(block, EnhancedPacket):
continue
packet_data = bytes(block.packet_data)
captured_length = int(block.captured_len)
packet_length = int(block.packet_len)
if captured_length != len(packet_data) or packet_length < captured_length:
raise ValueError("invalid enhanced-packet lengths")
timestamp = float(block.timestamp)
seconds = int(timestamp)
microseconds = int(round((timestamp - seconds) * 1_000_000))
if microseconds >= 1_000_000:
seconds += 1
microseconds -= 1_000_000
destination_stream.write(
struct.pack(
"<IIII",
seconds,
microseconds,
captured_length,
packet_length,
)
)
destination_stream.write(packet_data)
packet_count += 1
destination.chmod(0o600)
return packet_count
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Bounded, redacted USB iPhone capture for the Mission Core K1 lab"
)
parser.add_argument("--duration", type=int, default=120, help="capture seconds (5..600)")
parser.add_argument("--label", default="lixelgo-observe", help="safe session label")
parser.add_argument("--process", help="optional iOS process filter for network capture")
parser.add_argument("--interface", help="optional iOS interface filter for network capture")
parser.add_argument(
"--bluetooth",
action="store_true",
help="also capture iPhone Bluetooth HCI; requires Apple's logging profile",
)
parser.add_argument(
"--acknowledge-ios-bluetooth-profile",
action="store_true",
help="confirm the operator already installed Apple's Bluetooth logging profile",
)
parser.add_argument(
"--allow-dirty",
action="store_true",
help="record an explicit dirty-tree fingerprint instead of refusing the capture",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if not 5 <= args.duration <= 600:
raise SystemExit("--duration must be between 5 and 600 seconds")
if not SAFE_LABEL.fullmatch(args.label):
raise SystemExit("--label must use lowercase ASCII letters, digits, and hyphens")
if args.bluetooth and not args.acknowledge_ios_bluetooth_profile:
raise SystemExit(
"Bluetooth capture requires --acknowledge-ios-bluetooth-profile; "
"the script never installs the profile"
)
os.umask(0o077)
binary = pymobiledevice3_binary()
if usb_device_count(binary) != 1:
raise SystemExit("exactly one trusted USB iPhone must be connected")
snapshot = source_snapshot()
if snapshot["source_tree_dirty"] and not args.allow_dirty:
raise SystemExit(
"repository is dirty; commit the milestone or pass --allow-dirty "
"to record its fingerprint"
)
session_id = (
datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + f"_{args.label}_{secrets.token_hex(2)}"
)
session_dir = SESSION_ROOT / session_id
capture_dir = session_dir / "captures"
capture_dir.mkdir(parents=True, mode=0o700)
network_pcapng_path = capture_dir / "iphone-network.pcapng"
network_pcap_path = capture_dir / "iphone-network.pcap"
bluetooth_path = capture_dir / "iphone-bluetooth.pcapng"
stderr_path = session_dir / "capture.stderr.log"
manifest_path = session_dir / "manifest.redacted.json"
manifest: dict[str, Any] = {
"schema": "missioncore.xgrids-k1/iphone-observation-manifest/v1alpha1",
"session_id": session_id,
"status": "starting",
"started_at_utc": utc_now(),
"started_monotonic_ns": time.monotonic_ns(),
"requested_duration_seconds": args.duration,
"capture_backend": "pymobiledevice3-9.36.0/com.apple.pcapd",
"pymobiledevice3_version": pymobiledevice3_version(binary),
"network_capture": True,
"bluetooth_capture": args.bluetooth,
"network_process_filter": args.process,
"network_interface_filter": args.interface,
"device_identity_recorded": False,
"credentials_recorded_in_manifest": False,
"source": snapshot,
"artifacts": [],
}
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
manifest_path.chmod(0o600)
network_command = [binary, "--no-color", "pcap", "--out", str(network_pcapng_path)]
if args.process:
network_command.extend(["--process", args.process])
if args.interface:
network_command.extend(["--interface", args.interface])
commands = [("network", network_command)]
if args.bluetooth:
commands.append(
(
"bluetooth",
[binary, "--no-color", "btlogger", str(bluetooth_path), "--format", "pcapng"],
)
)
processes: list[tuple[str, subprocess.Popen[bytes]]] = []
exit_codes: dict[str, int] = {}
interrupted = False
failure: str | None = None
with stderr_path.open("wb") as error_log:
stderr_path.chmod(0o600)
try:
for name, command in commands:
process = subprocess.Popen(
command,
cwd=REPO_ROOT,
stdout=subprocess.DEVNULL,
stderr=error_log,
)
processes.append((name, process))
time.sleep(1)
early = [name for name, process in processes if process.poll() is not None]
if early:
raise RuntimeError(f"capture service exited early: {', '.join(early)}")
deadline = time.monotonic() + args.duration
while time.monotonic() < deadline:
ended = [name for name, process in processes if process.poll() is not None]
if ended:
raise RuntimeError(f"capture service stopped unexpectedly: {', '.join(ended)}")
time.sleep(min(0.25, deadline - time.monotonic()))
except KeyboardInterrupt:
interrupted = True
except RuntimeError as exc:
failure = str(exc)
finally:
for name, process in processes:
exit_codes[name] = terminate(process)
network_packet_count: int | None = None
if failure is None:
try:
network_packet_count = convert_pcapng_to_pcap(
network_pcapng_path,
network_pcap_path,
)
if network_packet_count == 0:
failure = "network capture validation found no packets"
except Exception as exc: # noqa: BLE001 - third-party parser errors vary by version
failure = f"network capture validation failed: {type(exc).__name__}"
artifacts = [
artifact_record(network_pcapng_path, "iphone-network-pcapng-raw", session_dir),
artifact_record(network_pcap_path, "iphone-network-pcap-compatible", session_dir),
artifact_record(bluetooth_path, "iphone-bluetooth-hci-pcapng", session_dir),
artifact_record(stderr_path, "capture-stderr", session_dir),
]
manifest.update(
{
"status": "failed" if failure else "completed",
"interrupted_by_operator": interrupted,
"failure": failure,
"ended_at_utc": utc_now(),
"ended_monotonic_ns": time.monotonic_ns(),
"process_exit_codes": exit_codes,
"network_packet_count": network_packet_count,
"artifacts": [item for item in artifacts if item is not None],
}
)
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
manifest_path.chmod(0o600)
print(f"session: {session_dir}")
print(f"status: {manifest['status']}")
return 1 if failure else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,76 @@
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
def pymobiledevice3_binary() -> str:
sibling = Path(sys.executable).with_name("pymobiledevice3")
if sibling.is_file():
return str(sibling)
discovered = shutil.which("pymobiledevice3")
if discovered is None:
raise RuntimeError("pymobiledevice3 is not installed in this environment")
return discovered
def pymobiledevice3_version(binary: str) -> str:
completed = subprocess.run(
[binary, "--no-color", "version"],
check=False,
capture_output=True,
text=True,
timeout=20,
)
if completed.returncode != 0:
raise RuntimeError("pymobiledevice3 version check failed")
return completed.stdout.strip()
def usb_device_count(binary: str) -> int:
completed = subprocess.run(
[binary, "--no-color", "usbmux", "list", "--usb"],
check=False,
capture_output=True,
text=True,
timeout=20,
)
if completed.returncode != 0:
raise RuntimeError(
"USB discovery failed; unlock the iPhone and confirm Trust This Computer"
)
try:
payload = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError("USB discovery returned an unexpected response") from exc
if not isinstance(payload, list):
raise RuntimeError("USB discovery returned an unexpected response")
return len(payload)
def main() -> int:
try:
binary = pymobiledevice3_binary()
version = pymobiledevice3_version(binary)
count = usb_device_count(binary)
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
print(f"BLOCKED: {exc}")
return 2
print(f"pymobiledevice3: {version}")
print(f"USB iPhone devices: {count}")
if count == 0:
print("BLOCKED: connect one unlocked iPhone with a data cable and confirm Trust")
return 2
if count > 1:
print("BLOCKED: leave exactly one trusted iPhone connected for a redacted capture")
return 2
print("READY: one trusted USB iPhone is available; no device identifier was printed")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,12 @@
[project]
name = "mission-core-ios-capture-lab"
version = "0.1.0"
description = "Isolated read-only iPhone packet-capture tooling for Mission Core lab work"
requires-python = ">=3.12,<3.13"
dependencies = [
"pymobiledevice3==9.36.0",
"python-pcapng==2.1.1",
]
[tool.uv]
package = false

View File

@ -0,0 +1,169 @@
"""Seal one completed iPhone capture without rewriting its original manifest.
The capture manifest is written when the bounded packet capture ends. Operator
events may be appended after that point, so they belong in a separate derived
integrity inventory rather than in a retroactively edited capture manifest.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
INTEGRITY_SCHEMA = "missioncore.xgrids-k1/iphone-session-integrity/v1alpha1"
CAPTURE_MANIFEST = "manifest.redacted.json"
INTEGRITY_INVENTORY = "integrity.private.json"
OPTIONAL_SESSION_ARTIFACTS = {
"capture.stderr.log": "capture-stderr",
"operator-events.private.jsonl": "operator-events",
}
class SessionIntegrityError(ValueError):
"""The session cannot be safely verified or sealed."""
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _load_object(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise SessionIntegrityError(f"cannot read JSON object: {path.name}") from exc
if not isinstance(value, dict):
raise SessionIntegrityError(f"JSON root must be an object: {path.name}")
return value
def _safe_artifact_path(session_dir: Path, relative_path: object) -> Path:
if not isinstance(relative_path, str) or not relative_path:
raise SessionIntegrityError("artifact relative_path must be a non-empty string")
relative = Path(relative_path)
if relative.is_absolute() or ".." in relative.parts:
raise SessionIntegrityError("artifact path must stay inside the session")
path = session_dir / relative
if path.is_symlink() or not path.is_file():
raise SessionIntegrityError(f"artifact is missing or is a symlink: {relative_path}")
return path
def _record(path: Path, *, kind: str, session_dir: Path) -> dict[str, Any]:
before = path.stat()
digest = sha256_file(path)
after = path.stat()
if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
raise SessionIntegrityError(f"artifact changed while hashing: {path.name}")
return {
"kind": kind,
"relative_path": str(path.relative_to(session_dir)),
"size_bytes": after.st_size,
"sha256": digest,
"mode_octal": f"{after.st_mode & 0o777:04o}",
}
def build_inventory(session_dir: Path) -> dict[str, Any]:
"""Verify a completed capture and return a derived integrity inventory."""
session_dir = session_dir.expanduser().resolve()
manifest_path = session_dir / CAPTURE_MANIFEST
if manifest_path.is_symlink() or not manifest_path.is_file():
raise SessionIntegrityError(f"missing {CAPTURE_MANIFEST}")
manifest = _load_object(manifest_path)
if manifest.get("status") != "completed":
raise SessionIntegrityError("only a completed capture can be sealed")
session_id = manifest.get("session_id")
if not isinstance(session_id, str) or session_id != session_dir.name:
raise SessionIntegrityError("manifest session_id does not match the directory")
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, list) or not artifacts:
raise SessionIntegrityError("capture manifest has no artifacts")
verified: list[dict[str, Any]] = []
for item in artifacts:
if not isinstance(item, dict):
raise SessionIntegrityError("manifest artifact must be an object")
path = _safe_artifact_path(session_dir, item.get("relative_path"))
actual = _record(
path,
kind=str(item.get("kind") or "capture-manifest-artifact"),
session_dir=session_dir,
)
if item.get("size_bytes") != actual["size_bytes"]:
raise SessionIntegrityError(f"artifact size mismatch: {actual['relative_path']}")
if item.get("sha256") != actual["sha256"]:
raise SessionIntegrityError(f"artifact hash mismatch: {actual['relative_path']}")
verified.append(actual)
derived = [_record(manifest_path, kind="capture-manifest", session_dir=session_dir)]
for relative_name, kind in OPTIONAL_SESSION_ARTIFACTS.items():
path = session_dir / relative_name
if path.exists():
if path.is_symlink() or not path.is_file():
raise SessionIntegrityError(
f"optional artifact is not a regular file: {relative_name}"
)
derived.append(_record(path, kind=kind, session_dir=session_dir))
return {
"schema": INTEGRITY_SCHEMA,
"session_id": session_id,
"sealed_at_utc": datetime.now(UTC).isoformat(),
"capture_manifest_preserved": True,
"verified_manifest_artifacts": verified,
"derived_session_artifacts": derived,
}
def seal_session(session_dir: Path, *, overwrite: bool = False) -> Path:
"""Write an ignored mode-0600 inventory next to the immutable capture."""
os.umask(0o077)
resolved = session_dir.expanduser().resolve()
destination = resolved / INTEGRITY_INVENTORY
if destination.exists() and not overwrite:
raise SessionIntegrityError(f"{INTEGRITY_INVENTORY} already exists")
inventory = build_inventory(resolved)
temporary = resolved / f".{INTEGRITY_INVENTORY}.tmp"
temporary.write_text(json.dumps(inventory, indent=2) + "\n", encoding="utf-8")
temporary.chmod(0o600)
temporary.replace(destination)
destination.chmod(0o600)
return destination
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Verify and seal one completed Mission Core iPhone capture session"
)
parser.add_argument("session", type=Path, help="completed ignored session directory")
parser.add_argument(
"--overwrite",
action="store_true",
help="replace only the derived inventory; never changes the capture manifest",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
destination = seal_session(args.session, overwrite=args.overwrite)
except SessionIntegrityError as exc:
raise SystemExit(str(exc)) from exc
print(f"sealed: {destination}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,24 @@
{
"apiVersion": "missioncore.nodedc/v1alpha1",
"apiVersion": "missioncore.nodedc/v1alpha2",
"kind": "DevicePlugin",
"metadata": {
"id": "nodedc.device.xgrids-lixelkity-k1",
"version": "0.1.0",
"version": "0.2.0",
"displayName": "XGRIDS K1 Integration"
},
"spec": {
"hostApiRange": "v1alpha1",
"hostApiRange": "v1alpha2",
"runtime": {
"backendEntrypoint": "k1link.web.xgrids_k1_facade:build_xgrids_k1_plugin",
"isolation": "transitional-in-process"
},
"compatibilityProfiles": [
{
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
"path": "profiles/fw-3.0.2/direct-lan.v1.json",
"modelId": "xgrids.lixelkity-k1"
}
],
"permissions": [
"device.discovery.ble",
"device.provisioning.wifi-over-ble",
@ -21,7 +28,16 @@
"actions": [
{ "id": "state.read", "mutating": false, "secretFields": [] },
{ "id": "discovery.scan", "mutating": false, "secretFields": [] },
{ "id": "device.inspect", "mutating": false, "secretFields": [] },
{ "id": "sensor.catalog.read", "mutating": false, "secretFields": [] },
{ "id": "calibration.device-snapshot.read", "mutating": false, "secretFields": [] },
{ "id": "network.provision", "mutating": true, "secretFields": ["password"] },
{ "id": "connection.verify", "mutating": false, "secretFields": [] },
{ "id": "acquisition.prepare", "mutating": true, "secretFields": [] },
{ "id": "acquisition.start", "mutating": true, "secretFields": [] },
{ "id": "acquisition.stop", "mutating": true, "secretFields": [] },
{ "id": "acquisition.abort", "mutating": true, "secretFields": [] },
{ "id": "acquisition.state.read", "mutating": false, "secretFields": [] },
{ "id": "stream.start-live", "mutating": true, "secretFields": [] },
{ "id": "stream.start-replay", "mutating": true, "secretFields": [] },
{ "id": "stream.stop", "mutating": true, "secretFields": [] },

View File

@ -0,0 +1,486 @@
"""Strict loader for the local XGRIDS K1 compatibility profile.
The profile is descriptive and fail-closed. Loading it never performs device
I/O and never grants write authority to a transport implementation.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
PROFILE_SCHEMA_VERSION = 1
DEFAULT_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
DEFAULT_PROFILE_PATH = Path(__file__).parent / "profiles" / "fw-3.0.2" / "direct-lan.v1.json"
EVIDENCE_FLAGS = (
"observed",
"decoded",
"replay_verified",
"physical_verified",
"write_enabled",
)
class CompatibilityProfileError(ValueError):
"""The compatibility profile is malformed or exceeds its reviewed scope."""
def _object(value: Any, path: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise CompatibilityProfileError(f"{path} must be an object")
return value
def _array(value: Any, path: str) -> list[Any]:
if not isinstance(value, list):
raise CompatibilityProfileError(f"{path} must be an array")
return value
def _string(value: Any, path: str) -> str:
if not isinstance(value, str) or not value:
raise CompatibilityProfileError(f"{path} must be a non-empty string")
return value
def _unique_index(items: list[Any], path: str) -> dict[str, dict[str, Any]]:
index: dict[str, dict[str, Any]] = {}
for position, value in enumerate(items):
item_path = f"{path}[{position}]"
item = _object(value, item_path)
item_id = _string(item.get("id"), f"{item_path}.id")
if item_id in index:
raise CompatibilityProfileError(f"{path} contains duplicate id {item_id!r}")
index[item_id] = item
return index
def _validate_evidence(value: Any, path: str) -> dict[str, bool]:
evidence = _object(value, path)
if set(evidence) != set(EVIDENCE_FLAGS):
expected = ", ".join(EVIDENCE_FLAGS)
raise CompatibilityProfileError(f"{path} must contain exactly: {expected}")
for flag in EVIDENCE_FLAGS:
if not isinstance(evidence[flag], bool):
raise CompatibilityProfileError(f"{path}.{flag} must be a boolean")
if evidence["write_enabled"]:
raise CompatibilityProfileError(f"{path}.write_enabled must remain false in v1")
return evidence # type: ignore[return-value]
def _walk_and_validate_evidence(value: Any, path: str = "$") -> None:
if isinstance(value, dict):
if "evidence" in value:
_validate_evidence(value["evidence"], f"{path}.evidence")
for key, child in value.items():
child_path = f"{path}.{key}"
if key == "write_enabled" and path != "$.evidence_vocabulary":
if not isinstance(child, bool):
raise CompatibilityProfileError(f"{child_path} must be a boolean")
if child:
raise CompatibilityProfileError(f"{child_path} must remain false in v1")
_walk_and_validate_evidence(child, child_path)
elif isinstance(value, list):
for position, child in enumerate(value):
_walk_and_validate_evidence(child, f"{path}[{position}]")
def _expect_evidence(
item: dict[str, Any],
path: str,
*,
observed: bool,
decoded: bool,
replay_verified: bool,
physical_verified: bool,
) -> None:
actual = _validate_evidence(item.get("evidence"), f"{path}.evidence")
expected = {
"observed": observed,
"decoded": decoded,
"replay_verified": replay_verified,
"physical_verified": physical_verified,
"write_enabled": False,
}
if actual != expected:
raise CompatibilityProfileError(
f"{path}.evidence exceeds or contradicts the reviewed v1 evidence"
)
def _validate_sources(profile: dict[str, Any]) -> set[str]:
sources = _unique_index(
_array(profile.get("evidence_sources"), "$.evidence_sources"),
"$.evidence_sources",
)
for source_id, source in sources.items():
_string(source.get("kind"), f"$.evidence_sources[{source_id!r}].kind")
path = _string(source.get("path"), f"$.evidence_sources[{source_id!r}].path")
if path.startswith("/") or ".." in Path(path).parts:
raise CompatibilityProfileError(
f"$.evidence_sources[{source_id!r}].path must be repository-relative"
)
_string(source.get("scope"), f"$.evidence_sources[{source_id!r}].scope")
return set(sources)
def _validate_source_references(value: Any, source_ids: set[str], path: str = "$") -> None:
if isinstance(value, dict):
if "source_ids" in value:
references = _array(value["source_ids"], f"{path}.source_ids")
if len(references) != len(set(references)):
raise CompatibilityProfileError(f"{path}.source_ids contains duplicates")
for position, source_id in enumerate(references):
source_id = _string(source_id, f"{path}.source_ids[{position}]")
if source_id not in source_ids:
raise CompatibilityProfileError(
f"{path}.source_ids[{position}] references unknown evidence source"
)
for key, child in value.items():
_validate_source_references(child, source_ids, f"{path}.{key}")
elif isinstance(value, list):
for position, child in enumerate(value):
_validate_source_references(child, source_ids, f"{path}[{position}]")
def _validate_transports(profile: dict[str, Any]) -> None:
transports = _unique_index(
_array(profile.get("transports"), "$.transports"),
"$.transports",
)
if set(transports) != {
"ble.wifi-bootstrap.fw3.v1",
"mqtt.direct-lan.fw3.v1",
"rtsp.camera-preview.fw3.v1",
}:
raise CompatibilityProfileError("$.transports must contain only the reviewed v1 transports")
ble = transports["ble.wifi-bootstrap.fw3.v1"]
if ble.get("service_uuid") != "00007f00-0000-1000-8000-00805f9b34fb":
raise CompatibilityProfileError("BLE service UUID differs from reviewed evidence")
characteristics = _object(ble.get("characteristics"), "$.transports[ble].characteristics")
if characteristics != {
"wifi_request": "00007f01-0000-1000-8000-00805f9b34fb",
"wifi_status": "00007f02-0000-1000-8000-00805f9b34fb",
}:
raise CompatibilityProfileError("BLE characteristic UUIDs differ from reviewed evidence")
if ble.get("request_frame_bytes") != 99:
raise CompatibilityProfileError("BLE provisioning frame must remain exactly 99 bytes")
_expect_evidence(
ble,
"$.transports[ble.wifi-bootstrap.fw3.v1]",
observed=True,
decoded=True,
replay_verified=False,
physical_verified=True,
)
mqtt = transports["mqtt.direct-lan.fw3.v1"]
if mqtt.get("protocol") != "MQTT 3.1.1":
raise CompatibilityProfileError("direct-LAN application protocol must remain MQTT 3.1.1")
network = _object(mqtt.get("network"), "$.transports[mqtt].network")
if network.get("transport") != "TCP" or network.get("port") != 1883:
raise CompatibilityProfileError("direct-LAN MQTT endpoint must remain TCP 1883")
if network.get("tls") is not False or network.get("authentication") != "none-observed":
raise CompatibilityProfileError("direct-LAN MQTT security claim differs from observation")
allowlist = _array(
mqtt.get("subscription_allowlist"),
"$.transports[mqtt].subscription_allowlist",
)
if "lixel/application/report/#" not in allowlist:
raise CompatibilityProfileError("MQTT report-topic allowlist is missing")
for topic in allowlist:
topic = _string(topic, "$.transports[mqtt].subscription_allowlist[]")
if "/request/" in topic:
raise CompatibilityProfileError(
"request topics cannot enter the subscribe-only profile"
)
_expect_evidence(
mqtt,
"$.transports[mqtt.direct-lan.fw3.v1]",
observed=True,
decoded=False,
replay_verified=False,
physical_verified=True,
)
rtsp = transports["rtsp.camera-preview.fw3.v1"]
if rtsp.get("protocol") != "RTSP 1.0 with interleaved RTP over TCP":
raise CompatibilityProfileError("camera-preview protocol differs from observation")
rtsp_network = _object(rtsp.get("network"), "$.transports[rtsp].network")
if rtsp_network.get("transport") != "TCP" or rtsp_network.get("port") != 8554:
raise CompatibilityProfileError("camera-preview endpoint must remain TCP 8554")
if (
rtsp_network.get("tls") is not False
or rtsp_network.get("authentication") != "none-observed"
):
raise CompatibilityProfileError("camera-preview security differs from observation")
media = _object(rtsp.get("media"), "$.transports[rtsp].media")
if media != {
"codec": "H.264",
"rtp_payload_type": 96,
"clock_hz": 90000,
"framing": "RTP/AVP/TCP interleaved channels 0-1",
}:
raise CompatibilityProfileError("camera-preview media contract differs from observation")
_expect_evidence(
rtsp,
"$.transports[rtsp.camera-preview.fw3.v1]",
observed=True,
decoded=False,
replay_verified=False,
physical_verified=True,
)
def _validate_channels(profile: dict[str, Any]) -> None:
channels = _unique_index(_array(profile.get("channels"), "$.channels"), "$.channels")
expected_ids = {
"spatial.point-cloud.live",
"spatial.pose.live",
"device.status.live",
"device.heartbeat.live",
"camera.preview.live",
}
if set(channels) != expected_ids:
raise CompatibilityProfileError("$.channels differs from the reviewed v1 channel set")
point = channels["spatial.point-cloud.live"]
if point.get("topic") != "lixel/application/report/lio_pcl":
raise CompatibilityProfileError("point-cloud topic differs from reviewed evidence")
_expect_evidence(
point,
"$.channels[spatial.point-cloud.live]",
observed=True,
decoded=True,
replay_verified=True,
physical_verified=True,
)
pose = channels["spatial.pose.live"]
if pose.get("topic") != "lixel/application/report/lio_pose":
raise CompatibilityProfileError("pose topic differs from reviewed evidence")
_expect_evidence(
pose,
"$.channels[spatial.pose.live]",
observed=True,
decoded=True,
replay_verified=True,
physical_verified=True,
)
for channel_id, topic in (
("device.status.live", "lixel/application/report/device_status"),
("device.heartbeat.live", "lixel/application/report/heartbeat"),
):
channel = channels[channel_id]
if channel.get("topic") != topic or channel.get("semantic_payload") is not None:
raise CompatibilityProfileError(f"{channel_id} must remain raw-only in v1")
_expect_evidence(
channel,
f"$.channels[{channel_id}]",
observed=True,
decoded=False,
replay_verified=False,
physical_verified=True,
)
camera = channels["camera.preview.live"]
if camera.get("discovery_status") != "observed":
raise CompatibilityProfileError("camera discovery must match the owner-controlled run")
if camera.get("topic") is not None:
raise CompatibilityProfileError("camera preview is not an MQTT topic")
expected_endpoints = {
"rtsp://{confirmed-device-private-ipv4}:8554/live/chn_left_main",
"rtsp://{confirmed-device-private-ipv4}:8554/live/chn_right_main",
}
endpoints = _array(camera.get("endpoint_templates"), "$.channels[camera].endpoint_templates")
if set(endpoints) != expected_endpoints or len(endpoints) != len(expected_endpoints):
raise CompatibilityProfileError("camera endpoint templates differ from observation")
if camera.get("wire_format") != "RTSP 1.0, interleaved RTP/TCP, H.264 PT96 at 90000 Hz":
raise CompatibilityProfileError("camera wire format differs from observation")
_string(camera.get("semantic_payload"), "$.channels[camera].semantic_payload")
_expect_evidence(
camera,
"$.channels[camera.preview.live]",
observed=True,
decoded=False,
replay_verified=False,
physical_verified=True,
)
def _validate_acquisition_control(profile: dict[str, Any]) -> None:
control = _object(profile.get("acquisition_control"), "$.acquisition_control")
if control.get("mode") != "operator-manual" or control.get("write_enabled") is not False:
raise CompatibilityProfileError("acquisition control must remain operator-manual")
device_control = _object(
control.get("verified_device_control"),
"$.acquisition_control.verified_device_control",
)
if device_control.get("gesture") != "physical-double-click":
raise CompatibilityProfileError("physical acquisition gesture differs from lab evidence")
_expect_evidence(
device_control,
"$.acquisition_control.verified_device_control",
observed=True,
decoded=False,
replay_verified=False,
physical_verified=True,
)
actions = _unique_index(
_array(control.get("semantic_actions"), "$.acquisition_control.semantic_actions"),
"$.acquisition_control.semantic_actions",
)
if set(actions) != {
"acquisition.start",
"acquisition.stop",
"calibration.device.start",
}:
raise CompatibilityProfileError("semantic action set differs from reviewed v1")
for action_id, action_value in (("acquisition.start", 1), ("acquisition.stop", 2)):
action = actions[action_id]
if action.get("execution") != "operator-manual":
raise CompatibilityProfileError(f"{action_id} must remain operator-manual")
mapping = _object(
action.get("vendor_request_mapping"),
f"$.acquisition_control.semantic_actions[{action_id}].vendor_request_mapping",
)
if mapping.get("evidence_kind") != "owner-controlled-wire-observation":
raise CompatibilityProfileError(f"{action_id} vendor mapping differs from evidence")
if mapping.get("topic") != "lixel/application/request/modeling":
raise CompatibilityProfileError(
f"{action_id} vendor topic differs from static evidence"
)
if mapping.get("qos") != 2 or mapping.get("action_field_value") != action_value:
raise CompatibilityProfileError(
f"{action_id} vendor mapping differs from static evidence"
)
if not _array(
mapping.get("required_unresolved_context"),
f"$.acquisition_control.semantic_actions[{action_id}].required_unresolved_context",
):
raise CompatibilityProfileError(f"{action_id} must declare unresolved request context")
_expect_evidence(
mapping,
f"$.acquisition_control.semantic_actions[{action_id}].vendor_request_mapping",
observed=True,
decoded=True,
replay_verified=False,
physical_verified=True,
)
_expect_evidence(
action,
f"$.acquisition_control.semantic_actions[{action_id}]",
observed=True,
decoded=True,
replay_verified=False,
physical_verified=True,
)
calibration = actions["calibration.device.start"]
if calibration.get("execution") != "unavailable":
raise CompatibilityProfileError("calibration must remain unavailable in v1")
if calibration.get("vendor_request_mapping") is not None:
raise CompatibilityProfileError("calibration vendor request is not evidenced")
_expect_evidence(
calibration,
"$.acquisition_control.semantic_actions[calibration.device.start]",
observed=False,
decoded=False,
replay_verified=False,
physical_verified=False,
)
def validate_compatibility_profile(profile: Any) -> dict[str, Any]:
"""Validate and return one read-only firmware-3/direct-LAN profile object."""
root = _object(profile, "$")
if root.get("schema_version") != PROFILE_SCHEMA_VERSION:
raise CompatibilityProfileError("unsupported compatibility profile schema_version")
if root.get("profile_id") != DEFAULT_PROFILE_ID:
raise CompatibilityProfileError("unexpected compatibility profile_id")
scope = _object(root.get("scope"), "$.scope")
firmware = _object(scope.get("firmware"), "$.scope.firmware")
if firmware != {"match": "exact", "version": "3.0.2"}:
raise CompatibilityProfileError("profile must match firmware 3.0.2 exactly")
if scope.get("topology") != "direct-lan":
raise CompatibilityProfileError("profile topology must remain direct-lan")
vocabulary = _object(root.get("evidence_vocabulary"), "$.evidence_vocabulary")
if set(vocabulary) != set(EVIDENCE_FLAGS):
raise CompatibilityProfileError("evidence vocabulary differs from schema v1")
for flag in EVIDENCE_FLAGS:
_string(vocabulary[flag], f"$.evidence_vocabulary.{flag}")
safety = _object(root.get("safety"), "$.safety")
if safety.get("default_mode") != "read-only":
raise CompatibilityProfileError("profile default mode must remain read-only")
if safety.get("vendor_writes_enabled") is not False:
raise CompatibilityProfileError("profile must not enable vendor writes")
if safety.get("unknown_firmware_policy") != "reject-profile":
raise CompatibilityProfileError("unknown firmware must fail closed")
if safety.get("request_topic_subscription_enabled") is not False:
raise CompatibilityProfileError("request-topic subscription must remain disabled")
source_ids = _validate_sources(root)
_validate_source_references(root, source_ids)
_walk_and_validate_evidence(root)
_validate_transports(root)
_validate_channels(root)
_validate_acquisition_control(root)
return root
def load_compatibility_profile(path: Path | str = DEFAULT_PROFILE_PATH) -> dict[str, Any]:
"""Load a JSON profile, rejecting duplicate keys and unreviewed claims."""
resolved = Path(path).expanduser().resolve()
def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise CompatibilityProfileError(f"duplicate JSON key {key!r}")
result[key] = value
return result
try:
raw = resolved.read_text(encoding="utf-8")
except OSError as exc:
raise CompatibilityProfileError(f"cannot read compatibility profile: {resolved}") from exc
try:
profile = json.loads(raw, object_pairs_hook=reject_duplicate_keys)
except json.JSONDecodeError as exc:
raise CompatibilityProfileError(f"invalid compatibility profile JSON: {exc}") from exc
return validate_compatibility_profile(profile)
def matches_target(
profile: dict[str, Any],
*,
firmware: str,
topology: str,
) -> bool:
"""Return whether an already validated exact-match profile covers the target."""
validated = validate_compatibility_profile(profile)
scope = validated["scope"]
return scope["firmware"]["version"] == firmware and scope["topology"] == topology
def _main() -> int:
parser = argparse.ArgumentParser(description="Validate an XGRIDS K1 compatibility profile")
parser.add_argument("path", nargs="?", type=Path, default=DEFAULT_PROFILE_PATH)
args = parser.parse_args()
profile = load_compatibility_profile(args.path)
print(profile["profile_id"])
return 0
if __name__ == "__main__":
raise SystemExit(_main())

View File

@ -0,0 +1,70 @@
# XGRIDS K1 compatibility profiles
This directory is the plugin-local, versioned compatibility vocabulary for
verified K1 wire behavior. It is deliberately **not** a platform ontology and
does not add runtime dependencies on NODE.DC Ontology Core.
The first profile is
[`fw-3.0.2/direct-lan.v1.json`](fw-3.0.2/direct-lan.v1.json). It matches exactly:
- model: XGRIDS LixelKity K1;
- firmware: `3.0.2`;
- topology: K1 and connector on the same owner-controlled LAN;
- evidence scope: one physical scanner across controlled direct-LAN and
owner-operated LixelGO/iPhone laboratory runs.
It must not be applied to another firmware or treated as a vendor API claim.
Unknown firmware fails closed.
## Evidence flags
Every transport, data channel, and semantic action uses five independent
boolean flags:
| Flag | Meaning |
| --- | --- |
| `observed` | Directly seen on the owner-controlled K1 |
| `decoded` | A bounded semantic decoder exists for observed bytes |
| `replay_verified` | Captured semantic data passed the replay-to-view path |
| `physical_verified` | Correlated with a controlled physical state/action |
| `write_enabled` | The profile grants emission of a state-changing request |
`decoded` does not mean that MQTT framing alone was parsed. Status and heartbeat
are therefore observed raw channels, not decoded status models. Camera preview
transport is observed, but its media decoder/replay flags remain independent.
The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire
capture verifies the `ModelingRequest` topic and action values, but complete
device/session/OpenAPI header construction, settings, save completion, timeout
and rollback contracts remain unresolved. Acquisition therefore stays
`operator-manual` through the verified physical double-click.
The existing BLE Wi-Fi provisioning workflow has its own reviewed profile and
operator confirmation. Merely loading this compatibility profile neither calls
nor authorizes that legacy mutation path.
## Validation
The loader uses only the Python standard library and performs no device I/O:
```bash
python plugins/xgrids-k1/profile_loader.py
```
It verifies the exact firmware/topology scope, evidence vocabulary, source
references, GATT UUIDs, MQTT subscribe-only boundary, channel evidence, camera
RTSP/H.264 endpoints, operator-manual acquisition, and disabled vendor request
mappings.
## Evolution rules
1. A different firmware or topology gets a new profile file and profile ID.
2. New evidence may only promote the flags supported by retained raw evidence,
a documented decoder/replay check, or a physical lab report.
3. Unknown fields remain explicit; they are never filled from naming or payload
shape alone.
4. Breaking profile semantics require a new `schema_version` and validator.
5. Sensitive packet captures, device identities, addresses and credentials stay
outside Git; redacted reports and hashes are the committed evidence links.
6. A profile describes compatibility. Host authorization and transport writes
remain separate policy and execution concerns.

View File

@ -0,0 +1,406 @@
{
"schema_version": 1,
"profile_id": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
"profile_status": "experimental-evidence-backed",
"scope": {
"vendor": "XGRIDS",
"model": "LixelKity K1",
"firmware": {
"match": "exact",
"version": "3.0.2"
},
"topology": "direct-lan",
"claim_limit": "One owner-controlled K1 on exact firmware 3.0.2 across controlled direct-LAN and owner-operated LixelGO/iPhone laboratory runs. This is not a vendor API or a cross-firmware compatibility claim."
},
"evidence_vocabulary": {
"observed": "The channel, transport, or physical behavior was directly seen on the owner-controlled K1.",
"decoded": "A bounded decoder produces the stated semantic payload from observed bytes; transport framing alone is not a semantic decode.",
"replay_verified": "A captured payload of this semantic channel has passed the repository replay-to-view path.",
"physical_verified": "The result was correlated with a controlled physical K1 state or operator action.",
"write_enabled": "This profile authorizes software to emit the state-changing vendor request. False never grants runtime write authority."
},
"safety": {
"default_mode": "read-only",
"vendor_writes_enabled": false,
"unknown_firmware_policy": "reject-profile",
"request_topic_subscription_enabled": false,
"notes": [
"Loading this descriptive profile does not authorize a BLE or MQTT write.",
"The existing reviewed Wi-Fi provisioning procedure remains separately operator-confirmed and is not activated by this profile.",
"Observed LixelGO modeling requests describe the wire contract but remain non-replayable and write-disabled.",
"Unknown firmware, transport, topics, fields, and action responses fail closed."
]
},
"evidence_sources": [
{
"id": "wifi-provisioning-profile",
"kind": "reviewed-profile",
"path": "docs/04_K1_WIFI_PROVISIONING_PROFILE.md",
"scope": "Observed firmware, GATT UUIDs, 99-byte provisioning frame, status read and physical LAN association."
},
{
"id": "mqtt-stream-profile",
"kind": "reviewed-profile",
"path": "docs/05_K1_MQTT_STREAM_PROFILE.md",
"scope": "Direct-LAN MQTT transport, report topics, bounded point/pose codecs and static modeling-request mapping."
},
{
"id": "lab-001",
"kind": "redacted-physical-lab-report",
"path": "docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md",
"scope": "Physical BLE-to-Wi-Fi result, MQTT message counts, point/pose decode totals and negative camera observation."
},
{
"id": "live-viewer-profile",
"kind": "implemented-path-description",
"path": "docs/06_K1_LIVE_VIEWER.md",
"scope": "Raw-first live/replay path for point cloud and pose."
},
{
"id": "lab-002",
"kind": "redacted-physical-protocol-report",
"path": "docs/lab/002_LIXELGO_IPHONE_LOCAL_PROTOCOL_20260716.redacted.md",
"scope": "Owner-operated LixelGO start/stop mapping, local-only bounded traffic result and left/right RTSP/H.264 camera-preview discovery."
}
],
"transports": [
{
"id": "ble.wifi-bootstrap.fw3.v1",
"role": "bootstrap-and-status",
"protocol": "BLE GATT",
"service_uuid": "00007f00-0000-1000-8000-00805f9b34fb",
"characteristics": {
"wifi_request": "00007f01-0000-1000-8000-00805f9b34fb",
"wifi_status": "00007f02-0000-1000-8000-00805f9b34fb"
},
"reviewed_profile_id": "xgrids-k1-fw3-wifi-v1",
"request_frame_bytes": 99,
"status_semantics": {
"ap_baseline_ipv4": "192.168.56.1",
"lan_acceptance": "A non-AP private IPv4 must be observed and independently confirmed on the intended LAN."
},
"evidence": {
"observed": true,
"decoded": true,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"wifi-provisioning-profile",
"lab-001"
]
},
{
"id": "mqtt.direct-lan.fw3.v1",
"role": "report-data-plane",
"protocol": "MQTT 3.1.1",
"network": {
"transport": "TCP",
"port": 1883,
"tls": false,
"authentication": "none-observed",
"addressing": "confirmed-device-private-ipv4-only"
},
"subscription_allowlist": [
"lixel/application/report/#",
"RealtimePointcloud",
"RealtimePath",
"DeviceStatus"
],
"evidence": {
"observed": true,
"decoded": false,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"mqtt-stream-profile",
"lab-001",
"live-viewer-profile",
"lab-002"
]
},
{
"id": "rtsp.camera-preview.fw3.v1",
"role": "camera-preview-data-plane",
"protocol": "RTSP 1.0 with interleaved RTP over TCP",
"network": {
"transport": "TCP",
"port": 8554,
"tls": false,
"authentication": "none-observed",
"addressing": "confirmed-device-private-ipv4-only"
},
"media": {
"codec": "H.264",
"rtp_payload_type": 96,
"clock_hz": 90000,
"framing": "RTP/AVP/TCP interleaved channels 0-1"
},
"evidence": {
"observed": true,
"decoded": false,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"lab-002"
]
}
],
"channels": [
{
"id": "spatial.point-cloud.live",
"kind": "point-cloud",
"direction": "device-report",
"topic": "lixel/application/report/lio_pcl",
"wire_format": "protobuf MqttCompressMsg containing raw-LZ4 LioPclReport",
"semantic_payload": "metric XYZ, complete uint32 rgbi and verified low-byte intensity",
"bounds": {
"max_mqtt_payload_bytes": 2097152,
"max_compressed_bytes": 1048576,
"max_decoded_bytes": 8388608,
"max_expansion_ratio": 64,
"max_points_per_frame": 250000
},
"unverified_fields": [
"upper 24 bits of rgbi as RGB",
"sensor timestamp epoch",
"sensor-to-vehicle extrinsics"
],
"evidence": {
"observed": true,
"decoded": true,
"replay_verified": true,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"mqtt-stream-profile",
"lab-001",
"live-viewer-profile"
]
},
{
"id": "spatial.pose.live",
"kind": "pose",
"direction": "device-report",
"topic": "lixel/application/report/lio_pose",
"wire_format": "protobuf LioPoseReport",
"semantic_payload": "position XYZ, quaternion XYZW, distance and pose accuracy",
"unverified_fields": [
"sensor timestamp epoch",
"coordinate-frame convention",
"sensor-to-vehicle extrinsics"
],
"evidence": {
"observed": true,
"decoded": true,
"replay_verified": true,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"mqtt-stream-profile",
"lab-001",
"live-viewer-profile"
]
},
{
"id": "device.status.live",
"kind": "device-status",
"direction": "device-report",
"topic": "lixel/application/report/device_status",
"wire_format": "opaque bytes",
"semantic_payload": null,
"limitations": [
"The report was physically observed, but no bounded semantic device-status decoder is implemented.",
"Raw capture is evidence; field names or meanings must not be inferred from payload shape."
],
"evidence": {
"observed": true,
"decoded": false,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"mqtt-stream-profile",
"lab-001"
]
},
{
"id": "device.heartbeat.live",
"kind": "heartbeat",
"direction": "device-report",
"topic": "lixel/application/report/heartbeat",
"wire_format": "opaque bytes",
"semantic_payload": null,
"limitations": [
"Only channel presence and approximate report cadence are verified.",
"Payload semantics are not decoded."
],
"evidence": {
"observed": true,
"decoded": false,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"mqtt-stream-profile",
"lab-001"
]
},
{
"id": "camera.preview.live",
"kind": "camera-preview",
"direction": "device-report",
"discovery_status": "observed",
"topic": null,
"endpoint_templates": [
"rtsp://{confirmed-device-private-ipv4}:8554/live/chn_left_main",
"rtsp://{confirmed-device-private-ipv4}:8554/live/chn_right_main"
],
"wire_format": "RTSP 1.0, interleaved RTP/TCP, H.264 PT96 at 90000 Hz",
"semantic_payload": "compressed live left/right camera preview selected by endpoint",
"limitations": [
"No full-resolution raw frame, camera calibration or panorama-stitching contract is verified.",
"Left/right optical identity is supported by endpoint labels and operator-selected application views, not an independent image-content fixture.",
"A bounded Mission Core camera decoder and replay fixture are not yet implemented."
],
"evidence": {
"observed": true,
"decoded": false,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"lab-002"
]
}
],
"acquisition_control": {
"mode": "operator-manual",
"write_enabled": false,
"verified_device_control": {
"gesture": "physical-double-click",
"state_dependent_result": "start from steady-green standby; stop during active scanning",
"single_click_result": "not a verified scan-start action",
"evidence": {
"observed": true,
"decoded": false,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"lab-001"
]
},
"semantic_actions": [
{
"id": "acquisition.start",
"execution": "operator-manual",
"operator_control": "physical-double-click from steady-green standby",
"observed_application_control": "owner-operated LixelGO project confirmation",
"vendor_request_mapping": {
"evidence_kind": "owner-controlled-wire-observation",
"transport": "MQTT 3.1.1",
"topic": "lixel/application/request/modeling",
"qos": 2,
"message_type": "ModelingRequest",
"action_field_value": 1,
"required_unresolved_context": [
"complete device/session/OpenAPI header construction",
"project/record/scan/mount setting semantics and safe defaults",
"timeout, rejection and rollback contract"
],
"evidence": {
"observed": true,
"decoded": true,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"write_enabled": false
},
"evidence": {
"observed": true,
"decoded": true,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"mqtt-stream-profile",
"lab-001",
"lab-002"
]
},
{
"id": "acquisition.stop",
"execution": "operator-manual",
"operator_control": "physical-double-click during active scanning",
"observed_application_control": "owner-operated LixelGO stop confirmation",
"vendor_request_mapping": {
"evidence_kind": "owner-controlled-wire-observation",
"transport": "MQTT 3.1.1",
"topic": "lixel/application/request/modeling",
"qos": 2,
"message_type": "ModelingRequest",
"action_field_value": 2,
"required_unresolved_context": [
"complete device/session/OpenAPI header construction",
"save-completion and final-standby state mapping",
"timeout and rollback contract"
],
"evidence": {
"observed": true,
"decoded": true,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"write_enabled": false
},
"evidence": {
"observed": true,
"decoded": true,
"replay_verified": false,
"physical_verified": true,
"write_enabled": false
},
"source_ids": [
"mqtt-stream-profile",
"lab-001",
"lab-002"
]
},
{
"id": "calibration.device.start",
"execution": "unavailable",
"operator_control": null,
"vendor_request_mapping": null,
"observed_behavior": "Static initialization follows acquisition.start; no independent calibration action was observed.",
"limitations": [
"No standalone calibration command topic, request schema, acknowledgment or state transition is verified."
],
"evidence": {
"observed": false,
"decoded": false,
"replay_verified": false,
"physical_verified": false,
"write_enabled": false
},
"source_ids": [
"lab-002"
]
}
]
}
}

View File

@ -15,6 +15,7 @@ dependencies = [
"fastapi>=0.116,<1",
"foxglove-sdk==0.25.3",
"lz4>=4.4,<5",
"missioncore-plugin-sdk",
"paho-mqtt>=2.1,<3",
"rerun-sdk==0.34.1",
"rich>=13.9,<15",
@ -22,6 +23,9 @@ dependencies = [
"uvicorn[standard]>=0.35,<1",
]
[tool.uv.sources]
missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true }
[project.scripts]
k1link = "k1link.cli:app"

View File

@ -234,8 +234,7 @@ def summarize_mqtt_streams(
"""Stream a raw MQTT capture into an aggregate-only, coordinate-free summary."""
if not 1 <= max_payload_bytes <= MAX_STREAM_SUMMARY_PAYLOAD_BYTES:
raise ValueError(
"max_payload_bytes must be between 1 and "
f"{MAX_STREAM_SUMMARY_PAYLOAD_BYTES}"
f"max_payload_bytes must be between 1 and {MAX_STREAM_SUMMARY_PAYLOAD_BYTES}"
)
capture_path = capture.expanduser()

View File

@ -179,9 +179,7 @@ async def provision_wifi_once(
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
device_name = client.name
service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(
WRITE_CHARACTERISTIC_UUID
)
write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID)
status_characteristic = client.services.get_characteristic(
STATUS_CHARACTERISTIC_UUID
)
@ -189,13 +187,11 @@ async def provision_wifi_once(
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
if write_characteristic is None:
raise ValueError(
"Reviewed K1 write characteristic not found: "
f"{WRITE_CHARACTERISTIC_UUID}"
f"Reviewed K1 write characteristic not found: {WRITE_CHARACTERISTIC_UUID}"
)
if status_characteristic is None:
raise ValueError(
"Reviewed K1 status characteristic not found: "
f"{STATUS_CHARACTERISTIC_UUID}"
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
)
if write_characteristic.service_uuid != service.uuid:
raise ValueError("K1 write characteristic is attached to an unexpected service")
@ -207,9 +203,7 @@ async def provision_wifi_once(
raise ValueError("Reviewed K1 status characteristic is not readable")
properties = set(write_characteristic.properties)
max_without_response = (
write_characteristic.max_write_without_response_size
)
max_without_response = write_characteristic.max_write_without_response_size
resolved_write_mode: ResolvedWriteMode
if write_mode == "auto":
if "write-without-response" in properties:
@ -276,9 +270,7 @@ async def provision_wifi_once(
"status_characteristic_uuid": status_characteristic.uuid,
"operation": "single_reviewed_wifi_provisioning_write",
"write_mode": resolved_write_mode,
"write_without_response_advertised": (
"write-without-response" in properties
),
"write_without_response_advertised": ("write-without-response" in properties),
"max_write_without_response_size": max_without_response,
"frame_length": len(frame),
"baseline_status": baseline,

View File

@ -0,0 +1,26 @@
"""Decoded in-process projections consumed by Mission Core visualizers.
These views are not wire contracts. Portable plugin/host envelopes live in the
versioned Plugin SDK; an extraction boundary can hydrate those envelopes into
these allocation-conscious representations for local consumers.
"""
from k1link.data_plane.views import (
ConsumerFrameContext,
DecodedDataPlaneView,
DecodedDeviceStatusView,
DecodedPointCloudView,
DecodedPoseView,
NormalizationError,
StatusAttribute,
)
__all__ = [
"ConsumerFrameContext",
"DecodedDataPlaneView",
"DecodedDeviceStatusView",
"DecodedPointCloudView",
"DecodedPoseView",
"NormalizationError",
"StatusAttribute",
]

View File

@ -0,0 +1,128 @@
from __future__ import annotations
import math
from dataclasses import dataclass
class NormalizationError(ValueError):
"""A transport message matched a known channel but could not be normalized."""
@dataclass(frozen=True, slots=True)
class ConsumerFrameContext:
"""Transport-neutral provenance shared by decoded consumer views.
Raw channel names and payloads deliberately do not cross this boundary. The
byte count is retained for operational metrics, while opaque device/session
aliases are optional because older streams do not carry them. They are
deliberately named as source aliases: a vendor header must never become a
Mission Core device identity merely by crossing the decode boundary.
"""
sequence: int
captured_at_epoch_ns: int
received_monotonic_ns: int | None
processing_started_monotonic_ns: int
encoded_size_bytes: int
live: bool
source_device_alias: str | None = None
source_session_alias: str | None = None
def __post_init__(self) -> None:
if self.sequence < 1:
raise ValueError("frame sequence must be positive")
if self.captured_at_epoch_ns < 0:
raise ValueError("capture time must be non-negative")
if self.received_monotonic_ns is not None and self.received_monotonic_ns < 0:
raise ValueError("receive monotonic time must be non-negative")
if self.processing_started_monotonic_ns < 0:
raise ValueError("processing start time must be non-negative")
if self.encoded_size_bytes < 0:
raise ValueError("encoded size must be non-negative")
@dataclass(frozen=True, slots=True)
class DecodedPointCloudView:
"""In-process point-cloud projection in a named Cartesian frame.
This is a consumer view, not the portable SDK ``PointCloudFrame`` contract.
"""
context: ConsumerFrameContext
frame_id: str
positions_xyz: tuple[tuple[float, float, float], ...]
intensities: bytes | None = None
colors_rgb: bytes | None = None
def __post_init__(self) -> None:
if not self.frame_id:
raise ValueError("point-cloud frame_id must not be empty")
point_count = len(self.positions_xyz)
if self.intensities is not None and len(self.intensities) != point_count:
raise ValueError("intensity count must equal point count")
if self.colors_rgb is not None and len(self.colors_rgb) != point_count * 3:
raise ValueError("RGB byte count must equal point count * 3")
if not all(
len(position) == 3 and all(math.isfinite(value) for value in position)
for position in self.positions_xyz
):
raise ValueError("point positions must contain finite xyz triples")
@property
def point_count(self) -> int:
return len(self.positions_xyz)
@dataclass(frozen=True, slots=True)
class DecodedPoseView:
"""In-process pose projection in a named Cartesian coordinate frame."""
context: ConsumerFrameContext
frame_id: str
child_frame_id: str
position_xyz: tuple[float, float, float]
orientation_xyzw: tuple[float, float, float, float]
def __post_init__(self) -> None:
if not self.frame_id or not self.child_frame_id:
raise ValueError("pose frame identifiers must not be empty")
values = (*self.position_xyz, *self.orientation_xyzw)
if not all(math.isfinite(value) for value in values):
raise ValueError("pose values must be finite")
@dataclass(frozen=True, slots=True)
class StatusAttribute:
"""One stable, normalized status attribute."""
name: str
value: bool | int | float | str
def __post_init__(self) -> None:
if not self.name:
raise ValueError("status attribute name must not be empty")
if isinstance(self.value, float) and not math.isfinite(self.value):
raise ValueError("status attribute float must be finite")
@dataclass(frozen=True, slots=True)
class DecodedDeviceStatusView:
"""An in-process device-status projection.
The current verified K1 profile does not yet decode its status topic. The
view exists so future verified status codecs do not alter consumers.
"""
context: ConsumerFrameContext
state: str
attributes: tuple[StatusAttribute, ...] = ()
def __post_init__(self) -> None:
if not self.state:
raise ValueError("device status state must not be empty")
names = [attribute.name for attribute in self.attributes]
if len(names) != len(set(names)):
raise ValueError("device status attribute names must be unique")
DecodedDataPlaneView = DecodedPointCloudView | DecodedPoseView | DecodedDeviceStatusView

View File

@ -130,7 +130,7 @@ class CaptureFrame:
@dataclass(frozen=True, slots=True)
class CapturedMqttMessage:
"""A message made durable by the raw writer and ready for live preview."""
"""A message flushed to the raw writer before it is exposed to live preview."""
sequence: int
topic: str

View File

@ -0,0 +1,182 @@
from __future__ import annotations
from typing import Protocol
from k1link.data_plane import (
ConsumerFrameContext,
DecodedDataPlaneView,
DecodedPointCloudView,
DecodedPoseView,
NormalizationError,
)
from k1link.protocol.streams import (
LegacyPointCloudFrame,
LegacyPoseFrame,
LioPointCloudFrame,
LioPoseFrame,
StreamDecodeError,
decode_legacy_pointcloud,
decode_legacy_pose,
decode_lio_pcl,
decode_lio_pose,
)
CANONICAL_MAP_FRAME = "map"
CANONICAL_SENSOR_FRAME = "sensor"
class K1TransportMessage(Protocol):
"""Minimum raw transport shape accepted by the K1 normalizer."""
@property
def sequence(self) -> int: ...
@property
def topic(self) -> str: ...
@property
def payload(self) -> bytes: ...
@property
def received_at_epoch_ns(self) -> int: ...
@property
def received_monotonic_ns(self) -> int | None: ...
@property
def source(self) -> str: ...
def normalize_k1_message(
message: K1TransportMessage,
*,
processing_started_monotonic_ns: int,
) -> DecodedDataPlaneView | None:
"""Normalize one verified K1 transport message.
Unknown channels intentionally return ``None``. A known channel with an
invalid payload raises a transport-neutral ``NormalizationError``. Neither
raw channel names nor raw bytes are retained in the returned consumer view.
"""
try:
if message.topic.endswith("/lio_pcl"):
return _normalize_lio_points(
decode_lio_pcl(message.payload),
_context(message, processing_started_monotonic_ns),
)
if message.topic == "RealtimePointcloud":
return _normalize_legacy_points(
decode_legacy_pointcloud(message.payload),
_context(message, processing_started_monotonic_ns),
)
if message.topic.endswith("/lio_pose"):
return _normalize_lio_pose(
decode_lio_pose(message.payload),
_context(message, processing_started_monotonic_ns),
)
if message.topic == "RealtimePath":
return _normalize_legacy_pose(
decode_legacy_pose(message.payload),
_context(message, processing_started_monotonic_ns),
)
except StreamDecodeError as exc:
raise NormalizationError("known K1 data-plane message failed validation") from exc
return None
def _context(
message: K1TransportMessage,
processing_started_monotonic_ns: int,
) -> ConsumerFrameContext:
return ConsumerFrameContext(
sequence=message.sequence,
captured_at_epoch_ns=message.received_at_epoch_ns,
received_monotonic_ns=message.received_monotonic_ns,
processing_started_monotonic_ns=processing_started_monotonic_ns,
encoded_size_bytes=len(message.payload),
live=message.source == "live_mqtt",
)
def _with_device_context(
context: ConsumerFrameContext,
*,
source_device_alias: str,
source_session_alias: str,
) -> ConsumerFrameContext:
return ConsumerFrameContext(
sequence=context.sequence,
captured_at_epoch_ns=context.captured_at_epoch_ns,
received_monotonic_ns=context.received_monotonic_ns,
processing_started_monotonic_ns=context.processing_started_monotonic_ns,
encoded_size_bytes=context.encoded_size_bytes,
live=context.live,
source_device_alias=source_device_alias or None,
source_session_alias=source_session_alias or None,
)
def _normalize_lio_points(
frame: LioPointCloudFrame,
context: ConsumerFrameContext,
) -> DecodedPointCloudView:
context = _with_device_context(
context,
source_device_alias=frame.header.device_id,
source_session_alias=frame.header.session_id,
)
positions = tuple(point.scaled_xyz(frame.header.scaler) for point in frame.points)
intensities = bytes(point.intensity for point in frame.points)
return DecodedPointCloudView(
context=context,
frame_id=CANONICAL_MAP_FRAME,
positions_xyz=positions,
intensities=intensities,
)
def _normalize_legacy_points(
frame: LegacyPointCloudFrame,
context: ConsumerFrameContext,
) -> DecodedPointCloudView:
return DecodedPointCloudView(
context=context,
frame_id=CANONICAL_MAP_FRAME,
positions_xyz=tuple((point.x, point.y, point.z) for point in frame.points),
intensities=bytes(point.intensity for point in frame.points),
colors_rgb=bytes(
channel for point in frame.points for channel in (point.r, point.g, point.b)
),
)
def _normalize_lio_pose(
frame: LioPoseFrame,
context: ConsumerFrameContext,
) -> DecodedPoseView:
context = _with_device_context(
context,
source_device_alias=frame.header.device_id,
source_session_alias=frame.header.session_id,
)
return DecodedPoseView(
context=context,
frame_id=CANONICAL_MAP_FRAME,
child_frame_id=CANONICAL_SENSOR_FRAME,
position_xyz=frame.position_xyz,
orientation_xyzw=frame.orientation_xyzw,
)
def _normalize_legacy_pose(
frame: LegacyPoseFrame,
context: ConsumerFrameContext,
) -> DecodedPoseView:
return DecodedPoseView(
context=context,
frame_id=CANONICAL_MAP_FRAME,
child_frame_id=CANONICAL_SENSOR_FRAME,
position_xyz=frame.position_xyz,
orientation_xyzw=frame.orientation_xyzw,
)

View File

@ -1,13 +1,9 @@
from __future__ import annotations
import math
import statistics
import struct
import threading
import time
from collections import deque
from dataclasses import dataclass
from typing import TypedDict
import foxglove
from foxglove import Channel
@ -45,6 +41,7 @@ from k1link.protocol.streams import (
decode_lio_pose,
)
from k1link.viewer.messages import StreamMessage
from k1link.viewer.metrics import BridgeMetrics
POINT_STRUCT = struct.Struct("<fffB3x")
POINT_STRIDE = POINT_STRUCT.size
@ -52,113 +49,12 @@ FRAME_ID = "map"
MAX_TRAJECTORY_POSES = 20_000
class MetricsSnapshot(TypedDict):
messages_received: int
payload_bytes: int
pcl_frames: int
pose_frames: int
points_published: int
last_point_count: int
decode_errors: int
preview_dropped: int
pcl_fps: float
pose_fps: float
mqtt_to_publish_ms: float | None
mqtt_to_publish_p50_ms: float | None
mqtt_to_publish_p95_ms: float | None
decode_publish_ms: float | None
trajectory_poses: int
@dataclass(frozen=True, slots=True)
class PackedPointCloud:
data: bytes
point_count: int
class BridgeMetrics:
"""Thread-safe counters shared with the local control API."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._messages_received = 0
self._payload_bytes = 0
self._pcl_frames = 0
self._pose_frames = 0
self._points_published = 0
self._last_point_count = 0
self._decode_errors = 0
self._preview_dropped = 0
self._trajectory_poses = 0
self._pcl_times: deque[int] = deque()
self._pose_times: deque[int] = deque()
self._latencies_ms: deque[float] = deque(maxlen=512)
self._decode_publish_ms: float | None = None
def received(self, payload_bytes: int) -> None:
with self._lock:
self._messages_received += 1
self._payload_bytes += payload_bytes
def published_pcl(self, point_count: int, now_ns: int, decode_publish_ms: float) -> None:
with self._lock:
self._pcl_frames += 1
self._points_published += point_count
self._last_point_count = point_count
self._decode_publish_ms = decode_publish_ms
self._pcl_times.append(now_ns)
_trim_rate_window(self._pcl_times, now_ns)
def published_pose(self, now_ns: int, decode_publish_ms: float, path_size: int) -> None:
with self._lock:
self._pose_frames += 1
self._decode_publish_ms = decode_publish_ms
self._trajectory_poses = path_size
self._pose_times.append(now_ns)
_trim_rate_window(self._pose_times, now_ns)
def record_latency(self, milliseconds: float) -> None:
if not math.isfinite(milliseconds) or milliseconds < 0:
return
with self._lock:
self._latencies_ms.append(milliseconds)
def decode_error(self) -> None:
with self._lock:
self._decode_errors += 1
def preview_dropped(self) -> None:
with self._lock:
self._preview_dropped += 1
def snapshot(self) -> MetricsSnapshot:
now_ns = time.monotonic_ns()
with self._lock:
_trim_rate_window(self._pcl_times, now_ns)
_trim_rate_window(self._pose_times, now_ns)
latencies = list(self._latencies_ms)
last_latency = latencies[-1] if latencies else None
p50 = statistics.median(latencies) if latencies else None
p95 = _percentile(latencies, 0.95) if latencies else None
return {
"messages_received": self._messages_received,
"payload_bytes": self._payload_bytes,
"pcl_frames": self._pcl_frames,
"pose_frames": self._pose_frames,
"points_published": self._points_published,
"last_point_count": self._last_point_count,
"decode_errors": self._decode_errors,
"preview_dropped": self._preview_dropped,
"pcl_fps": _window_rate(self._pcl_times),
"pose_fps": _window_rate(self._pose_times),
"mqtt_to_publish_ms": _rounded(last_latency),
"mqtt_to_publish_p50_ms": _rounded(p50),
"mqtt_to_publish_p95_ms": _rounded(p95),
"decode_publish_ms": _rounded(self._decode_publish_ms),
"trajectory_poses": self._trajectory_poses,
}
class FoxgloveBridge:
"""Decode verified K1 topics and publish Foxglove-native visualization messages."""
@ -390,28 +286,3 @@ def _identity_pose() -> Pose:
def _timestamp(epoch_ns: int) -> Timestamp:
return Timestamp(epoch_ns // 1_000_000_000, epoch_ns % 1_000_000_000)
def _trim_rate_window(values: deque[int], now_ns: int) -> None:
cutoff = now_ns - 1_000_000_000
while values and values[0] < cutoff:
values.popleft()
def _window_rate(values: deque[int]) -> float:
if len(values) < 2:
return float(len(values))
elapsed = (values[-1] - values[0]) / 1_000_000_000
return len(values) / max(elapsed, 1.0)
def _percentile(values: list[float], fraction: float) -> float:
if not values:
raise ValueError("cannot calculate a percentile of an empty sample")
ordered = sorted(values)
index = min(len(ordered) - 1, math.ceil(len(ordered) * fraction) - 1)
return ordered[index]
def _rounded(value: float | None) -> float | None:
return None if value is None else round(value, 3)

View File

@ -0,0 +1,134 @@
from __future__ import annotations
import math
import statistics
import threading
import time
from collections import deque
from typing import TypedDict
class MetricsSnapshot(TypedDict):
messages_received: int
payload_bytes: int
pcl_frames: int
pose_frames: int
points_published: int
last_point_count: int
decode_errors: int
preview_dropped: int
pcl_fps: float
pose_fps: float
mqtt_to_publish_ms: float | None
mqtt_to_publish_p50_ms: float | None
mqtt_to_publish_p95_ms: float | None
decode_publish_ms: float | None
trajectory_poses: int
class BridgeMetrics:
"""Thread-safe counters shared by canonical visualization consumers."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._messages_received = 0
self._payload_bytes = 0
self._pcl_frames = 0
self._pose_frames = 0
self._points_published = 0
self._last_point_count = 0
self._decode_errors = 0
self._preview_dropped = 0
self._trajectory_poses = 0
self._pcl_times: deque[int] = deque()
self._pose_times: deque[int] = deque()
self._latencies_ms: deque[float] = deque(maxlen=512)
self._decode_publish_ms: float | None = None
def received(self, payload_bytes: int) -> None:
with self._lock:
self._messages_received += 1
self._payload_bytes += payload_bytes
def published_pcl(self, point_count: int, now_ns: int, decode_publish_ms: float) -> None:
with self._lock:
self._pcl_frames += 1
self._points_published += point_count
self._last_point_count = point_count
self._decode_publish_ms = decode_publish_ms
self._pcl_times.append(now_ns)
_trim_rate_window(self._pcl_times, now_ns)
def published_pose(self, now_ns: int, decode_publish_ms: float, path_size: int) -> None:
with self._lock:
self._pose_frames += 1
self._decode_publish_ms = decode_publish_ms
self._trajectory_poses = path_size
self._pose_times.append(now_ns)
_trim_rate_window(self._pose_times, now_ns)
def record_latency(self, milliseconds: float) -> None:
if not math.isfinite(milliseconds) or milliseconds < 0:
return
with self._lock:
self._latencies_ms.append(milliseconds)
def decode_error(self) -> None:
with self._lock:
self._decode_errors += 1
def preview_dropped(self) -> None:
with self._lock:
self._preview_dropped += 1
def snapshot(self) -> MetricsSnapshot:
now_ns = time.monotonic_ns()
with self._lock:
_trim_rate_window(self._pcl_times, now_ns)
_trim_rate_window(self._pose_times, now_ns)
latencies = list(self._latencies_ms)
last_latency = latencies[-1] if latencies else None
p50 = statistics.median(latencies) if latencies else None
p95 = _percentile(latencies, 0.95) if latencies else None
return {
"messages_received": self._messages_received,
"payload_bytes": self._payload_bytes,
"pcl_frames": self._pcl_frames,
"pose_frames": self._pose_frames,
"points_published": self._points_published,
"last_point_count": self._last_point_count,
"decode_errors": self._decode_errors,
"preview_dropped": self._preview_dropped,
"pcl_fps": _window_rate(self._pcl_times),
"pose_fps": _window_rate(self._pose_times),
"mqtt_to_publish_ms": _rounded(last_latency),
"mqtt_to_publish_p50_ms": _rounded(p50),
"mqtt_to_publish_p95_ms": _rounded(p95),
"decode_publish_ms": _rounded(self._decode_publish_ms),
"trajectory_poses": self._trajectory_poses,
}
def _trim_rate_window(samples: deque[int], now_ns: int) -> None:
cutoff_ns = now_ns - 1_000_000_000
while samples and samples[0] < cutoff_ns:
samples.popleft()
def _window_rate(samples: deque[int]) -> float:
if len(samples) < 2:
return float(len(samples))
elapsed = (samples[-1] - samples[0]) / 1_000_000_000
return len(samples) / max(elapsed, 1.0)
def _percentile(values: list[float], quantile: float) -> float:
if not values:
raise ValueError("values must not be empty")
ordered = sorted(values)
index = max(0, min(len(ordered) - 1, math.ceil(len(ordered) * quantile) - 1))
return ordered[index]
def _rounded(value: float | None) -> float | None:
return None if value is None else round(value, 3)

View File

@ -11,19 +11,12 @@ import numpy as np
import rerun as rr
from rerun import blueprint as rrb
from k1link.protocol.streams import (
LegacyPointCloudFrame,
LegacyPoseFrame,
LioPointCloudFrame,
LioPoseFrame,
StreamDecodeError,
decode_legacy_pointcloud,
decode_legacy_pose,
decode_lio_pcl,
decode_lio_pose,
from k1link.data_plane import (
DecodedDataPlaneView,
DecodedPointCloudView,
DecodedPoseView,
)
from k1link.viewer.foxglove_bridge import BridgeMetrics
from k1link.viewer.messages import StreamMessage
from k1link.viewer.metrics import BridgeMetrics
PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"]
PointPalette = Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
@ -59,7 +52,7 @@ SettingsProvider = Callable[[], RerunSceneSettings]
class RerunBridge:
"""Decode verified device topics into a self-hosted Rerun recording stream."""
"""Publish transport-neutral canonical envelopes to a Rerun recording."""
def __init__(
self,
@ -73,9 +66,7 @@ class RerunBridge:
self.metrics = metrics or BridgeMetrics()
self._settings_provider = settings_provider or RerunSceneSettings
self._settings = self._settings_provider()
self._recording = (recording_factory or rr.RecordingStream)(
"nodedc_mission_core_spatial"
)
self._recording = (recording_factory or rr.RecordingStream)("nodedc_mission_core_spatial")
blueprint = _blueprint(self._settings)
self._url = self._recording.serve_grpc(
grpc_port=grpc_port,
@ -95,9 +86,7 @@ class RerunBridge:
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
static=True,
)
self._path: deque[tuple[float, float, float]] = deque(
maxlen=MAX_TRAJECTORY_POSES
)
self._path: deque[tuple[float, float, float]] = deque(maxlen=MAX_TRAJECTORY_POSES)
self._last_trajectory_publish_ns = 0
self._last_point_count = 0
self._closed = False
@ -130,33 +119,23 @@ class RerunBridge:
make_default=True,
)
def process(self, message: StreamMessage) -> None:
started_ns = time.monotonic_ns()
self.metrics.received(len(message.payload))
def process(self, envelope: DecodedDataPlaneView) -> None:
self._apply_latest_settings()
self._set_message_time(message)
self._set_message_time(envelope)
try:
if message.topic.endswith("/lio_pcl"):
self._publish_lio_pcl(decode_lio_pcl(message.payload))
point_frame = True
elif message.topic == "RealtimePointcloud":
self._publish_legacy_pcl(decode_legacy_pointcloud(message.payload))
point_frame = True
elif message.topic.endswith("/lio_pose"):
self._publish_lio_pose(decode_lio_pose(message.payload))
point_frame = False
elif message.topic == "RealtimePath":
self._publish_legacy_pose(decode_legacy_pose(message.payload))
point_frame = False
else:
return
except StreamDecodeError:
self.metrics.decode_error()
if isinstance(envelope, DecodedPointCloudView):
self._publish_points(envelope)
point_frame = True
elif isinstance(envelope, DecodedPoseView):
self._publish_pose(envelope)
point_frame = False
else:
return
published_ns = time.monotonic_ns()
decode_publish_ms = (published_ns - started_ns) / 1_000_000
decode_publish_ms = (
published_ns - envelope.context.processing_started_monotonic_ns
) / 1_000_000
if point_frame:
self.metrics.published_pcl(
self._last_point_count,
@ -169,10 +148,9 @@ class RerunBridge:
decode_publish_ms,
len(self._path),
)
if message.source == "live_mqtt" and message.received_monotonic_ns is not None:
self.metrics.record_latency(
(published_ns - message.received_monotonic_ns) / 1_000_000
)
context = envelope.context
if context.live and context.received_monotonic_ns is not None:
self.metrics.record_latency((published_ns - context.received_monotonic_ns) / 1_000_000)
def close(self) -> None:
if self._closed:
@ -187,13 +165,14 @@ class RerunBridge:
# instead of waiting for the publisher thread frame to be collected.
del self._recording
def _set_message_time(self, message: StreamMessage) -> None:
def _set_message_time(self, envelope: DecodedDataPlaneView) -> None:
context = envelope.context
self._recording.set_time("stream_time", timestamp=time.time())
self._recording.set_time(
"capture_time",
timestamp=message.received_at_epoch_ns / 1_000_000_000,
timestamp=context.captured_at_epoch_ns / 1_000_000_000,
)
self._recording.set_time("message_sequence", sequence=message.sequence)
self._recording.set_time("message_sequence", sequence=context.sequence)
def _apply_latest_settings(self) -> None:
settings = self._settings_provider()
@ -202,34 +181,17 @@ class RerunBridge:
self._settings = settings
self._recording.send_blueprint(_blueprint(settings))
def _publish_lio_pcl(self, frame: LioPointCloudFrame) -> None:
count = len(frame.points)
positions = np.empty((count, 3), dtype=np.float32)
intensities = np.empty(count, dtype=np.uint8)
scaler = frame.header.scaler
for index, point in enumerate(frame.points):
positions[index] = point.scaled_xyz(scaler)
intensities[index] = point.intensity
self._publish_points(positions, intensities, rgb=None)
def _publish_legacy_pcl(self, frame: LegacyPointCloudFrame) -> None:
count = len(frame.points)
positions = np.empty((count, 3), dtype=np.float32)
intensities = np.empty(count, dtype=np.uint8)
rgb = np.empty((count, 3), dtype=np.uint8)
for index, point in enumerate(frame.points):
positions[index] = (point.x, point.y, point.z)
intensities[index] = point.intensity
rgb[index] = (point.r, point.g, point.b)
self._publish_points(positions, intensities, rgb=rgb)
def _publish_points(
self,
positions: np.ndarray,
intensities: np.ndarray,
*,
rgb: np.ndarray | None,
) -> None:
def _publish_points(self, frame: DecodedPointCloudView) -> None:
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
if frame.intensities is None:
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
else:
intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
rgb = (
None
if frame.colors_rgb is None
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
)
self._last_point_count = int(positions.shape[0])
if not self._settings.show_points:
self._recording.log("/world/points", rr.Clear(recursive=False))
@ -244,25 +206,15 @@ class RerunBridge:
),
)
def _publish_lio_pose(self, frame: LioPoseFrame) -> None:
self._publish_pose(frame.position_xyz, frame.orientation_xyzw)
def _publish_legacy_pose(self, frame: LegacyPoseFrame) -> None:
self._publish_pose(frame.position_xyz, frame.orientation_xyzw)
def _publish_pose(
self,
position_xyz: tuple[float, float, float],
orientation_xyzw: tuple[float, float, float, float],
) -> None:
def _publish_pose(self, frame: DecodedPoseView) -> None:
self._recording.log(
"/world/sensor_pose",
rr.Transform3D(
translation=position_xyz,
quaternion=rr.Quaternion(xyzw=orientation_xyzw),
translation=frame.position_xyz,
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
),
)
self._path.append(position_xyz)
self._path.append(frame.position_xyz)
if not self._settings.show_trajectory:
self._recording.log("/world/trajectory", rr.Clear(recursive=False))
return
@ -283,6 +235,7 @@ class RerunBridge:
),
)
def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds)
time_range = rr.VisibleTimeRange(

View File

@ -7,12 +7,13 @@ import time
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal, TypedDict
from typing import Literal, Protocol, TypedDict
from k1link.artifacts import utc_now_iso, write_json_atomic
from k1link.data_plane import DecodedDataPlaneView, NormalizationError
from k1link.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
from k1link.viewer.foxglove_bridge import BridgeMetrics, MetricsSnapshot
from k1link.viewer.messages import StreamMessage
from k1link.viewer.metrics import BridgeMetrics, MetricsSnapshot
from k1link.viewer.replay import iter_replay_messages
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
@ -29,10 +30,20 @@ StateCallback = Callable[[], None]
BridgeFactory = Callable[..., RerunBridge]
class CanonicalNormalizer(Protocol):
def __call__(
self,
message: StreamMessage,
*,
processing_started_monotonic_ns: int,
) -> DecodedDataPlaneView | None: ...
class RuntimeSnapshot(TypedDict):
phase: RuntimePhase
message: str
source_mode: SourceMode
source_ready: bool
foxglove_ws_url: str | None
foxglove_viewer_url: str | None
rerun_grpc_url: str | None
@ -49,6 +60,7 @@ class VisualizationRuntime:
on_state_change: StateCallback | None = None,
grpc_port: int = DEFAULT_GRPC_PORT,
bridge_factory: BridgeFactory | None = None,
normalizer: CanonicalNormalizer,
) -> None:
self._lock = threading.Lock()
self._on_state_change = on_state_change
@ -57,11 +69,13 @@ class VisualizationRuntime:
self._phase: RuntimePhase = "idle"
self._message = "Готово. Включите устройство и начните с поиска по Bluetooth."
self._source_mode: SourceMode = "idle"
self._source_ready = False
self._foxglove_ws_url: str | None = None
self._foxglove_viewer_url: str | None = None
self._rerun_grpc_url: str | None = None
self._grpc_port = grpc_port
self._bridge_factory = bridge_factory or RerunBridge
self._normalizer = normalizer
self._bridge: RerunBridge | None = None
self._closed = False
self._scene_settings = RerunSceneSettings()
@ -73,6 +87,7 @@ class VisualizationRuntime:
"phase": self._phase,
"message": self._message,
"source_mode": self._source_mode,
"source_ready": self._source_ready,
"foxglove_ws_url": self._foxglove_ws_url,
"foxglove_viewer_url": self._foxglove_viewer_url,
"rerun_grpc_url": self._rerun_grpc_url,
@ -135,6 +150,7 @@ class VisualizationRuntime:
if thread is None or not thread.is_alive():
self._phase = "idle"
self._source_mode = "idle"
self._source_ready = False
self._message = "Активного потока нет."
notify_only = True
else:
@ -161,6 +177,7 @@ class VisualizationRuntime:
else:
self._phase = "idle"
self._source_mode = "idle"
self._source_ready = False
self._message = "Локальный поток завершён."
self._notify()
@ -194,17 +211,34 @@ class VisualizationRuntime:
self._metrics = BridgeMetrics()
self._phase = phase
self._source_mode = source_mode
self._source_ready = False
self._message = message
self._foxglove_ws_url = None
self._foxglove_viewer_url = None
self._thread = threading.Thread(
target=target,
target=lambda: self._run_target_safely(target, source_mode),
name=f"k1-{source_mode}-session",
daemon=True,
)
self._thread.start()
self._notify()
def _run_target_safely(
self,
target: Callable[[], None],
source_mode: SourceMode,
) -> None:
"""Convert setup failures before the pipeline into observable runtime state."""
try:
target()
except BaseException as exc:
with self._lock:
closed = self._closed
if closed:
return
self._finish_error(f"Ошибка {source_mode}-источника: {type(exc).__name__}: {exc}")
def _run_replay(self, path: Path, *, speed: float, loop: bool) -> None:
def produce(put: Callable[[StreamMessage], None]) -> str:
while not self._stop_event.is_set():
@ -324,8 +358,9 @@ class VisualizationRuntime:
publisher_aborted.set()
else:
self._rerun_grpc_url = bridge.grpc_url
if not self._closed and self._phase != "stopping":
if running_phase == "replay" and not self._closed and self._phase != "stopping":
self._phase = running_phase
self._source_ready = True
self._message = "Локальный Rerun-мост готов; источник данных запущен."
publisher_ready.set()
self._notify()
@ -339,7 +374,18 @@ class VisualizationRuntime:
except queue.Empty:
continue
try:
bridge.process(message)
processing_started_ns = time.monotonic_ns()
self._metrics.received(len(message.payload))
try:
envelope = self._normalizer(
message,
processing_started_monotonic_ns=processing_started_ns,
)
except NormalizationError:
self._metrics.decode_error()
else:
if envelope is not None:
bridge.process(envelope)
finally:
messages.task_done()
if self._metrics.snapshot()["messages_received"] % 10 == 0:
@ -413,6 +459,7 @@ class VisualizationRuntime:
with self._lock:
if self._phase != "stopping":
self._phase = phase
self._source_ready = True
self._message = message
self._notify()
@ -420,6 +467,7 @@ class VisualizationRuntime:
with self._lock:
self._phase = "idle"
self._source_mode = "idle"
self._source_ready = False
self._message = message
self._foxglove_ws_url = None
self._foxglove_viewer_url = None
@ -428,6 +476,7 @@ class VisualizationRuntime:
def _finish_error(self, message: str) -> None:
with self._lock:
self._phase = "error"
self._source_ready = False
self._message = message
self._foxglove_ws_url = None
self._foxglove_viewer_url = None

View File

@ -6,7 +6,9 @@ from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
@ -23,6 +25,7 @@ from k1link.web.plugin_runtime import (
)
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
@ -48,6 +51,16 @@ app = FastAPI(
)
@app.exception_handler(RequestValidationError)
async def request_validation_error_handler(
_: Request,
__: RequestValidationError,
) -> JSONResponse:
"""Return validation failures without reflecting request values or credentials."""
return JSONResponse(status_code=422, content={"detail": INVALID_REQUEST_DETAIL})
@app.get("/api/health")
def health() -> dict[str, Any]:
return {
@ -86,7 +99,7 @@ async def invoke_device_plugin_action(
except (PluginNotFoundError, PluginActionNotFoundError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
raise HTTPException(status_code=422, detail=INVALID_REQUEST_DETAIL) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PluginExecutionError as exc:

View File

@ -0,0 +1,391 @@
from __future__ import annotations
import threading
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
from uuid import UUID, uuid4
OperationStatus = Literal[
"accepted",
"running",
"operator_action_required",
"succeeded",
"failed",
"cancelled",
"timed_out",
"interrupted",
]
AcquisitionState = Literal[
"preparing",
"prepared",
"awaiting_external_start",
"starting",
"acquiring",
"awaiting_external_stop",
"stopping",
"finalizing",
"completed",
"failed",
"aborted",
"interrupted",
]
ControlMode = Literal["operator-manual", "plugin-commanded", "observe-only"]
TERMINAL_OPERATION_STATUSES: frozenset[OperationStatus] = frozenset(
{"succeeded", "failed", "cancelled", "timed_out", "interrupted"}
)
TERMINAL_ACQUISITION_STATES: frozenset[AcquisitionState] = frozenset(
{"completed", "failed", "aborted", "interrupted"}
)
def utc_now() -> datetime:
return datetime.now(UTC)
def _iso(value: datetime | None) -> str | None:
return value.isoformat().replace("+00:00", "Z") if value is not None else None
def _identifier(value: str | None, *, prefix: str) -> str:
if value is None:
return f"{prefix}-{uuid4()}"
candidate = value.strip()
if not candidate or len(candidate) > 128:
raise ValueError(f"{prefix} id must contain 1..128 characters")
try:
UUID(candidate.removeprefix(f"{prefix}-"))
except ValueError as exc:
raise ValueError(f"{prefix} id must be a generated UUID identifier") from exc
return candidate
@dataclass(slots=True)
class OperationRecord:
operation_id: str
action: str
status: OperationStatus
accepted_at: datetime
device_id: str | None = None
device_session_id: str | None = None
idempotency_key: str | None = None
deadline_at: datetime | None = None
stage_code: str = "accepted"
message_code: str = "operation.accepted"
sequence: int = 1
state_revision: int = 1
completed_at: datetime | None = None
cancellable: bool = False
cancel_requested: bool = False
result: dict[str, Any] | None = None
error: dict[str, Any] | None = None
evidence_refs: tuple[str, ...] = ()
# A keyed, non-reversible digest supplied by the service. It is deliberately
# excluded from API snapshots: callers only need mismatch detection, while
# the journal must never retain action inputs or secret material.
request_fingerprint: str | None = field(default=None, repr=False)
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.operation-snapshot/v1alpha2",
"operation_id": self.operation_id,
"action": self.action,
"status": self.status,
"accepted_at": _iso(self.accepted_at),
"completed_at": _iso(self.completed_at),
"deadline_at": _iso(self.deadline_at),
"device_id": self.device_id,
"device_session_id": self.device_session_id,
"idempotency_key": self.idempotency_key,
"stage_code": self.stage_code,
"message_code": self.message_code,
"sequence": self.sequence,
"state_revision": self.state_revision,
"cancellable": self.cancellable,
"cancel_requested": self.cancel_requested,
"result": dict(self.result) if self.result is not None else None,
"error": dict(self.error) if self.error is not None else None,
"evidence_refs": list(self.evidence_refs),
}
class OperationJournal:
"""Bounded, secret-free operation journal for one in-process plugin runtime.
The journal deliberately stores lifecycle metadata only. Action inputs, BLE
frames, MQTT payloads and credentials never enter operation events.
"""
def __init__(
self,
*,
max_records: int = 128,
clock: Callable[[], datetime] = utc_now,
) -> None:
if max_records < 1:
raise ValueError("max_records must be positive")
self._max_records = max_records
self._clock = clock
self._lock = threading.Lock()
self._records: dict[str, OperationRecord] = {}
self._order: list[str] = []
self._idempotency: dict[str, str] = {}
def begin(
self,
action: str,
*,
operation_id: str | None = None,
idempotency_key: str | None = None,
device_id: str | None = None,
device_session_id: str | None = None,
deadline_seconds: float | None = None,
cancellable: bool = False,
request_fingerprint: str | None = None,
) -> tuple[OperationRecord, bool]:
action = action.strip()
if not action:
raise ValueError("operation action cannot be blank")
if idempotency_key is not None:
idempotency_key = idempotency_key.strip()
if not idempotency_key or len(idempotency_key) > 160:
raise ValueError("idempotency key must contain 1..160 characters")
if deadline_seconds is not None and not 0 < deadline_seconds <= 86_400:
raise ValueError("operation deadline must be within 1..86400 seconds")
with self._lock:
if idempotency_key is not None and idempotency_key in self._idempotency:
existing_idempotent = self._records[self._idempotency[idempotency_key]]
if existing_idempotent.action != action:
raise ValueError("idempotency key is already bound to another action")
if existing_idempotent.request_fingerprint != request_fingerprint:
raise ValueError("idempotency key is already bound to a different request")
return existing_idempotent, False
resolved_id = _identifier(operation_id, prefix="op")
existing_by_id = self._records.get(resolved_id)
if existing_by_id is not None:
if existing_by_id.action != action:
raise ValueError("operation id is already bound to another action")
if existing_by_id.request_fingerprint != request_fingerprint:
raise ValueError("operation id is already bound to a different request")
return existing_by_id, False
now = self._clock()
record = OperationRecord(
operation_id=resolved_id,
action=action,
status="accepted",
accepted_at=now,
device_id=device_id,
device_session_id=device_session_id,
idempotency_key=idempotency_key,
deadline_at=(
now + timedelta(seconds=deadline_seconds)
if deadline_seconds is not None
else None
),
cancellable=cancellable,
request_fingerprint=request_fingerprint,
)
self._records[resolved_id] = record
self._order.append(resolved_id)
if idempotency_key is not None:
self._idempotency[idempotency_key] = resolved_id
self._trim_locked()
return record, True
def transition(
self,
operation_id: str,
status: OperationStatus,
*,
stage_code: str,
message_code: str,
result: Mapping[str, Any] | None = None,
error: Mapping[str, Any] | None = None,
evidence_refs: Iterable[str] = (),
) -> OperationRecord:
with self._lock:
record = self._require_locked(operation_id)
if record.status in TERMINAL_OPERATION_STATUSES:
if record.status == status:
return record
raise ValueError(f"operation {operation_id} is already terminal")
record.status = status
record.stage_code = stage_code
record.message_code = message_code
record.sequence += 1
record.state_revision += 1
record.result = dict(result) if result is not None else None
record.error = dict(error) if error is not None else None
record.evidence_refs = tuple(evidence_refs)
if status in TERMINAL_OPERATION_STATUSES:
record.completed_at = self._clock()
self._trim_locked()
return record
def request_cancel(self, operation_id: str) -> OperationRecord:
with self._lock:
record = self._require_locked(operation_id)
if record.status in TERMINAL_OPERATION_STATUSES:
return record
if not record.cancellable:
raise ValueError(f"operation {operation_id} is not cancellable")
record.cancel_requested = True
record.sequence += 1
record.state_revision += 1
record.stage_code = "cancellation-requested"
record.message_code = "operation.cancellation_requested"
return record
def transition_if_pending(
self,
operation_id: str | None,
status: OperationStatus,
*,
stage_code: str,
message_code: str,
result: Mapping[str, Any] | None = None,
error: Mapping[str, Any] | None = None,
evidence_refs: Iterable[str] = (),
) -> OperationRecord | None:
"""Atomically transition an existing non-terminal operation.
Lifecycle reconciliation and explicit stop/abort paths can race. This
helper makes terminalization idempotent without exposing mutable journal
records or turning an already-completed operation into an error.
"""
with self._lock:
if operation_id is None:
return None
record = self._records.get(operation_id)
if record is None or record.status in TERMINAL_OPERATION_STATUSES:
return record
record.status = status
record.stage_code = stage_code
record.message_code = message_code
record.sequence += 1
record.state_revision += 1
record.result = dict(result) if result is not None else None
record.error = dict(error) if error is not None else None
record.evidence_refs = tuple(evidence_refs)
if status in TERMINAL_OPERATION_STATUSES:
record.completed_at = self._clock()
self._trim_locked()
return record
def get(self, operation_id: str) -> OperationRecord:
with self._lock:
return self._require_locked(operation_id)
def latest(self) -> OperationRecord | None:
with self._lock:
return self._records[self._order[-1]] if self._order else None
def snapshot(self, *, limit: int = 20) -> list[dict[str, Any]]:
if limit < 1:
return []
with self._lock:
return [self._records[item].as_dict() for item in self._order[-limit:]]
def _require_locked(self, operation_id: str) -> OperationRecord:
try:
return self._records[operation_id]
except KeyError as exc:
raise KeyError(f"unknown operation: {operation_id}") from exc
def _trim_locked(self) -> None:
while len(self._order) > self._max_records:
oldest_id = next(
(
operation_id
for operation_id in self._order
if self._records[operation_id].status in TERMINAL_OPERATION_STATUSES
),
None,
)
# Never evict an operation that still needs reconciliation. A brief
# overrun is safer than turning a later device observation into an
# unknown-operation failure.
if oldest_id is None:
return
self._order.remove(oldest_id)
oldest = self._records.pop(oldest_id)
if oldest.idempotency_key is not None:
self._idempotency.pop(oldest.idempotency_key, None)
@dataclass(slots=True)
class AcquisitionRecord:
acquisition_id: str
device_id: str
device_session_id: str
compatibility_profile_id: str
control_mode: ControlMode
requested_streams: tuple[str, ...]
target_host: str
duration_seconds: float
evidence_policy: Literal["required", "best-effort", "disabled"]
state: AcquisitionState = "preparing"
state_revision: int = 1
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
message_code: str = "acquisition.preparing"
operator_instructions: tuple[str, ...] = ()
result: dict[str, Any] | None = None
def transition(
self,
state: AcquisitionState,
*,
message_code: str,
operator_instructions: Iterable[str] = (),
result: Mapping[str, Any] | None = None,
) -> None:
if self.state in TERMINAL_ACQUISITION_STATES:
if self.state == state:
return
raise ValueError(f"acquisition {self.acquisition_id} is already terminal")
self.state = state
self.state_revision += 1
self.updated_at = utc_now()
self.message_code = message_code
self.operator_instructions = tuple(operator_instructions)
self.result = dict(result) if result is not None else None
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.acquisition-snapshot/v1alpha2",
"acquisition_id": self.acquisition_id,
"device_id": self.device_id,
"device_session_id": self.device_session_id,
"compatibility_profile_id": self.compatibility_profile_id,
"control_mode": self.control_mode,
"requested_streams": list(self.requested_streams),
"target_host": self.target_host,
"duration_seconds": self.duration_seconds,
"evidence_policy": self.evidence_policy,
"state": self.state,
"state_revision": self.state_revision,
"created_at": _iso(self.created_at),
"updated_at": _iso(self.updated_at),
"message_code": self.message_code,
"operator_instructions": list(self.operator_instructions),
"result": dict(self.result) if self.result is not None else None,
}
def new_acquisition_id() -> str:
return f"acq-{uuid4()}"
def new_device_id() -> str:
return f"device-{uuid4()}"
def new_device_session_id() -> str:
return f"device-session-{uuid4()}"

View File

@ -1,13 +1,29 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Annotated, Any, Literal
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, ValidationError
from pydantic import (
AfterValidator,
BaseModel,
ConfigDict,
Field,
ValidationError,
model_validator,
)
from k1link.web.plugin_runtime import STATE_READ_ACTION_ID
PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1"
PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha2"
SUPPORTED_PLUGIN_API_VERSIONS = frozenset(
{
"missioncore.nodedc/v1alpha1",
PLUGIN_API_VERSION,
}
)
_V1ALPHA2_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$")
def _reject_blank(value: str) -> str:
@ -16,6 +32,11 @@ def _reject_blank(value: str) -> str:
return value
def _validate_v1alpha2_identifier(value: str, path: str) -> None:
if _V1ALPHA2_IDENTIFIER.fullmatch(value) is None:
raise ValueError(f"{path} must be a v1alpha2 identifier")
ShortText = Annotated[
str,
Field(min_length=1, max_length=160),
@ -31,6 +52,11 @@ EntrypointText = Annotated[
Field(min_length=1, max_length=256),
AfterValidator(_reject_blank),
]
ProfilePathText = Annotated[
str,
Field(min_length=1, max_length=512),
AfterValidator(_reject_blank),
]
class CapabilityManifest(BaseModel):
@ -75,6 +101,14 @@ class PluginActionManifest(BaseModel):
secretFields: list[ShortText]
class CompatibilityProfileLinkManifest(BaseModel):
model_config = ConfigDict(extra="forbid")
profileId: ShortText
path: ProfilePathText
modelId: ShortText
class PluginMetadata(BaseModel):
model_config = ConfigDict(extra="forbid")
@ -94,26 +128,164 @@ class PluginMetadata(BaseModel):
class PluginSpec(BaseModel):
model_config = ConfigDict(extra="forbid")
hostApiRange: Literal["v1alpha1"]
hostApiRange: Literal["v1alpha1", "v1alpha2"]
runtime: PluginRuntimeManifest
permissions: list[ShortText]
actions: list[PluginActionManifest]
models: list[DeviceModelManifest] = Field(min_length=1, max_length=1)
models: list[DeviceModelManifest] = Field(min_length=1)
compatibilityProfiles: list[CompatibilityProfileLinkManifest] | None = None
class DevicePluginManifest(BaseModel):
model_config = ConfigDict(extra="forbid")
apiVersion: Literal["missioncore.nodedc/v1alpha1"]
apiVersion: Literal[
"missioncore.nodedc/v1alpha1",
"missioncore.nodedc/v1alpha2",
]
kind: Literal["DevicePlugin"]
metadata: PluginMetadata
spec: PluginSpec
@model_validator(mode="after")
def validate_contract_version(self) -> DevicePluginManifest:
expected_host_range = self.apiVersion.rsplit("/", maxsplit=1)[-1]
if self.spec.hostApiRange != expected_host_range:
raise ValueError("apiVersion and hostApiRange must declare the same contract")
if self.apiVersion == "missioncore.nodedc/v1alpha1":
if len(self.spec.models) != 1:
raise ValueError("v1alpha1 must declare exactly one device model")
if self.spec.compatibilityProfiles is not None:
raise ValueError("v1alpha1 must not declare compatibilityProfiles")
else:
if not self.spec.compatibilityProfiles:
raise ValueError("v1alpha2 must declare at least one compatibility profile")
identifiers: list[tuple[str, str]] = [
("metadata.id", self.metadata.id),
*(
(f"spec.permissions[{index}]", permission)
for index, permission in enumerate(self.spec.permissions)
),
]
for action_index, action in enumerate(self.spec.actions):
identifiers.append((f"spec.actions[{action_index}].id", action.id))
identifiers.extend(
(
f"spec.actions[{action_index}].secretFields[{field_index}]",
field,
)
for field_index, field in enumerate(action.secretFields)
)
for model_index, model in enumerate(self.spec.models):
identifiers.append((f"spec.models[{model_index}].id", model.id))
identifiers.extend(
(
f"spec.models[{model_index}].capabilities[{capability_index}].id",
capability.id,
)
for capability_index, capability in enumerate(model.capabilities)
)
for profile_index, profile in enumerate(self.spec.compatibilityProfiles):
identifiers.extend(
(
(
f"spec.compatibilityProfiles[{profile_index}].profileId",
profile.profileId,
),
(
f"spec.compatibilityProfiles[{profile_index}].modelId",
profile.modelId,
),
)
)
for path, value in identifiers:
_validate_v1alpha2_identifier(value, path)
return self
class PluginCatalogError(RuntimeError):
"""An installed manifest is invalid or conflicts with another manifest."""
def _reject_duplicate_profile_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise PluginCatalogError(f"Duplicate JSON key in compatibility profile: {key}")
result[key] = value
return result
def _validate_profile_links(
manifest: DevicePluginManifest,
manifest_path: Path,
) -> None:
links = manifest.spec.compatibilityProfiles
if links is None:
return
plugin_directory = manifest_path.parent.resolve()
model_ids = {model.id for model in manifest.spec.models}
linked_model_ids: set[str] = set()
profile_ids: set[str] = set()
profile_paths: set[Path] = set()
for link in links:
if link.profileId in profile_ids:
raise PluginCatalogError(
f"Duplicate compatibility profile id in {manifest.metadata.id}: {link.profileId}"
)
profile_ids.add(link.profileId)
if link.modelId not in model_ids:
raise PluginCatalogError(
f"Compatibility profile {link.profileId} references unknown model {link.modelId}"
)
linked_model_ids.add(link.modelId)
relative_path = Path(link.path)
if relative_path.is_absolute():
raise PluginCatalogError(
f"Compatibility profile path must be plugin-relative: {link.path}"
)
profile_path = (plugin_directory / relative_path).resolve()
try:
profile_path.relative_to(plugin_directory)
except ValueError as exc:
raise PluginCatalogError(
f"Compatibility profile path escapes plugin directory: {link.path}"
) from exc
if profile_path in profile_paths:
raise PluginCatalogError(
f"Duplicate compatibility profile path in {manifest.metadata.id}: {link.path}"
)
profile_paths.add(profile_path)
if not profile_path.is_file():
raise PluginCatalogError(f"Compatibility profile does not exist: {link.path}")
try:
profile_document = json.loads(
profile_path.read_text(encoding="utf-8"),
object_pairs_hook=_reject_duplicate_profile_keys,
)
except (OSError, json.JSONDecodeError) as exc:
raise PluginCatalogError(
f"Invalid compatibility profile {profile_path}: {exc}"
) from exc
if not isinstance(profile_document, dict):
raise PluginCatalogError(
f"Compatibility profile must contain a JSON object: {link.path}"
)
if profile_document.get("profile_id") != link.profileId:
raise PluginCatalogError(
f"Compatibility profile id mismatch for {link.path}: expected {link.profileId}"
)
if linked_model_ids != model_ids:
missing = ", ".join(sorted(model_ids - linked_model_ids))
raise PluginCatalogError(f"Device models without compatibility profiles: {missing}")
class DevicePluginCatalog:
"""Read-only catalog of statically reviewed device-plugin manifests."""
@ -141,6 +313,8 @@ class DevicePluginCatalog:
raise PluginCatalogError(f"Duplicate device-plugin id: {plugin_id}")
plugin_ids.add(plugin_id)
_validate_profile_links(manifest, path)
action_ids: set[str] = set()
for action in manifest.spec.actions:
if action.id in action_ids:
@ -178,7 +352,9 @@ class DevicePluginCatalog:
return list(self._validated_manifests)
def plugin_documents(self) -> list[dict[str, Any]]:
return [manifest.model_dump(mode="json") for manifest in self.manifests()]
return [
manifest.model_dump(mode="json", exclude_none=True) for manifest in self.manifests()
]
def model_documents(self) -> list[dict[str, Any]]:
models: list[dict[str, Any]] = []

File diff suppressed because it is too large Load Diff

View File

@ -23,10 +23,10 @@ def test_advertisement_record_marks_k1_candidate() -> None:
assert record["service_uuids"] == ["B", "a"]
def test_advertisement_record_marks_observed_xgr_serial_name_as_k1_candidate() -> None:
device = BLEDevice("F89438FA-55ED-85AD-EED7-734AC84746D8", "XGR-A46BE7", None)
def test_advertisement_record_marks_synthetic_xgr_name_as_k1_candidate() -> None:
device = BLEDevice("00000000-0000-0000-0000-000000000001", "XGR-TEST01", None)
advertisement = AdvertisementData(
local_name="XGR-A46BE7",
local_name="XGR-TEST01",
manufacturer_data={},
service_data={},
service_uuids=[],

View File

@ -0,0 +1,314 @@
from __future__ import annotations
import inspect
import struct
import time
from pathlib import Path
import lz4.block
import pytest
import k1link.viewer.runtime as runtime_module
from k1link.data_plane import (
ConsumerFrameContext,
DecodedDeviceStatusView,
DecodedPointCloudView,
DecodedPoseView,
NormalizationError,
)
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.protocol.normalizer import normalize_k1_message
from k1link.viewer.messages import StreamMessage
from k1link.viewer.rerun_bridge import RerunBridge
from k1link.viewer.runtime import VisualizationRuntime
class FakeRecording:
def __init__(self) -> None:
self.logs: list[tuple[str, object, bool]] = []
def serve_grpc(self, **_: object) -> str:
return "rerun+http://127.0.0.1:9876/proxy"
def log(self, path: str, entity: object, *, static: bool = False) -> None:
self.logs.append((path, entity, static))
def set_time(self, _timeline: str, **_value: object) -> None:
return
def send_blueprint(self, _blueprint: object, **_: object) -> None:
return
def disconnect(self) -> None:
return
def flush(self, **_: object) -> None:
return
def _message(
topic: str,
payload: bytes,
*,
source: str = "live_mqtt",
) -> StreamMessage:
return StreamMessage(
sequence=9,
topic=topic,
payload=payload,
received_at_epoch_ns=1_784_124_315_186_225_000,
received_monotonic_ns=100,
source=source,
)
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _key(number: int, wire_type: int) -> bytes:
return _varint((number << 3) | wire_type)
def _uint(number: int, value: int) -> bytes:
return _key(number, 0) + _varint(value)
def _sint(number: int, value: int) -> bytes:
zigzag = (value << 1) ^ (value >> 63)
return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF)
def _bytes(number: int, value: bytes) -> bytes:
return _key(number, 2) + _varint(len(value)) + value
def _fixed32(number: int, value: float) -> bytes:
return _key(number, 5) + struct.pack("<f", value)
def _fixed64(number: int, value: float) -> bytes:
return _key(number, 1) + struct.pack("<d", value)
def _lio_header() -> bytes:
return b"".join(
(
_uint(1, 7),
_sint(2, 123456),
_sint(3, 1000),
_bytes(4, b"device-redacted"),
_bytes(5, b"session-redacted"),
)
)
def _lio_point_payload() -> bytes:
point = _sint(1, 1000) + _sint(2, -2000) + _sint(3, 500) + _uint(4, 0x44)
report = _bytes(1, _lio_header()) + _bytes(2, point)
compressed = lz4.block.compress(report, store_size=False)
return _uint(3, len(report)) + _bytes(4, compressed)
def _lio_pose_payload() -> bytes:
position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75)
orientation = _fixed64(1, 0.0) + _fixed64(2, 0.0) + _fixed64(3, 0.0) + _fixed64(4, 1.0)
pose = _bytes(1, position) + _bytes(2, orientation)
stamped = _sint(1, 987654321) + _bytes(2, pose)
return _bytes(1, _lio_header()) + _bytes(2, stamped) + _fixed32(3, 12.5) + _fixed32(4, 0.001)
def _context() -> ConsumerFrameContext:
return ConsumerFrameContext(
sequence=1,
captured_at_epoch_ns=1,
received_monotonic_ns=None,
processing_started_monotonic_ns=1,
encoded_size_bytes=1,
live=False,
)
def test_k1_normalizer_emits_transport_neutral_point_clouds() -> None:
modern = normalize_k1_message(
_message("lixel/application/report/lio_pcl", _lio_point_payload()),
processing_started_monotonic_ns=50,
)
legacy_payload = struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
legacy = normalize_k1_message(
_message("RealtimePointcloud", legacy_payload, source="legacy_tsv"),
processing_started_monotonic_ns=60,
)
assert isinstance(modern, DecodedPointCloudView)
assert modern.positions_xyz == ((1.0, -2.0, 0.5),)
assert modern.intensities == bytes((0x44,))
assert modern.colors_rgb is None
assert modern.context.source_device_alias == "device-redacted"
assert modern.context.source_session_alias == "session-redacted"
assert modern.context.live is True
assert not hasattr(modern, "topic")
assert not hasattr(modern, "payload")
assert isinstance(legacy, DecodedPointCloudView)
assert legacy.positions_xyz == ((1.0, -2.0, 3.0),)
assert legacy.intensities == bytes((40,))
assert legacy.colors_rgb == bytes((10, 20, 30))
assert legacy.context.source_device_alias is None
assert legacy.context.live is False
def test_k1_normalizer_emits_transport_neutral_poses() -> None:
modern = normalize_k1_message(
_message("lixel/application/report/lio_pose", _lio_pose_payload()),
processing_started_monotonic_ns=50,
)
legacy_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
legacy = normalize_k1_message(
_message("RealtimePath", legacy_payload, source="legacy_tsv"),
processing_started_monotonic_ns=60,
)
assert isinstance(modern, DecodedPoseView)
assert modern.frame_id == "map"
assert modern.child_frame_id == "sensor"
assert modern.position_xyz == (1.25, -2.5, 3.75)
assert modern.orientation_xyzw == (0.0, 0.0, 0.0, 1.0)
assert modern.context.source_device_alias == "device-redacted"
assert modern.context.source_session_alias == "session-redacted"
assert isinstance(legacy, DecodedPoseView)
assert legacy.position_xyz == (1.0, 2.0, 3.0)
assert legacy.orientation_xyzw == (0.0, 0.0, 0.0, 1.0)
assert legacy.context.source_device_alias is None
def test_known_invalid_and_unknown_channels_have_distinct_outcomes() -> None:
with pytest.raises(NormalizationError) as caught:
normalize_k1_message(
_message("RealtimePointcloud", b"short"),
processing_started_monotonic_ns=1,
)
assert "RealtimePointcloud" not in str(caught.value)
assert (
normalize_k1_message(
_message("lixel/application/report/unverified", b"opaque"),
processing_started_monotonic_ns=1,
)
is None
)
# The status channel is observed but its payload schema is not verified;
# fail closed instead of inventing normalized status fields.
assert (
normalize_k1_message(
_message("lixel/application/report/device_status", b"opaque"),
processing_started_monotonic_ns=1,
)
is None
)
def test_canonical_contracts_reject_misaligned_data_and_support_status() -> None:
with pytest.raises(ValueError, match="RGB byte count"):
DecodedPointCloudView(
context=_context(),
frame_id="map",
positions_xyz=((0.0, 0.0, 0.0),),
colors_rgb=b"\x00\x01",
)
status = DecodedDeviceStatusView(context=_context(), state="ready")
assert status.state == "ready"
def test_rerun_bridge_source_has_no_vendor_protocol_or_raw_transport_knowledge() -> None:
source = inspect.getsource(__import__("k1link.viewer.rerun_bridge", fromlist=["*"]))
for forbidden in (
"k1link.protocol",
"StreamMessage",
"RealtimePointcloud",
"RealtimePath",
"lio_pcl",
"lio_pose",
".topic",
".payload",
):
assert forbidden not in source
def test_visual_runtime_has_no_implicit_vendor_normalizer() -> None:
source = inspect.getsource(__import__("k1link.viewer.runtime", fromlist=["*"]))
assert "k1link.protocol" not in source
assert "normalize_k1_message" not in source
assert "normalizer: CanonicalNormalizer" in source
def test_runtime_counts_transport_and_normalization_failures_before_rerun(
tmp_path: Path,
) -> None:
topic = b"RealtimePointcloud"
payload = b"short"
capture = tmp_path / "mqtt.raw.k1mqtt"
capture.write_bytes(RAW_MAGIC + FRAME_HEADER.pack(len(topic), len(payload)) + topic + payload)
recording = FakeRecording()
def bridge_factory(**kwargs: object) -> RerunBridge:
return RerunBridge(
recording_factory=lambda _: recording, # type: ignore[arg-type]
**kwargs, # type: ignore[arg-type]
)
runtime = VisualizationRuntime(
bridge_factory=bridge_factory,
normalizer=normalize_k1_message,
)
runtime.start_replay(capture, speed=0.0)
deadline = time.monotonic() + 5.0
while runtime.snapshot()["phase"] != "idle" and time.monotonic() < deadline:
time.sleep(0.01)
snapshot = runtime.snapshot()
assert snapshot["phase"] == "idle"
assert snapshot["metrics"]["messages_received"] == 1
assert snapshot["metrics"]["payload_bytes"] == len(payload)
assert snapshot["metrics"]["decode_errors"] == 1
assert snapshot["metrics"]["pcl_frames"] == 0
assert not any(path == "/world/points" for path, _, _ in recording.logs)
runtime.close()
def test_live_runtime_surfaces_preamble_failure_as_terminal_error(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
def fail_preamble(*_: object, **__: object) -> None:
raise OSError("synthetic evidence directory failure")
monkeypatch.setattr(runtime_module, "_write_live_session_preamble", fail_preamble)
runtime = VisualizationRuntime(normalizer=normalize_k1_message)
runtime.start_live(
"192.168.1.20",
tmp_path / "unwritable-evidence",
duration_seconds=1.0,
)
deadline = time.monotonic() + 2.0
snapshot = runtime.snapshot()
while snapshot["phase"] == "starting_live" and time.monotonic() < deadline:
time.sleep(0.01)
snapshot = runtime.snapshot()
assert snapshot["phase"] == "error"
assert snapshot["source_mode"] == "live"
assert snapshot["source_ready"] is False
assert "synthetic evidence directory failure" in snapshot["message"]
runtime.close()

View File

@ -0,0 +1,164 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from k1link.web.device_lifecycle import (
AcquisitionRecord,
OperationJournal,
new_acquisition_id,
)
def test_operation_journal_reuses_an_idempotent_request_without_storing_input() -> None:
journal = OperationJournal(clock=lambda: datetime(2026, 7, 16, 12, 0, tzinfo=UTC))
first, created = journal.begin(
"network.provision",
idempotency_key="provision-owned-k1-once",
device_id="device-test",
device_session_id="device-session-test",
deadline_seconds=45,
)
repeated, repeated_created = journal.begin(
"network.provision",
idempotency_key="provision-owned-k1-once",
device_id="device-test",
device_session_id="device-session-test",
deadline_seconds=45,
)
assert created is True
assert repeated_created is False
assert repeated is first
assert "password" not in str(journal.snapshot()).lower()
def test_operation_journal_records_ack_progress_and_terminal_result() -> None:
journal = OperationJournal()
operation, _ = journal.begin("acquisition.prepare", cancellable=True)
journal.transition(
operation.operation_id,
"running",
stage_code="compatibility-check",
message_code="acquisition.compatibility_check",
)
journal.transition(
operation.operation_id,
"succeeded",
stage_code="prepared",
message_code="acquisition.prepared",
result={"acquisition_id": "acq-test"},
evidence_refs=("evidence-manifest-test",),
)
document = journal.get(operation.operation_id).as_dict()
assert document["schema_version"] == "missioncore.operation-snapshot/v1alpha2"
assert document["status"] == "succeeded"
assert document["sequence"] == 3
assert document["result"] == {"acquisition_id": "acq-test"}
assert document["evidence_refs"] == ["evidence-manifest-test"]
assert document["completed_at"] is not None
with pytest.raises(ValueError, match="already terminal"):
journal.transition(
operation.operation_id,
"failed",
stage_code="late-failure",
message_code="operation.failed",
)
def test_operation_id_and_idempotency_key_cannot_be_rebound() -> None:
journal = OperationJournal()
operation, _ = journal.begin("device.inspect", idempotency_key="same-key")
with pytest.raises(ValueError, match="another action"):
journal.begin("network.provision", idempotency_key="same-key")
with pytest.raises(ValueError, match="another action"):
journal.begin("sensor.catalog.read", operation_id=operation.operation_id)
def test_idempotency_identity_cannot_be_reused_for_different_request_fingerprint() -> None:
journal = OperationJournal()
operation, _ = journal.begin(
"network.provision",
idempotency_key="same-request-only",
request_fingerprint="fingerprint-a",
)
repeated, created = journal.begin(
"network.provision",
idempotency_key="same-request-only",
request_fingerprint="fingerprint-a",
)
assert created is False
assert repeated is operation
with pytest.raises(ValueError, match="different request"):
journal.begin(
"network.provision",
idempotency_key="same-request-only",
request_fingerprint="fingerprint-b",
)
with pytest.raises(ValueError, match="different request"):
journal.begin(
"network.provision",
operation_id=operation.operation_id,
request_fingerprint="fingerprint-b",
)
assert "fingerprint-a" not in str(journal.snapshot())
def test_bounded_journal_never_evicts_an_operation_that_is_still_active() -> None:
journal = OperationJournal(max_records=1)
active, _ = journal.begin("acquisition.start", request_fingerprint="start")
completed, _ = journal.begin("device.inspect", request_fingerprint="inspect")
journal.transition(
completed.operation_id,
"succeeded",
stage_code="completed",
message_code="device.inspect.completed",
)
assert journal.get(active.operation_id).status == "accepted"
assert [item["operation_id"] for item in journal.snapshot(limit=10)] == [active.operation_id]
def test_non_cancellable_operation_rejects_cancel_request() -> None:
journal = OperationJournal()
operation, _ = journal.begin("network.provision", cancellable=False)
with pytest.raises(ValueError, match="not cancellable"):
journal.request_cancel(operation.operation_id)
def test_acquisition_record_keeps_device_session_profile_and_manual_control_separate() -> None:
acquisition = AcquisitionRecord(
acquisition_id=new_acquisition_id(),
device_id="device-test",
device_session_id="device-session-test",
compatibility_profile_id="xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
control_mode="operator-manual",
requested_streams=("spatial.point-cloud.live", "spatial.pose.live"),
target_host="192.168.1.20",
duration_seconds=60,
evidence_policy="required",
)
acquisition.transition("prepared", message_code="acquisition.prepared")
acquisition.transition(
"awaiting_external_start",
message_code="acquisition.operator_start_required",
operator_instructions=("Дважды нажмите физическую кнопку устройства.",),
)
document = acquisition.as_dict()
assert document["schema_version"] == "missioncore.acquisition-snapshot/v1alpha2"
assert document["control_mode"] == "operator-manual"
assert document["state"] == "awaiting_external_start"
assert document["device_id"] != document["device_session_id"]
assert document["operator_instructions"]

View File

@ -90,3 +90,46 @@ def test_model_switch_is_guarded_by_plugin_deactivation() -> None:
assert "catch" in host_source
assert "selectionTransitionError" in host_source
assert "return controller.stop();" in xgrids_runtime
def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity() -> None:
repository_root = Path(__file__).resolve().parents[1]
plugin_root = (
repository_root / "apps" / "control-station" / "src" / "device-plugins" / "xgrids-k1"
)
manifest_source = (plugin_root / "manifest.ts").read_text("utf-8")
api_source = (plugin_root / "api.ts").read_text("utf-8")
hook_source = (plugin_root / "useXgridsK1Runtime.ts").read_text("utf-8")
runtime_source = (plugin_root / "runtimeContext.tsx").read_text("utf-8")
for action in ("acquisition.prepare", "acquisition.start", "acquisition.stop"):
assert action in manifest_source
assert "prepareAcquisition" in api_source
assert "startAcquisition" in api_source
assert hook_source.index("xgridsK1Api.prepareAcquisition") < hook_source.index(
"xgridsK1Api.startAcquisition"
)
assert 'mode: "capture-only"' in hook_source
assert "state.device_ref" in runtime_source
assert "instanceId: deviceRef.device_id" in runtime_source
assert "acquisition?.acquisition_id" in runtime_source
assert "instanceId: state.selected_device_id" not in runtime_source
assert 'id: "xgrids-k1-rerun-live"' not in runtime_source
def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner() -> None:
repository_root = Path(__file__).resolve().parents[1]
connection_source = (
repository_root
/ "apps"
/ "control-station"
/ "src"
/ "device-plugins"
/ "xgrids-k1"
/ "XgridsK1Connection.tsx"
).read_text("utf-8")
assert "Подготовить приём данных" in connection_source
assert "Программная команда запуска на K1 пока не отправляется" in connection_source
assert "Остановить локальный приём" in connection_source
assert "Физическое состояние сканера остаётся неизвестным" in connection_source

View File

@ -0,0 +1,107 @@
from __future__ import annotations
import hashlib
import importlib.util
import json
from pathlib import Path
from types import ModuleType
import pytest
REPOSITORY_ROOT = Path(__file__).parents[1]
INTEGRITY_PATH = (
REPOSITORY_ROOT / "plugins" / "xgrids-k1" / "lab" / "iphone-capture" / "session_integrity.py"
)
def _load_module() -> ModuleType:
spec = importlib.util.spec_from_file_location(
"iphone_capture_session_integrity", INTEGRITY_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
INTEGRITY = _load_module()
def _sha256(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def _session(tmp_path: Path) -> tuple[Path, Path]:
session = tmp_path / "20260716T000000Z_test_abcd"
capture_dir = session / "captures"
capture_dir.mkdir(parents=True)
capture = capture_dir / "iphone-network.pcap"
capture_bytes = b"synthetic-private-capture"
capture.write_bytes(capture_bytes)
manifest = {
"session_id": session.name,
"status": "completed",
"artifacts": [
{
"kind": "iphone-network-pcap-compatible",
"relative_path": "captures/iphone-network.pcap",
"size_bytes": len(capture_bytes),
"sha256": _sha256(capture_bytes),
}
],
}
(session / "manifest.redacted.json").write_text(json.dumps(manifest), encoding="utf-8")
(session / "capture.stderr.log").write_bytes(b"")
events = session / "operator-events.private.jsonl"
events.write_text('{"sequence":1}\n', encoding="utf-8")
return session, capture
def test_seal_session_preserves_manifest_and_hashes_late_operator_events(tmp_path: Path) -> None:
session, _ = _session(tmp_path)
manifest = session / "manifest.redacted.json"
before = manifest.read_bytes()
destination = INTEGRITY.seal_session(session)
inventory = json.loads(destination.read_text(encoding="utf-8"))
assert manifest.read_bytes() == before
assert inventory["capture_manifest_preserved"] is True
assert inventory["verified_manifest_artifacts"][0]["relative_path"] == (
"captures/iphone-network.pcap"
)
derived = {item["kind"]: item for item in inventory["derived_session_artifacts"]}
assert derived["capture-manifest"]["sha256"] == _sha256(before)
assert derived["capture-stderr"]["size_bytes"] == 0
assert derived["operator-events"]["relative_path"] == "operator-events.private.jsonl"
assert destination.stat().st_mode & 0o777 == 0o600
def test_seal_session_rejects_capture_tampering(tmp_path: Path) -> None:
session, capture = _session(tmp_path)
capture.write_bytes(b"tampered")
with pytest.raises(INTEGRITY.SessionIntegrityError, match="size mismatch"):
INTEGRITY.seal_session(session)
def test_seal_session_refuses_to_replace_inventory_without_explicit_flag(
tmp_path: Path,
) -> None:
session, _ = _session(tmp_path)
INTEGRITY.seal_session(session)
with pytest.raises(INTEGRITY.SessionIntegrityError, match="already exists"):
INTEGRITY.seal_session(session)
def test_seal_session_rejects_manifest_path_escape(tmp_path: Path) -> None:
session, _ = _session(tmp_path)
manifest_path = session / "manifest.redacted.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["artifacts"][0]["relative_path"] = "../outside.pcap"
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with pytest.raises(INTEGRITY.SessionIntegrityError, match="stay inside"):
INTEGRITY.seal_session(session)

View File

@ -45,12 +45,38 @@ def _manifest(plugin_id: str, model_id: str) -> dict[str, Any]:
}
def _v1alpha2_manifest(plugin_id: str, model_id: str) -> dict[str, Any]:
document = _manifest(plugin_id, model_id)
document["apiVersion"] = "missioncore.nodedc/v1alpha2"
document["metadata"]["version"] = "0.2.0"
document["spec"]["hostApiRange"] = "v1alpha2"
document["spec"]["compatibilityProfiles"] = [
{
"profileId": f"{model_id}.fw-1.direct-lan.v1",
"path": "profiles/fw-1/direct-lan.v1.json",
"modelId": model_id,
}
]
return document
def _write_manifest(root: Path, directory: str, document: dict[str, Any]) -> None:
target = root / "plugins" / directory / "plugin.manifest.json"
target.parent.mkdir(parents=True)
target.write_text(json.dumps(document), encoding="utf-8")
def _write_profile(
root: Path,
directory: str,
profile_id: str,
relative_path: str = "profiles/fw-1/direct-lan.v1.json",
) -> None:
target = root / "plugins" / directory / relative_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps({"profile_id": profile_id}), encoding="utf-8")
def test_repository_catalog_exposes_xgrids_model() -> None:
repository_root = Path(__file__).resolve().parents[1]
catalog = DevicePluginCatalog(repository_root)
@ -58,10 +84,40 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
plugins = catalog.plugin_documents()
models = catalog.model_documents()
assert any(item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1" for item in plugins)
plugin = next(
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
)
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
assert plugin["metadata"]["version"] == "0.2.0"
assert plugin["spec"]["hostApiRange"] == "v1alpha2"
assert plugin["spec"]["compatibilityProfiles"] == [
{
"profileId": "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1",
"path": "profiles/fw-3.0.2/direct-lan.v1.json",
"modelId": "xgrids.lixelkity-k1",
}
]
action_ids = {action["id"] for action in plugin["spec"]["actions"]}
assert {
"device.inspect",
"sensor.catalog.read",
"calibration.device-snapshot.read",
"connection.verify",
"acquisition.prepare",
"acquisition.start",
"acquisition.stop",
"acquisition.abort",
"acquisition.state.read",
} <= action_ids
assert {
"stream.start-live",
"stream.start-replay",
"stream.stop",
"viewer.settings.update",
} <= action_ids
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
"pluginVersion": "0.1.0",
"pluginVersion": "0.2.0",
"id": "xgrids.lixelkity-k1",
"vendor": "XGRIDS",
"displayName": "XGRIDS LixelKity K1",
@ -105,6 +161,152 @@ def test_catalog_accepts_multiple_distinct_plugins_and_models(tmp_path: Path) ->
}
def test_catalog_accepts_v1alpha2_profile_link(tmp_path: Path) -> None:
document = _v1alpha2_manifest("example.next", "example.next-model")
profile_id = document["spec"]["compatibilityProfiles"][0]["profileId"]
_write_manifest(tmp_path, "next", document)
_write_profile(tmp_path, "next", profile_id)
plugin = DevicePluginCatalog(tmp_path).plugin_documents()[0]
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
assert plugin["spec"]["compatibilityProfiles"][0]["profileId"] == profile_id
@pytest.mark.parametrize(
"mutate",
[
lambda document: document["metadata"].update({"id": "invalid plugin"}),
lambda document: document["spec"]["permissions"].append("invalid permission"),
lambda document: document["spec"]["actions"][0].update({"id": "invalid action"}),
lambda document: document["spec"]["models"][0].update({"id": "invalid model"}),
lambda document: document["spec"]["compatibilityProfiles"][0].update(
{"profileId": "invalid profile"}
),
],
)
def test_v1alpha2_rejects_non_identifier_contract_fields(
tmp_path: Path,
mutate: Any,
) -> None:
document = _v1alpha2_manifest("example.strict", "example.strict-model")
mutate(document)
_write_manifest(tmp_path, "strict", document)
with pytest.raises(PluginCatalogError, match="v1alpha2 identifier"):
DevicePluginCatalog(tmp_path).manifests()
def test_v1alpha1_keeps_legacy_nonblank_identifier_compatibility(tmp_path: Path) -> None:
document = _manifest("legacy plugin", "legacy model")
_write_manifest(tmp_path, "legacy", document)
plugin = DevicePluginCatalog(tmp_path).plugin_documents()[0]
assert plugin["metadata"]["id"] == "legacy plugin"
assert plugin["spec"]["models"][0]["id"] == "legacy model"
def test_catalog_accepts_multiple_profiled_models_in_v1alpha2(tmp_path: Path) -> None:
document = _v1alpha2_manifest("example.family", "example.model-a")
model_b = {
**document["spec"]["models"][0],
"id": "example.model-b",
"displayName": "Example model B",
}
document["spec"]["models"].append(model_b)
profile_b = "example.model-b.fw-1.direct-lan.v1"
document["spec"]["compatibilityProfiles"].append(
{
"profileId": profile_b,
"path": "profiles/fw-1/model-b.v1.json",
"modelId": "example.model-b",
}
)
_write_manifest(tmp_path, "family", document)
profile_a = document["spec"]["compatibilityProfiles"][0]["profileId"]
_write_profile(tmp_path, "family", profile_a)
_write_profile(
tmp_path,
"family",
profile_b,
relative_path="profiles/fw-1/model-b.v1.json",
)
plugin = DevicePluginCatalog(tmp_path).plugin_documents()[0]
assert [model["id"] for model in plugin["spec"]["models"]] == [
"example.model-a",
"example.model-b",
]
def test_catalog_rejects_api_and_host_contract_mismatch(tmp_path: Path) -> None:
document = _manifest("example.mismatch", "example.mismatch-model")
document["spec"]["hostApiRange"] = "v1alpha2"
_write_manifest(tmp_path, "mismatch", document)
with pytest.raises(PluginCatalogError, match="Invalid device-plugin manifest"):
DevicePluginCatalog(tmp_path).manifests()
def test_v1alpha2_catalog_rejects_missing_profile_file(tmp_path: Path) -> None:
document = _v1alpha2_manifest("example.missing", "example.missing-model")
_write_manifest(tmp_path, "missing", document)
with pytest.raises(PluginCatalogError, match="Compatibility profile does not exist"):
DevicePluginCatalog(tmp_path).manifests()
def test_v1alpha2_catalog_rejects_profile_path_outside_plugin(tmp_path: Path) -> None:
document = _v1alpha2_manifest("example.escape", "example.escape-model")
profile_id = document["spec"]["compatibilityProfiles"][0]["profileId"]
document["spec"]["compatibilityProfiles"][0]["path"] = "../outside.json"
outside = tmp_path / "plugins" / "outside.json"
outside.parent.mkdir(parents=True, exist_ok=True)
outside.write_text(json.dumps({"profile_id": profile_id}), encoding="utf-8")
_write_manifest(tmp_path, "escape", document)
with pytest.raises(PluginCatalogError, match="escapes plugin directory"):
DevicePluginCatalog(tmp_path).manifests()
def test_v1alpha2_catalog_rejects_profile_id_mismatch(tmp_path: Path) -> None:
document = _v1alpha2_manifest("example.mismatch", "example.mismatch-model")
_write_manifest(tmp_path, "mismatch", document)
_write_profile(tmp_path, "mismatch", "different.profile.id")
with pytest.raises(PluginCatalogError, match="Compatibility profile id mismatch"):
DevicePluginCatalog(tmp_path).manifests()
def test_v1alpha2_catalog_rejects_duplicate_profile_json_keys(tmp_path: Path) -> None:
document = _v1alpha2_manifest("example.duplicate", "example.duplicate-model")
profile_id = document["spec"]["compatibilityProfiles"][0]["profileId"]
_write_manifest(tmp_path, "duplicate", document)
target = tmp_path / "plugins" / "duplicate" / "profiles" / "fw-1" / "direct-lan.v1.json"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
f'{{"profile_id": "{profile_id}", "profile_id": "{profile_id}"}}',
encoding="utf-8",
)
with pytest.raises(PluginCatalogError, match="Duplicate JSON key"):
DevicePluginCatalog(tmp_path).manifests()
def test_v1alpha2_catalog_rejects_profile_for_unknown_model(tmp_path: Path) -> None:
document = _v1alpha2_manifest("example.unknown", "example.known-model")
link = document["spec"]["compatibilityProfiles"][0]
profile_id = link["profileId"]
link["modelId"] = "example.unknown-model"
_write_manifest(tmp_path, "unknown", document)
_write_profile(tmp_path, "unknown", profile_id)
with pytest.raises(PluginCatalogError, match="references unknown model"):
DevicePluginCatalog(tmp_path).manifests()
def test_catalog_rejects_duplicate_model_ids(tmp_path: Path) -> None:
_write_manifest(tmp_path, "first", _manifest("example.first", "example.model"))
_write_manifest(tmp_path, "second", _manifest("example.second", "example.model"))

View File

@ -31,6 +31,7 @@ from k1link.web.xgrids_k1_facade import (
ACTION_STREAM_STOP,
ACTION_VIEWER_SETTINGS_UPDATE,
XGRIDS_K1_PLUGIN_ID,
CompatibilityAttestationRequest,
ConnectRequest,
ViewerSettingsRequest,
XgridsK1PluginFacade,
@ -53,8 +54,13 @@ class FakeXgridsService:
self.calls.append(("connect", request))
return {"phase": "connected", "k1_ip": "192.168.1.20"}
def start_live(self, host: str | None, duration_seconds: float) -> dict[str, Any]:
self.calls.append(("live", (host, duration_seconds)))
def start_live(
self,
host: str | None,
duration_seconds: float,
compatibility_attestation: CompatibilityAttestationRequest,
) -> dict[str, Any]:
self.calls.append(("live", (host, duration_seconds, compatibility_attestation)))
return {"phase": "live"}
def start_replay(self, path: str, speed: float, loop: bool) -> dict[str, Any]:
@ -271,7 +277,12 @@ def test_facade_validates_payload_before_calling_service() -> None:
dispatcher.invoke(
XGRIDS_K1_PLUGIN_ID,
ACTION_NETWORK_PROVISION,
{"device_id": "id", "ssid": "network", "password": "secret", "extra": True},
{
"device_id": "id",
"ssid": "network",
"password": "x" * 24,
"extra": True,
},
)
)
@ -281,7 +292,18 @@ def test_facade_validates_payload_before_calling_service() -> None:
@pytest.mark.parametrize(
("action_id", "payload", "expected_call"),
[
(ACTION_STREAM_START_LIVE, {"host": "192.168.1.20"}, "live"),
(
ACTION_STREAM_START_LIVE,
{
"host": "192.168.1.20",
"compatibility_attestation": {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"operator_confirmed": True,
},
},
"live",
),
(
ACTION_STREAM_START_REPLAY,
{"path": "sessions/capture.k1mqtt", "speed": 1, "loop": False},

View File

@ -0,0 +1,364 @@
from __future__ import annotations
import base64
import hashlib
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from missioncore_plugin_sdk import v0alpha2 as sdk
from pydantic import TypeAdapter, ValidationError
NOW = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
def _model() -> sdk.DeviceModelRef:
return sdk.DeviceModelRef(
plugin_id="nodedc.device.synthetic",
plugin_version="0.2.0",
model_id="synthetic.sensor",
)
def _device() -> sdk.DeviceInstanceRef:
return sdk.DeviceInstanceRef(
device_id="synthetic.device-001",
model=_model(),
stability=sdk.IdentityStability.STABLE,
basis=sdk.IdentityBasis.PLUGIN_DERIVED,
)
def _session_ref() -> sdk.DeviceSessionRef:
return sdk.DeviceSessionRef(
session_id="session-001",
device_id="synthetic.device-001",
)
def _header() -> sdk.CanonicalStreamHeader:
return sdk.CanonicalStreamHeader(
message_id="message-001",
session=_session_ref(),
acquisition_id="acquisition-001",
source_id="lidar-primary",
channel_id="points",
sequence=1,
captured_at=NOW,
observed_at=NOW + timedelta(milliseconds=3),
clock_domain=sdk.ClockDomain.UTC,
compatibility_profile_id="synthetic.fw-1.v1",
)
def _inline_payload(data: bytes) -> sdk.InlinePayload:
return sdk.InlinePayload(
media_type="application/x-pointcloud-struct",
byte_length=len(data),
sha256=f"sha256:{hashlib.sha256(data).hexdigest()}",
data=base64.b64encode(data).decode("ascii"),
)
def test_plugin_sdk_v0alpha2_identity_separates_transport_alias_from_stable_id() -> None:
alias = sdk.TransportAlias(
transport="ble.corebluetooth",
scope_id="agent.macbook-01",
value="SYNTHETIC-K1-0001",
observed_at=NOW,
)
provisional = sdk.DeviceInstanceRef(
device_id="observation.ble-synthetic-k1-0001",
model=_model(),
stability=sdk.IdentityStability.PROVISIONAL,
basis=sdk.IdentityBasis.TRANSPORT_LOCAL,
aliases=(alias,),
)
assert provisional.aliases == (alias,)
with pytest.raises(ValidationError, match="transport-local identifier"):
sdk.DeviceInstanceRef(
device_id="incorrectly-stable",
model=_model(),
stability=sdk.IdentityStability.STABLE,
basis=sdk.IdentityBasis.TRANSPORT_LOCAL,
)
def test_plugin_sdk_v0alpha2_session_tracks_three_independent_state_machines() -> None:
context = sdk.DeviceSessionContext(
session_id="session-001",
device=_device(),
execution=sdk.ExecutionBinding(
node_id="mission-node.local-01",
agent_instance_id="agent-process-001",
platform=sdk.ExecutionPlatform.MACOS,
),
compatibility_profile_id="synthetic.fw-1.v1",
opened_at=NOW,
)
snapshot = sdk.DeviceSessionSnapshot(
context=context,
revision=4,
enrollment=sdk.EnrollmentState.ENROLLED,
connectivity=sdk.ConnectivityState.CONNECTED,
acquisition=sdk.AcquisitionState.IDLE,
observed_at=NOW,
active_operation_ids=("operation-health",),
)
assert snapshot.enrollment is sdk.EnrollmentState.ENROLLED
assert snapshot.connectivity is sdk.ConnectivityState.CONNECTED
assert snapshot.acquisition is sdk.AcquisitionState.IDLE
with pytest.raises(ValidationError, match="active operation ids must be unique"):
sdk.DeviceSessionSnapshot.model_validate(
{
**snapshot.model_dump(),
"active_operation_ids": ("operation-health", "operation-health"),
}
)
def test_plugin_sdk_v0alpha2_operation_ack_is_not_completion() -> None:
policy = sdk.OperationPolicy(
action_id="acquisition.start",
safety_class=sdk.OperationSafetyClass.STATE_CHANGING,
idempotency=sdk.IdempotencyMode.IDEMPOTENT_WITH_KEY,
timeout_seconds=30,
retry_limit=1,
preconditions=("device.connected", "calibration.ready"),
secret_fields=("device_credential",),
)
request = sdk.OperationRequest(
operation_id="operation-start-001",
session=_session_ref(),
action_id="acquisition.start",
requested_at=NOW,
deadline_at=NOW + timedelta(seconds=30),
idempotency_key="acquisition.start:001",
parameters={"project_id": "project-001"},
secret_refs={
"device_credential": sdk.SecretReference(
provider="missioncore.secret-store",
reference="devices/synthetic-001/credential",
)
},
)
sdk.validate_operation_request(policy, request)
adapter = TypeAdapter(sdk.OperationEvent)
acknowledgement = adapter.validate_python(
{
"event_type": "acknowledgement",
"operation_id": request.operation_id,
"session": request.session.model_dump(),
"sequence": 0,
"recorded_at": NOW,
"disposition": "accepted",
}
)
completion = adapter.validate_python(
{
"event_type": "completion",
"operation_id": request.operation_id,
"session": request.session.model_dump(),
"sequence": 2,
"recorded_at": NOW + timedelta(seconds=1),
"result": {"acquisition_id": "acquisition-001"},
}
)
assert isinstance(acknowledgement, sdk.OperationAcknowledgement)
assert isinstance(completion, sdk.OperationCompletion)
assert acknowledgement.event_type != completion.event_type
def test_plugin_sdk_v0alpha2_operation_policy_fails_closed() -> None:
with pytest.raises(ValidationError, match="automatic retries"):
sdk.OperationPolicy(
action_id="calibration.start",
safety_class=sdk.OperationSafetyClass.SAFETY_CRITICAL,
idempotency=sdk.IdempotencyMode.NON_IDEMPOTENT,
timeout_seconds=60,
retry_limit=1,
)
policy = sdk.OperationPolicy(
action_id="camera.catalog.read",
safety_class=sdk.OperationSafetyClass.READ_ONLY,
idempotency=sdk.IdempotencyMode.IDEMPOTENT,
timeout_seconds=5,
)
request = sdk.OperationRequest(
operation_id="operation-camera-001",
session=_session_ref(),
action_id="camera.preview.start",
requested_at=NOW,
deadline_at=NOW + timedelta(seconds=5),
)
with pytest.raises(ValueError, match="action_id"):
sdk.validate_operation_request(policy, request)
def test_plugin_sdk_v0alpha2_point_cloud_layout_and_payload_are_verified() -> None:
payload = _inline_payload(b"\0" * 24)
frame = sdk.PointCloudFrame(
header=_header(),
coordinate_frame="sensor.lidar-primary",
width=2,
point_count=2,
point_step=12,
row_step=24,
dense=False,
fields=(
sdk.PointField(name="x", offset=0, data_type=sdk.PointFieldType.FLOAT32),
sdk.PointField(name="y", offset=4, data_type=sdk.PointFieldType.FLOAT32),
sdk.PointField(name="z", offset=8, data_type=sdk.PointFieldType.FLOAT32),
),
payload=payload,
)
assert frame.payload.byte_length == frame.point_count * frame.point_step
with pytest.raises(ValidationError, match="point_count"):
sdk.PointCloudFrame.model_validate({**frame.model_dump(), "point_count": 3})
with pytest.raises(ValidationError, match="sha256"):
sdk.InlinePayload.model_validate({**payload.model_dump(), "sha256": f"sha256:{'0' * 64}"})
def test_plugin_sdk_v0alpha2_evidence_is_immutable_and_lineage_is_explicit() -> None:
handle = sdk.EvidenceHandle(
evidence_id="evidence.raw-001",
store_id="evidence-store.local",
media_type="application/vnd.tcpdump.pcap",
byte_length=1024,
sha256=f"sha256:{'a' * 64}",
object_key="experiments/2026-07-16/raw-001.pcap",
)
record = sdk.EvidenceRecord(
handle=handle,
plugin_id="nodedc.device.synthetic",
session=_session_ref(),
created_at=NOW,
source_kind="packet-capture",
retention=sdk.EvidenceRetention.EXPERIMENT,
redaction=sdk.RedactionState.UNREVIEWED,
)
assert record.handle.sha256.startswith("sha256:")
with pytest.raises(ValidationError, match="cannot name itself"):
sdk.EvidenceRecord.model_validate(
{**record.model_dump(), "parent_evidence_ids": [handle.evidence_id]}
)
with pytest.raises(ValidationError, match="frozen"):
record.redaction = sdk.RedactionState.CLEARED # type: ignore[misc]
def test_plugin_sdk_v0alpha2_contracts_are_deeply_immutable() -> None:
source_parameters = {
"pipeline": {
"stages": ["capture", "decode"],
"options": {"raw_first": True},
}
}
request = sdk.OperationRequest(
operation_id="operation-immutable-001",
session=_session_ref(),
action_id="acquisition.prepare",
requested_at=NOW,
deadline_at=NOW + timedelta(seconds=30),
parameters=source_parameters,
)
pipeline = request.parameters["pipeline"]
assert isinstance(pipeline, dict)
stages = pipeline["stages"]
assert isinstance(stages, list)
options = pipeline["options"]
assert isinstance(options, dict)
with pytest.raises(TypeError, match="deeply immutable"):
request.parameters["new"] = True
with pytest.raises(TypeError, match="deeply immutable"):
options["raw_first"] = False
with pytest.raises(TypeError, match="deeply immutable"):
stages.append("publish")
source_parameters["pipeline"]["stages"].append("outside-mutation")
assert stages == ["capture", "decode"]
wire_document = request.model_dump(mode="json")
assert wire_document["parameters"] == {
"pipeline": {
"stages": ["capture", "decode"],
"options": {"raw_first": True},
}
}
assert json.loads(json.dumps(wire_document))["parameters"] == wire_document["parameters"]
assert "parameters" in sdk.OperationRequest.model_json_schema()["properties"]
copy_update = {"parameters": {"pipeline": {"stages": ["publish"]}}}
copied = request.model_copy(update=copy_update)
copied_stages = copied.parameters["pipeline"]["stages"]
assert copied_stages == ["publish"]
with pytest.raises(TypeError, match="deeply immutable"):
copied_stages.append("mutate")
copy_update["parameters"]["pipeline"]["stages"].append("outside-mutation")
assert copied_stages == ["publish"]
with pytest.raises(ValidationError, match="extra"):
request.model_copy(update={"undeclared": True})
def test_plugin_sdk_v0alpha2_compatibility_blocks_unproven_control() -> None:
pass_rule = sdk.CompatibilityRuleResult(
rule_id="firmware.exact-match",
outcome=sdk.RuleOutcome.PASS,
blocking=True,
detail="Firmware and protocol fixture match the reviewed profile.",
)
assessment = sdk.CompatibilityAssessment(
assessment_id="compatibility-001",
device=_device(),
evaluated_at=NOW,
observed_firmware="1.0.0",
profile_id="synthetic.fw-1.v1",
decision=sdk.CompatibilityDecision.COMPATIBLE,
permitted_mode=sdk.PermittedMode.ACTIVE_CONTROL,
rules=(pass_rule,),
supported_actions=("acquisition.start", "acquisition.stop"),
)
assert assessment.permitted_mode is sdk.PermittedMode.ACTIVE_CONTROL
with pytest.raises(ValidationError, match="active control requires a compatible decision"):
sdk.CompatibilityAssessment.model_validate(
{
**assessment.model_dump(),
"decision": "unknown",
"permitted_mode": "active-control",
"profile_id": None,
}
)
def test_plugin_sdk_v0alpha2_exports_closed_json_schemas_and_vocabulary() -> None:
schemas = sdk.contract_json_schemas()
assert {
"DeviceInstanceRef",
"DeviceSessionSnapshot",
"OperationEvent",
"CanonicalStreamEnvelope",
"EvidenceRecord",
"CompatibilityAssessment",
}.issubset(schemas)
assert "oneOf" in schemas["OperationEvent"]
with pytest.raises(ValidationError, match="extra"):
sdk.DeviceSessionRef.model_validate(
{"session_id": "session-001", "device_id": "device-001", "vendor": "forbidden"}
)
repository_root = Path(__file__).resolve().parents[1]
vocabulary = (
repository_root / "docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md"
).read_text(encoding="utf-8")
assert "acknowledgement is not operation completion" in vocabulary
assert "not yet a NODE.DC platform ontology" in vocabulary

View File

@ -10,7 +10,9 @@ from pathlib import Path
import numpy as np
import pytest
from k1link.data_plane import DecodedDataPlaneView, NormalizationError
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.protocol.normalizer import normalize_k1_message
from k1link.viewer.messages import StreamMessage
from k1link.viewer.rerun_bridge import RerunBridge, RerunSceneSettings, _point_colors
from k1link.viewer.runtime import VisualizationRuntime
@ -53,6 +55,15 @@ def _message(topic: str, payload: bytes, *, sequence: int = 7) -> StreamMessage:
)
def _envelope(topic: str, payload: bytes, *, sequence: int = 7) -> DecodedDataPlaneView:
envelope = normalize_k1_message(
_message(topic, payload, sequence=sequence),
processing_started_monotonic_ns=time.monotonic_ns(),
)
assert envelope is not None
return envelope
def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
recording = FakeRecording()
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
@ -61,8 +72,8 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
"<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40
)
pose_payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
bridge.process(_message("RealtimePointcloud", point_payload))
bridge.process(_message("RealtimePath", pose_payload, sequence=8))
bridge.process(_envelope("RealtimePointcloud", point_payload))
bridge.process(_envelope("RealtimePath", pose_payload, sequence=8))
paths = [path for path, _, _ in recording.logs]
snapshot = bridge.metrics.snapshot()
@ -83,15 +94,19 @@ def test_legacy_points_and_pose_are_logged_to_rerun() -> None:
assert recording.disconnected is True
def test_bad_frame_is_counted_without_publishing() -> None:
def test_bad_frame_is_rejected_before_rerun_without_publishing() -> None:
recording = FakeRecording()
bridge = RerunBridge(recording_factory=lambda _: recording) # type: ignore[arg-type]
bridge.process(_message("RealtimePointcloud", b"short"))
with pytest.raises(NormalizationError):
normalize_k1_message(
_message("RealtimePointcloud", b"short"),
processing_started_monotonic_ns=time.monotonic_ns(),
)
snapshot = bridge.metrics.snapshot()
assert snapshot["messages_received"] == 1
assert snapshot["decode_errors"] == 1
assert snapshot["messages_received"] == 0
assert snapshot["decode_errors"] == 0
assert not any(path == "/world/points" for path, _, _ in recording.logs)
@ -193,6 +208,7 @@ def test_runtime_exposes_rerun_url_and_stops_cleanly(tmp_path: Path) -> None:
runtime = VisualizationRuntime(
bridge_factory=bridge_factory,
normalizer=normalize_k1_message,
)
runtime.start_replay(capture, speed=1.0)
deadline = time.monotonic() + 5.0
@ -258,7 +274,10 @@ def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) ->
**kwargs, # type: ignore[arg-type]
)
runtime = VisualizationRuntime(bridge_factory=blocked_factory)
runtime = VisualizationRuntime(
bridge_factory=blocked_factory,
normalizer=normalize_k1_message,
)
runtime.start_replay(capture, speed=0.0)
assert factory_entered.wait(timeout=2.0)
@ -306,7 +325,10 @@ def test_stop_fails_closed_when_runtime_thread_misses_deadline(tmp_path: Path) -
**kwargs, # type: ignore[arg-type]
)
runtime = VisualizationRuntime(bridge_factory=blocked_factory)
runtime = VisualizationRuntime(
bridge_factory=blocked_factory,
normalizer=normalize_k1_message,
)
runtime.start_replay(capture, speed=0.0)
assert factory_entered.wait(timeout=2.0)

View File

@ -113,20 +113,10 @@ def test_decode_lio_pcl_rejects_unverified_or_unsafe_frames() -> None:
def test_decode_lio_pose() -> None:
position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75)
orientation = (
_fixed64(1, 0.1)
+ _fixed64(2, 0.2)
+ _fixed64(3, 0.3)
+ _fixed64(4, 0.9)
)
orientation = _fixed64(1, 0.1) + _fixed64(2, 0.2) + _fixed64(3, 0.3) + _fixed64(4, 0.9)
pose = _bytes(1, position) + _bytes(2, orientation)
stamped = _sint(1, 987654321) + _bytes(2, pose)
payload = (
_bytes(1, _header())
+ _bytes(2, stamped)
+ _fixed32(3, 12.5)
+ _fixed32(4, 0.001)
)
payload = _bytes(1, _header()) + _bytes(2, stamped) + _fixed32(3, 12.5) + _fixed32(4, 0.001)
frame = decode_lio_pose(payload)
assert frame.pose_stamp == 987654321

View File

@ -77,15 +77,9 @@ def _pcl_payload(*, scaler: int, point_count: int) -> bytes:
def _pose_payload(position_xyz: tuple[float, float, float]) -> bytes:
position = b"".join(
_fixed64(field_number, value)
for field_number, value in enumerate(position_xyz, start=1)
)
orientation = (
_fixed64(1, 0.0)
+ _fixed64(2, 0.0)
+ _fixed64(3, 0.0)
+ _fixed64(4, 1.0)
_fixed64(field_number, value) for field_number, value in enumerate(position_xyz, start=1)
)
orientation = _fixed64(1, 0.0) + _fixed64(2, 0.0) + _fixed64(3, 0.0) + _fixed64(4, 1.0)
pose = _bytes(1, position) + _bytes(2, orientation)
stamped = _sint(1, 987654321) + _bytes(2, pose)
return _bytes(1, _header(scaler=1000)) + _bytes(2, stamped)

View File

@ -19,8 +19,8 @@ def test_ble_scan_exposes_every_device_and_only_labels_likely_k1(
"devices": [
{
"macos_uuid": "K1-UUID",
"name": "XGR-A46BE7",
"local_name": "XGR-A46BE7",
"name": "XGR-TEST01",
"local_name": "XGR-TEST01",
"rssi": -51,
"k1_name_candidate": True,
},

View File

@ -0,0 +1,91 @@
from __future__ import annotations
import asyncio
import json
from typing import Any
import pytest
from k1link.web.app import INVALID_REQUEST_DETAIL, app
from k1link.web.xgrids_k1_facade import XGRIDS_K1_PLUGIN_ID
async def _post_json(path: str, payload: dict[str, Any]) -> tuple[int, str]:
body = json.dumps(payload).encode()
request_sent = False
response_messages: list[dict[str, Any]] = []
async def receive() -> dict[str, Any]:
nonlocal request_sent
if request_sent:
return {"type": "http.disconnect"}
request_sent = True
return {"type": "http.request", "body": body, "more_body": False}
async def send(message: dict[str, Any]) -> None:
response_messages.append(message)
scope: dict[str, Any] = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"root_path": "",
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode()),
],
"client": ("127.0.0.1", 41000),
"server": ("127.0.0.1", 8765),
}
await app(scope, receive, send)
start = next(
message for message in response_messages if message["type"] == "http.response.start"
)
response_body = b"".join(
message.get("body", b"")
for message in response_messages
if message["type"] == "http.response.body"
)
return int(start["status"]), response_body.decode()
@pytest.mark.parametrize(
("path", "wrap_input"),
[
(
f"/api/v1/device-plugins/{XGRIDS_K1_PLUGIN_ID}/actions/network.provision",
True,
),
("/api/connect", False),
],
)
def test_validation_errors_do_not_echo_sensitive_request_values(
path: str,
wrap_input: bool,
) -> None:
sensitive_value = "x" * 300
action_input = {
"device_id": "synthetic-device",
"ssid": "synthetic-network",
"password": sensitive_value,
"compatibility_attestation": {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"operator_confirmed": True,
},
}
payload = {"input": action_input} if wrap_input else action_input
status_code, response_text = asyncio.run(_post_json(path, payload))
assert status_code == 422
assert json.loads(response_text) == {"detail": INVALID_REQUEST_DETAIL}
assert sensitive_value not in response_text
assert sensitive_value[:32] not in response_text
assert "input_value" not in response_text

View File

@ -8,31 +8,34 @@ from k1link.ble.wifi_provisioning import (
def test_build_wifi_provisioning_frame_layout() -> None:
frame = build_wifi_provisioning_frame("LabNet", "correct horse")
credential = "x" * 13
frame = build_wifi_provisioning_frame("LabNet", credential)
assert len(frame) == FRAME_LENGTH
assert frame[0] == 6
assert frame[1:7] == b"LabNet"
assert frame[7:33] == bytes(26)
assert frame[33] == 13
assert frame[34:47] == b"correct horse"
assert frame[34:47] == b"x" * 13
assert frame[47:98] == bytes(51)
assert frame[98] == 0
def test_build_wifi_provisioning_frame_uses_utf8_byte_lengths() -> None:
frame = build_wifi_provisioning_frame("Сеть", "пароль")
ssid = "Ж" * 4
credential = "я" * 6
frame = build_wifi_provisioning_frame(ssid, credential)
assert frame[0] == len("Сеть".encode())
assert frame[33] == len("пароль".encode())
assert frame[0] == len(ssid.encode())
assert frame[33] == len(credential.encode())
@pytest.mark.parametrize(
("ssid", "password", "message"),
[
("", "password", "SSID must not be empty"),
("", "x" * 8, "SSID must not be empty"),
("network", "", "password must not be empty"),
("x" * 33, "password", "at most 32 UTF-8 bytes"),
("x" * 33, "y" * 8, "at most 32 UTF-8 bytes"),
("network", "x" * 65, "at most 64 UTF-8 bytes"),
],
)

View File

@ -0,0 +1,697 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
import pytest
from pydantic import SecretStr
import k1link.web.xgrids_k1_facade as facade_module
from k1link.web.xgrids_k1_facade import (
DEFAULT_LIVE_STREAMS,
XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
AbortAcquisitionRequest,
CompatibilityAttestationRequest,
ConnectRequest,
PrepareAcquisitionRequest,
StartAcquisitionRequest,
StopAcquisitionRequest,
XgridsK1CompatibilityService,
)
ATTESTATION = CompatibilityAttestationRequest(
firmware_version="3.0.2",
topology="direct-lan",
operator_confirmed=True,
)
PRIMARY_TEST_CREDENTIAL = "x" * 24
SECONDARY_TEST_CREDENTIAL = "y" * 24
class FakeVisualizationRuntime:
def __init__(self) -> None:
self.phase = "idle"
self.source_mode = "idle"
self.source_ready = False
self.pcl_frames = 0
self.start_calls: list[tuple[str, Path, float]] = []
self.stop_calls = 0
self.stop_error: Exception | None = None
def snapshot(self) -> dict[str, Any]:
return {
"phase": self.phase,
"message": "test runtime",
"source_mode": self.source_mode,
"source_ready": self.source_ready,
"foxglove_ws_url": None,
"foxglove_viewer_url": None,
"rerun_grpc_url": None,
"viewer_settings": {},
"metrics": {
"messages_received": self.pcl_frames,
"payload_bytes": 0,
"pcl_frames": self.pcl_frames,
"pose_frames": 0,
"points_published": 0,
"last_point_count": 0,
"decode_errors": 0,
"preview_dropped": 0,
"pcl_fps": 0.0,
"pose_fps": 0.0,
"mqtt_to_publish_ms": None,
"mqtt_to_publish_p50_ms": None,
"mqtt_to_publish_p95_ms": None,
"decode_publish_ms": None,
"trajectory_poses": 0,
},
}
def start_live(self, host: str, out_dir: Path, *, duration_seconds: float) -> None:
self.start_calls.append((host, out_dir, duration_seconds))
self.phase = "starting_live"
self.source_mode = "live"
def mark_ready(self) -> None:
self.phase = "live"
self.source_ready = True
def stop(self) -> None:
self.stop_calls += 1
if self.stop_error is not None:
raise self.stop_error
self.phase = "idle"
self.source_mode = "idle"
self.source_ready = False
def service_with_fake_runtime(
tmp_path: Path,
) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]:
service = XgridsK1CompatibilityService(tmp_path)
runtime = FakeVisualizationRuntime()
service.runtime = runtime # type: ignore[assignment]
return service, runtime
def test_prepare_creates_provisional_device_session_and_profiled_acquisition(
tmp_path: Path,
) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
state = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
duration_seconds=60,
compatibility_attestation=ATTESTATION,
)
)
assert runtime.start_calls == []
assert state["device_ref"]["identity_stability"] == "provisional"
assert state["device_ref"]["device_id"] != state["device_session"]["device_session_id"]
assert state["acquisition"]["state"] == "prepared"
assert state["acquisition"]["compatibility_profile_id"] == (XGRIDS_K1_COMPATIBILITY_PROFILE_ID)
assert state["compatibility"]["vendor_writes_enabled"] is False
def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Path) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
duration_seconds=60,
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
starting = service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
assert starting["acquisition"]["state"] == "starting"
assert starting["last_operation"]["status"] == "running"
runtime.mark_ready()
awaiting = service.state()
assert awaiting["acquisition"]["state"] == "awaiting_external_start"
assert awaiting["last_operation"]["status"] == "operator_action_required"
assert len(runtime.start_calls) == 1
runtime.pcl_frames = 1
acquiring = service.state()
assert acquiring["acquisition"]["state"] == "acquiring"
assert acquiring["last_operation"]["status"] == "succeeded"
assert acquiring["last_operation"]["result"]["confirmation"] == "point-frame"
def test_receiver_completion_without_point_data_fails_start_operation(tmp_path: Path) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
duration_seconds=60,
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
runtime.phase = "idle"
runtime.source_mode = "idle"
failed = service.state()
assert failed["acquisition"]["state"] == "failed"
assert failed["last_operation"]["status"] == "failed"
assert failed["acquisition"]["result"]["device_state"] == "unknown"
def test_capture_only_stop_never_claims_that_physical_k1_stopped(tmp_path: Path) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
duration_seconds=60,
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
stopped = service.stop_acquisition(
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="capture-only")
)
assert runtime.stop_calls == 1
assert stopped["acquisition"]["state"] == "completed"
assert stopped["acquisition"]["result"] == {
"receiver_stopped": True,
"device_stop": "unknown",
}
operations = {item["action"]: item for item in stopped["operations"]}
assert operations["acquisition.start"]["status"] == "cancelled"
assert operations["acquisition.stop"]["status"] == "succeeded"
def test_graceful_stop_waits_for_explicit_operator_confirmation(tmp_path: Path) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
duration_seconds=60,
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
runtime.mark_ready()
service.state()
runtime.pcl_frames = 1
acquiring = service.state()
assert acquiring["acquisition"]["state"] == "acquiring"
awaiting = service.stop_acquisition(
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful")
)
operation_id = awaiting["last_operation"]["operation_id"]
assert runtime.stop_calls == 0
assert awaiting["acquisition"]["state"] == "awaiting_external_stop"
assert awaiting["last_operation"]["status"] == "operator_action_required"
retried = service.stop_acquisition(
StopAcquisitionRequest(
acquisition_id=acquisition_id,
operation_id=operation_id,
mode="graceful",
)
)
assert retried["acquisition"]["state"] == "awaiting_external_stop"
assert retried["last_operation"]["operation_id"] == operation_id
assert retried["last_operation"]["status"] == "operator_action_required"
with pytest.raises(ValueError, match="исходную stop-operation"):
service.stop_acquisition(
StopAcquisitionRequest(
acquisition_id=acquisition_id,
mode="graceful",
operator_confirmed=True,
)
)
completed = service.stop_acquisition(
StopAcquisitionRequest(
acquisition_id=acquisition_id,
operation_id=operation_id,
mode="graceful",
operator_confirmed=True,
)
)
assert runtime.stop_calls == 1
assert completed["acquisition"]["result"]["device_stop"] == "operator-confirmed"
stop_operations = [
item for item in completed["operations"] if item["action"] == "acquisition.stop"
]
assert len(stop_operations) == 1
assert stop_operations[0]["operation_id"] == operation_id
assert stop_operations[0]["status"] == "succeeded"
def test_graceful_stop_retry_by_idempotency_key_reuses_original_operation(
tmp_path: Path,
) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
runtime.mark_ready()
service.state()
runtime.pcl_frames = 1
service.state()
first = service.stop_acquisition(
StopAcquisitionRequest(
acquisition_id=acquisition_id,
idempotency_key="graceful-stop-once",
mode="graceful",
)
)
first_operation_id = first["last_operation"]["operation_id"]
retried = service.stop_acquisition(
StopAcquisitionRequest(
acquisition_id=acquisition_id,
idempotency_key="graceful-stop-once",
mode="graceful",
)
)
assert retried["last_operation"]["operation_id"] == first_operation_id
assert retried["last_operation"]["status"] == "operator_action_required"
assert (
len([item for item in retried["operations"] if item["action"] == "acquisition.stop"]) == 1
)
def test_unrelated_graceful_stop_is_rejected_while_confirmation_is_pending(
tmp_path: Path,
) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
runtime.mark_ready()
service.state()
runtime.pcl_frames = 1
service.state()
first = service.stop_acquisition(
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful")
)
expected_operation_id = first["last_operation"]["operation_id"]
with pytest.raises(ValueError, match="уже ожидает"):
service.stop_acquisition(
StopAcquisitionRequest(
acquisition_id=acquisition_id,
idempotency_key="unrelated-stop",
mode="graceful",
)
)
original = next(
item
for item in service.state()["operations"]
if item["operation_id"] == expected_operation_id
)
assert original["status"] == "operator_action_required"
def test_graceful_stop_is_rejected_until_point_data_confirms_acquisition(
tmp_path: Path,
) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
with pytest.raises(ValueError, match="подтверждённого потока point cloud"):
service.stop_acquisition(
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful")
)
state = service.state()
assert runtime.stop_calls == 0
assert state["acquisition"]["state"] == "starting"
assert state["last_operation"]["action"] == "acquisition.start"
assert state["last_operation"]["status"] == "running"
@pytest.mark.parametrize("evidence_policy", ["best-effort", "disabled"])
def test_prepare_rejects_unsupported_evidence_policies(
tmp_path: Path,
evidence_policy: str,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
with pytest.raises(ValueError, match="evidence_policy=required"):
service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
evidence_policy=evidence_policy, # type: ignore[arg-type]
compatibility_attestation=ATTESTATION,
)
)
assert service.state()["compatibility"]["profile_id"] is None
@pytest.mark.parametrize(
"requested_streams",
[
DEFAULT_LIVE_STREAMS[:-1],
(*DEFAULT_LIVE_STREAMS, DEFAULT_LIVE_STREAMS[0]),
],
)
def test_prepare_rejects_stream_subsets_and_duplicates(
tmp_path: Path,
requested_streams: tuple[str, ...],
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
with pytest.raises(ValueError, match="полный проверенный набор"):
service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
requested_streams=requested_streams, # type: ignore[arg-type]
compatibility_attestation=ATTESTATION,
)
)
def test_exact_profile_is_inactive_until_explicit_operator_attestation(
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
initial = service.state()
assert initial["compatibility"] == {
"profile_id": None,
"decision": "unknown",
"permitted_mode": "evidence-only",
"firmware_claim": "exact-3.0.2-profile-not-attested",
"attestation": None,
"vendor_writes_enabled": False,
"camera_preview": "unverified",
}
assert initial["device_calibration"]["compatibility_profile_id"] is None
attested = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
assert attested["compatibility"]["profile_id"] == XGRIDS_K1_COMPATIBILITY_PROFILE_ID
assert attested["compatibility"]["decision"] == "limited"
assert attested["compatibility"]["attestation"]["basis"] == "operator-attested"
assert attested["device_session"]["compatibility_profile_id"] == (
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
)
def test_prepare_rejects_device_ap_fallback_as_direct_lan_target(tmp_path: Path) -> None:
service, _ = service_with_fake_runtime(tmp_path)
with pytest.raises(ValueError, match="точки доступа"):
service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.56.1",
compatibility_attestation=ATTESTATION,
)
)
state = service.state()
assert state["device_ref"] is None
assert state["compatibility"]["profile_id"] is None
def test_runtime_error_terminalizes_pending_graceful_stop(tmp_path: Path) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
runtime.mark_ready()
service.state()
runtime.pcl_frames = 1
service.state()
awaiting = service.stop_acquisition(
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful")
)
stop_operation_id = awaiting["last_operation"]["operation_id"]
runtime.phase = "error"
failed = service.state()
stop_operation = next(
item for item in failed["operations"] if item["operation_id"] == stop_operation_id
)
assert failed["acquisition"]["state"] == "failed"
assert stop_operation["status"] == "failed"
assert stop_operation["error"]["side_effect_status"] == "unknown"
def test_receiver_completion_terminalizes_unconfirmed_graceful_stop(
tmp_path: Path,
) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
runtime.mark_ready()
service.state()
runtime.pcl_frames = 1
service.state()
awaiting = service.stop_acquisition(
StopAcquisitionRequest(acquisition_id=acquisition_id, mode="graceful")
)
stop_operation_id = awaiting["last_operation"]["operation_id"]
runtime.phase = "idle"
runtime.source_mode = "idle"
runtime.source_ready = False
failed = service.state()
stop_operation = next(
item for item in failed["operations"] if item["operation_id"] == stop_operation_id
)
assert failed["acquisition"]["state"] == "failed"
assert failed["acquisition"]["result"] == {
"receiver_stopped": True,
"device_state": "unknown",
}
assert stop_operation["status"] == "failed"
assert stop_operation["error"]["code"] == ("receiver-completed-before-device-stop-confirmation")
def test_abort_failure_terminalizes_acquisition_and_pending_start(tmp_path: Path) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
acquisition_id = prepared["acquisition"]["acquisition_id"]
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
runtime.stop_error = RuntimeError("synthetic receiver stop failure")
with pytest.raises(RuntimeError, match="synthetic receiver stop failure"):
service.abort_acquisition(AbortAcquisitionRequest(acquisition_id=acquisition_id))
state = service.state()
operations = {item["action"]: item for item in state["operations"]}
assert state["acquisition"]["state"] == "failed"
assert state["acquisition"]["result"] == {
"receiver_stopped": False,
"device_state": "unknown",
}
assert operations["acquisition.start"]["status"] == "cancelled"
assert operations["acquisition.abort"]["status"] == "failed"
assert state["source_mode"] == "live"
def test_replay_is_rejected_during_nonterminal_acquisition(tmp_path: Path) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
with pytest.raises(RuntimeError, match="активной acquisition-сессии"):
service.start_replay("sessions/fixture.k1mqtt", speed=1.0, loop=False)
assert service.state()["acquisition"]["state"] == "prepared"
def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_boundary(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [ # noqa: SLF001 - deliberate white-box concurrency fixture
{"device_id": "k1-a"},
{"device_id": "k1-b"},
]
boundary_calls: list[tuple[str, str, str]] = []
async def scenario() -> dict[str, Any]:
entered = asyncio.Event()
release = asyncio.Event()
async def fake_provision(
device_id: str,
ssid: str,
password: str,
**_: object,
) -> dict[str, Any]:
boundary_calls.append((device_id, ssid, password))
entered.set()
await release.wait()
return {
"started_at_utc": "2026-07-16T12:00:00Z",
"completed_at_utc": "2026-07-16T12:00:01Z",
"profile_id": "xgrids-k1-fw3-wifi-v1",
"outcome": "lan_address_observed",
"observations": [{"status": {"ipv4": "192.168.1.20"}}],
}
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
first = asyncio.create_task(
service.connect(
ConnectRequest(
device_id="k1-a",
ssid="lab-network",
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
compatibility_attestation=ATTESTATION,
)
)
)
await asyncio.wait_for(entered.wait(), timeout=1.0)
with pytest.raises(RuntimeError, match="уже выполняется"):
await service.connect(
ConnectRequest(
device_id="k1-b",
ssid="other-network",
password=SecretStr(SECONDARY_TEST_CREDENTIAL),
compatibility_attestation=ATTESTATION,
)
)
release.set()
return await asyncio.wait_for(first, timeout=1.0)
connected = asyncio.run(scenario())
assert boundary_calls == [("k1-a", "lab-network", PRIMARY_TEST_CREDENTIAL)]
assert connected["k1_ip"] == "192.168.1.20"
assert PRIMARY_TEST_CREDENTIAL not in str(connected)
provision_operations = [
item for item in connected["operations"] if item["action"] == "network.provision"
]
assert {item["status"] for item in provision_operations} == {"succeeded", "failed"}
def test_provisioning_cannot_switch_device_during_active_acquisition(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
called = False
async def should_not_run(*_: object, **__: object) -> dict[str, Any]:
nonlocal called
called = True
raise AssertionError("provisioning boundary must not be reached")
monkeypatch.setattr(facade_module, "provision_wifi_once", should_not_run)
with pytest.raises(RuntimeError, match="активной acquisition-сессии"):
asyncio.run(
service.connect(
ConnectRequest(
device_id="k1-a",
ssid="lab-network",
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
compatibility_attestation=ATTESTATION,
)
)
)
assert called is False
def test_sensor_catalog_exposes_observed_camera_only_after_profile_attestation(
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
initial = service.state()
initial_camera = next(
stream
for stream in initial["sensor_catalog"]["streams"]
if stream["stream_id"] == "camera.preview.live"
)
assert initial_camera["availability"] == "unverified"
state = service.prepare_acquisition(
PrepareAcquisitionRequest(
host="192.168.1.20",
compatibility_attestation=ATTESTATION,
)
)
camera = next(
stream
for stream in state["sensor_catalog"]["streams"]
if stream["stream_id"] == "camera.preview.live"
)
assert camera["availability"] == "observed"
assert camera["modality"] == "encoded-video"
assert camera["decode_status"] == "transport-observed-runtime-adapter-pending"
assert state["connection_verification"]["network_reachability"] == "unknown"
assert state["device_calibration"]["status"] == "unavailable"
assert state["device_calibration"]["vehicle_extrinsics"] == ("host-domain-not-owned-by-plugin")

View File

@ -0,0 +1,196 @@
from __future__ import annotations
import copy
import importlib.util
from pathlib import Path
from types import ModuleType
from typing import Any
import pytest
REPOSITORY_ROOT = Path(__file__).parents[1]
LOADER_PATH = REPOSITORY_ROOT / "plugins" / "xgrids-k1" / "profile_loader.py"
def _load_module() -> ModuleType:
spec = importlib.util.spec_from_file_location("xgrids_k1_profile_loader", LOADER_PATH)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
LOADER = _load_module()
def _by_id(items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {item["id"]: item for item in items}
def _boolean_write_flags(value: Any) -> list[bool]:
flags: list[bool] = []
if isinstance(value, dict):
write_enabled = value.get("write_enabled")
if isinstance(write_enabled, bool):
flags.append(write_enabled)
for child in value.values():
flags.extend(_boolean_write_flags(child))
elif isinstance(value, list):
for child in value:
flags.extend(_boolean_write_flags(child))
return flags
def test_xgrids_compatibility_profile_loads_exact_firmware_and_sources() -> None:
profile = LOADER.load_compatibility_profile()
assert profile["profile_id"] == "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
assert profile["scope"]["firmware"] == {"match": "exact", "version": "3.0.2"}
assert profile["scope"]["topology"] == "direct-lan"
assert LOADER.matches_target(profile, firmware="3.0.2", topology="direct-lan")
assert not LOADER.matches_target(profile, firmware="3.0.3", topology="direct-lan")
assert not LOADER.matches_target(profile, firmware="3.0.2", topology="device-ap")
for source in profile["evidence_sources"]:
assert (REPOSITORY_ROOT / source["path"]).is_file()
def test_xgrids_compatibility_profile_keeps_evidence_levels_independent() -> None:
profile = LOADER.load_compatibility_profile()
channels = _by_id(profile["channels"])
verified_stream = {
"observed": True,
"decoded": True,
"replay_verified": True,
"physical_verified": True,
"write_enabled": False,
}
raw_status = {
"observed": True,
"decoded": False,
"replay_verified": False,
"physical_verified": True,
"write_enabled": False,
}
observed_raw = {
"observed": True,
"decoded": False,
"replay_verified": False,
"physical_verified": True,
"write_enabled": False,
}
assert channels["spatial.point-cloud.live"]["evidence"] == verified_stream
assert channels["spatial.pose.live"]["evidence"] == verified_stream
assert channels["device.status.live"]["evidence"] == raw_status
assert channels["device.heartbeat.live"]["evidence"] == raw_status
assert channels["device.status.live"]["semantic_payload"] is None
camera = channels["camera.preview.live"]
assert camera["discovery_status"] == "observed"
assert camera["topic"] is None
assert set(camera["endpoint_templates"]) == {
"rtsp://{confirmed-device-private-ipv4}:8554/live/chn_left_main",
"rtsp://{confirmed-device-private-ipv4}:8554/live/chn_right_main",
}
assert "H.264" in camera["wire_format"]
assert camera["evidence"] == observed_raw
def test_xgrids_compatibility_profile_declares_only_reviewed_transports() -> None:
profile = LOADER.load_compatibility_profile()
transports = _by_id(profile["transports"])
ble = transports["ble.wifi-bootstrap.fw3.v1"]
assert ble["service_uuid"] == "00007f00-0000-1000-8000-00805f9b34fb"
assert ble["characteristics"] == {
"wifi_request": "00007f01-0000-1000-8000-00805f9b34fb",
"wifi_status": "00007f02-0000-1000-8000-00805f9b34fb",
}
assert ble["request_frame_bytes"] == 99
assert ble["evidence"]["observed"] is True
assert ble["evidence"]["physical_verified"] is True
assert ble["evidence"]["write_enabled"] is False
mqtt = transports["mqtt.direct-lan.fw3.v1"]
assert mqtt["protocol"] == "MQTT 3.1.1"
assert mqtt["network"]["transport"] == "TCP"
assert mqtt["network"]["port"] == 1883
assert mqtt["network"]["tls"] is False
assert "lixel/application/report/#" in mqtt["subscription_allowlist"]
assert not any("/request/" in topic for topic in mqtt["subscription_allowlist"])
assert mqtt["evidence"]["observed"] is True
assert mqtt["evidence"]["decoded"] is False
assert mqtt["evidence"]["write_enabled"] is False
rtsp = transports["rtsp.camera-preview.fw3.v1"]
assert rtsp["protocol"] == "RTSP 1.0 with interleaved RTP over TCP"
assert rtsp["network"]["port"] == 8554
assert rtsp["media"]["codec"] == "H.264"
assert rtsp["media"]["rtp_payload_type"] == 96
assert rtsp["evidence"]["observed"] is True
assert rtsp["evidence"]["write_enabled"] is False
def test_xgrids_compatibility_profile_maps_actions_without_enabling_writes() -> None:
profile = LOADER.load_compatibility_profile()
control = profile["acquisition_control"]
actions = _by_id(control["semantic_actions"])
assert profile["safety"]["default_mode"] == "read-only"
assert profile["safety"]["vendor_writes_enabled"] is False
assert control["mode"] == "operator-manual"
assert control["verified_device_control"]["gesture"] == "physical-double-click"
for action_id, action_code in (("acquisition.start", 1), ("acquisition.stop", 2)):
action = actions[action_id]
mapping = action["vendor_request_mapping"]
assert action["execution"] == "operator-manual"
assert mapping["evidence_kind"] == "owner-controlled-wire-observation"
assert mapping["topic"] == "lixel/application/request/modeling"
assert mapping["qos"] == 2
assert mapping["message_type"] == "ModelingRequest"
assert mapping["action_field_value"] == action_code
assert mapping["required_unresolved_context"]
assert mapping["evidence"]["observed"] is True
assert mapping["evidence"]["decoded"] is True
assert mapping["evidence"]["physical_verified"] is True
assert mapping["evidence"]["replay_verified"] is False
assert mapping["write_enabled"] is False
calibration = actions["calibration.device.start"]
assert calibration["execution"] == "unavailable"
assert calibration["vendor_request_mapping"] is None
assert calibration["evidence"]["observed"] is False
assert _boolean_write_flags(profile)
assert not any(_boolean_write_flags(profile))
def test_xgrids_compatibility_profile_rejects_vendor_write_promotion() -> None:
profile = LOADER.load_compatibility_profile()
modified = copy.deepcopy(profile)
actions = _by_id(modified["acquisition_control"]["semantic_actions"])
actions["acquisition.start"]["vendor_request_mapping"]["write_enabled"] = True
with pytest.raises(LOADER.CompatibilityProfileError, match="must remain false"):
LOADER.validate_compatibility_profile(modified)
def test_xgrids_compatibility_profile_rejects_claimed_camera_endpoint() -> None:
profile = LOADER.load_compatibility_profile()
modified = copy.deepcopy(profile)
channels = _by_id(modified["channels"])
channels["camera.preview.live"]["endpoint_templates"].append("rtsp://unverified")
with pytest.raises(LOADER.CompatibilityProfileError, match="camera endpoint templates"):
LOADER.validate_compatibility_profile(modified)
def test_xgrids_compatibility_profile_rejects_duplicate_json_keys(tmp_path: Path) -> None:
profile_path = tmp_path / "duplicate.json"
profile_path.write_text('{"schema_version": 1, "schema_version": 1}', encoding="utf-8")
with pytest.raises(LOADER.CompatibilityProfileError, match="duplicate JSON key"):
LOADER.load_compatibility_profile(profile_path)

13
uv.lock
View File

@ -237,6 +237,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "missioncore-plugin-sdk"
version = "0.2.0a2"
source = { editable = "packages/plugin-sdk" }
dependencies = [
{ name = "pydantic" },
]
[package.metadata]
requires-dist = [{ name = "pydantic", specifier = ">=2.11,<3" }]
[[package]]
name = "mypy"
version = "1.20.2"
@ -277,6 +288,7 @@ dependencies = [
{ name = "fastapi" },
{ name = "foxglove-sdk" },
{ name = "lz4" },
{ name = "missioncore-plugin-sdk" },
{ name = "paho-mqtt" },
{ name = "rerun-sdk" },
{ name = "rich" },
@ -297,6 +309,7 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.116,<1" },
{ name = "foxglove-sdk", specifier = "==0.25.3" },
{ name = "lz4", specifier = ">=4.4,<5" },
{ name = "missioncore-plugin-sdk", editable = "packages/plugin-sdk" },
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
{ name = "rerun-sdk", specifier = "==0.34.1" },
{ name = "rich", specifier = ">=13.9,<15" },