chore(node): preserve pre-canonicalization experiment snapshot

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